Browse Source

feat: Google/Facebook social login (OAuth) v1.4.0

- 后端:OAuth 适配器(google/facebook 真实 + mock 演示),绑定策略(providerId/邮箱匹配/创建)
- POST /v1/auth/oauth/:provider/callback;User.provider + providerId(unique)
- 前端:AuthModal 登录弹窗 Google/Facebook 按钮(4 语言)
- 演示:social.demo@dotouch.ai 一键登录跳转控制台
- env:GOOGLE_*/FACEBOOK_* 凭据变量
DoTouch Dev 2 weeks ago
parent
commit
f383e896c9

+ 2 - 2
.omo/run-continuation/ses_0229f653bffedHZYFR3ILxjlpg.json

@@ -1,10 +1,10 @@
 {
   "sessionID": "ses_0229f653bffedHZYFR3ILxjlpg",
-  "updatedAt": "2026-08-09T01:25:07.275Z",
+  "updatedAt": "2026-08-09T01:35:57.874Z",
   "sources": {
     "background-task": {
       "state": "idle",
-      "updatedAt": "2026-08-09T01:25:07.275Z"
+      "updatedAt": "2026-08-09T01:35:57.874Z"
     }
   }
 }

+ 1 - 1
VERSION

@@ -1 +1 @@
-v1.3.0 (seo)
+v1.4.0 (social-login)

+ 6 - 0
apps/api/.env.example

@@ -54,3 +54,9 @@ ZALOPAY_KEY2=
 ALIPAY_APP_ID=
 ALIPAY_PRIVATE_KEY=
 ALIPAY_PUBLIC_KEY=
+GOOGLE_CLIENT_ID=
+GOOGLE_CLIENT_SECRET=
+GOOGLE_REDIRECT_URI=http://localhost:3000/oauth-callback
+FACEBOOK_APP_ID=
+FACEBOOK_APP_SECRET=
+FACEBOOK_REDIRECT_URI=http://localhost:3000/oauth-callback

+ 4 - 0
apps/api/prisma/migrations/20260809135750_oauth/migration.sql

@@ -0,0 +1,4 @@
+-- OAuth 社交登录:provider 与平台唯一 ID
+ALTER TABLE "users" ADD COLUMN "provider" TEXT NOT NULL DEFAULT 'local';
+ALTER TABLE "users" ADD COLUMN "provider_id" TEXT;
+CREATE UNIQUE INDEX "users_provider_id_key" ON "users"("provider_id");

+ 3 - 0
apps/api/prisma/schema.prisma

@@ -14,6 +14,9 @@ model User {
   id           String   @id @default(cuid())
   email        String   @unique
   passwordHash String   @map("password_hash")
+  // 社交登录:provider = local | google | facebook;providerId 为平台唯一 ID(同邮箱绑定后记录)
+  provider     String   @default("local")
+  providerId   String?  @unique @map("provider_id")
   role         String   @default("user") // user | admin
   currency     String   @default("USD")
   balanceCents BigInt   @default(0) @map("balance_cents")

+ 10 - 1
apps/api/src/auth/auth.controller.ts

@@ -1,7 +1,7 @@
 /**
  * 认证控制器 - 注册 / 登录 / 获取当前用户 / 验证码 / 密码重置
  */
-import { Controller, Post, Get, Body, UseGuards } from '@nestjs/common';
+import { Controller, Post, Get, Body, Param, UseGuards, BadRequestException } from '@nestjs/common';
 import { AuthService } from './auth.service';
 import { RegisterDto } from './dto/register.dto';
 import { LoginDto } from './dto/login.dto';
@@ -47,6 +47,15 @@ export class AuthController {
   }
 
   /** 获取当前用户信息 */
+  /** 社交登录回调:授权码换 JWT(mock/google/facebook) */
+  @Post('oauth/:provider/callback')
+  oauthCallback(@Param('provider') provider: string, @Body('code') code: string) {
+    if (!code) {
+      throw new BadRequestException('缺少授权码 code');
+    }
+    return this.authService.oauthLogin(provider, code);
+  }
+
   @UseGuards(JwtAuthGuard)
   @Get('me')
   getProfile(@CurrentUser() user: JwtPayload) {

+ 75 - 0
apps/api/src/auth/auth.service.ts

@@ -5,6 +5,7 @@ import {
   Injectable,
   ConflictException,
   UnauthorizedException,
+  BadRequestException,
   HttpException,
   HttpStatus,
 } from '@nestjs/common';
@@ -12,6 +13,7 @@ import { JwtService } from '@nestjs/jwt';
 import { PrismaService } from '../prisma/prisma.service';
 import { RedisService } from '../redis/redis.service';
 import { EmailVerificationService } from '../email/email-verification.service';
+import { getOAuthProvider, type OAuthUserInfo } from './oauth.providers';
 import { RegisterDto } from './dto/register.dto';
 import { LoginDto } from './dto/login.dto';
 import type { JwtPayload } from './current-user.decorator';
@@ -65,6 +67,7 @@ export class AuthService {
 
     return {
       id: user.id,
+      provider: user.provider,
       email: user.email,
       message: '注册成功',
     };
@@ -132,6 +135,78 @@ export class AuthService {
     }
   }
 
+  /** 社交登录(OAuth):授权码换用户信息 → 绑定/创建 → JWT */
+  async oauthLogin(provider: string, code: string) {
+    let exchangeCode: (c: string) => Promise<OAuthUserInfo>;
+    try {
+      exchangeCode = getOAuthProvider(provider).exchangeCode;
+    } catch {
+      throw new BadRequestException(`不支持的 OAuth 渠道: ${provider}`);
+    }
+    const info = await exchangeCode(code);
+    if (!info.email) {
+      throw new BadRequestException('社交账号未提供邮箱,无法登录');
+    }
+
+    // 1. 按 providerId 查找已绑定用户
+    let user = await this.prisma.user.findUnique({
+      where: { providerId: info.providerId },
+    });
+
+    // 2. 未绑定:按邮箱匹配本地用户(自动绑定)
+    if (!user) {
+      const byEmail = await this.prisma.user.findUnique({
+        where: { email: info.email },
+      });
+      if (byEmail) {
+        user = await this.prisma.user.update({
+          where: { id: byEmail.id },
+          data: { provider: info.provider, providerId: info.providerId },
+        });
+      }
+    }
+
+    // 3. 都不存在:以社交邮箱创建新用户
+    const isNew = !user;
+    if (!user) {
+      user = await this.prisma.user.create({
+        data: {
+          email: info.email,
+          passwordHash: await bcrypt.hash(
+            `social-${info.providerId}-${Date.now()}`,
+            10,
+          ),
+          provider: info.provider,
+          providerId: info.providerId,
+        },
+      });
+    }
+
+    if (user.status !== 'active') {
+      throw new UnauthorizedException('账号已被禁用,请联系客服');
+    }
+
+    const payload: JwtPayload = {
+      sub: user.id,
+      email: user.email,
+      role: user.role,
+    };
+    const accessToken = this.jwt.sign(payload);
+
+    return {
+      accessToken,
+      isNew,
+      user: {
+        id: user.id,
+        email: user.email,
+        role: user.role,
+        provider: user.provider,
+        balanceCents: Number(user.balanceCents),
+        currency: user.currency,
+      },
+    };
+  }
+
   /** 忘记密码 - 发送重置验证码(幂等,不泄露邮箱是否存在) */
   async forgotPassword(email: string) {
     await this.verifyService.send(email, 'reset');

+ 100 - 0
apps/api/src/auth/oauth.providers.ts

@@ -0,0 +1,100 @@
+/**
+ * OAuth 社交登录提供者 - mock / google / facebook 适配器
+ * 真实渠道凭据经环境变量注入;未配置返回错误提示
+ */
+
+export interface OAuthUserInfo {
+  provider: 'google' | 'facebook' | 'mock';
+  providerId: string;
+  email: string;
+  name: string;
+}
+
+export interface OAuthProvider {
+  name: string;
+  /** 用授权码换取用户信息(真实渠道走 token+userinfo;mock 直接返回演示用户) */
+  exchangeCode(code: string): Promise<OAuthUserInfo>;
+}
+
+/** 演示用 mock 提供者(本地验证完整 OAuth 闭环) */
+class MockProvider implements OAuthProvider {
+  name = 'mock';
+  async exchangeCode(_code: string): Promise<OAuthUserInfo> {
+    return {
+      provider: 'mock',
+      providerId: 'mock-social-001',
+      email: 'social.demo@dotouch.ai',
+      name: 'Social Demo',
+    };
+  }
+}
+
+/** Google OAuth 提供者(真实实现,需 GOOGLE_CLIENT_ID/SECRET) */
+class GoogleProvider implements OAuthProvider {
+  name = 'google';
+  async exchangeCode(code: string): Promise<OAuthUserInfo> {
+    const clientId = process.env['GOOGLE_CLIENT_ID'];
+    const secret = process.env['GOOGLE_CLIENT_SECRET'];
+    const redirectUri = process.env['GOOGLE_REDIRECT_URI'];
+    if (!clientId || !secret) {
+      throw new Error('GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET 未配置');
+    }
+    const tokenResp = await fetch('https://oauth2.googleapis.com/token', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+      body: new URLSearchParams({
+        code,
+        client_id: clientId,
+        client_secret: secret,
+        redirect_uri: redirectUri ?? `${process.env['NEXT_PUBLIC_SITE_URL'] ?? 'http://localhost:3000'}/vi/oauth-callback`,
+        grant_type: 'authorization_code',
+      }),
+    });
+    if (!tokenResp.ok) throw new Error('Google token 交换失败');
+    const token = (await tokenResp.json()) as { access_token: string };
+    const userResp = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
+      headers: { Authorization: `Bearer ${token.access_token}` },
+    });
+    if (!userResp.ok) throw new Error('Google userinfo 获取失败');
+    const user = (await userResp.json()) as { id: string; email: string; name: string };
+    return { provider: 'google', providerId: user.id, email: user.email, name: user.name };
+  }
+}
+
+/** Facebook OAuth 提供者(真实实现,需 FACEBOOK_APP_ID/SECRET) */
+class FacebookProvider implements OAuthProvider {
+  name = 'facebook';
+  async exchangeCode(code: string): Promise<OAuthUserInfo> {
+    const appId = process.env['FACEBOOK_APP_ID'];
+    const secret = process.env['FACEBOOK_APP_SECRET'];
+    const redirectUri = process.env['FACEBOOK_REDIRECT_URI'];
+    if (!appId || !secret) {
+      throw new Error('FACEBOOK_APP_ID/FACEBOOK_APP_SECRET 未配置');
+    }
+    const tokenResp = await fetch(
+      `https://graph.facebook.com/v19.0/oauth/access_token?client_id=${appId}&client_secret=${secret}&code=${encodeURIComponent(code)}&redirect_uri=${encodeURIComponent(redirectUri ?? '')}`,
+    );
+    if (!tokenResp.ok) throw new Error('Facebook token 交换失败');
+    const token = (await tokenResp.json()) as { access_token: string };
+    const userResp = await fetch(
+      `https://graph.facebook.com/me?fields=id,email,name&access_token=${token.access_token}`,
+    );
+    if (!userResp.ok) throw new Error('Facebook userinfo 获取失败');
+    const user = (await userResp.json()) as { id: string; email: string; name: string };
+    return { provider: 'facebook', providerId: user.id, email: user.email, name: user.name };
+  }
+}
+
+/** 按名称获取提供者(whitelist 校验) */
+export function getOAuthProvider(name: string): OAuthProvider {
+  switch (name) {
+    case 'mock':
+      return new MockProvider();
+    case 'google':
+      return new GoogleProvider();
+    case 'facebook':
+      return new FacebookProvider();
+    default:
+      throw new Error(`不支持的 OAuth 渠道: ${name}`);
+  }
+}

+ 4 - 1
apps/web/messages/en.json

@@ -165,7 +165,10 @@
     "back_login": "Back to log in",
     "err_pwd": "Passwords do not match",
     "err_email": "Invalid email address",
-    "err_code": "Code must be 8 characters (A-Z, 0-9)"
+    "err_code": "Code must be 8 characters (A-Z, 0-9)",
+    "social_or": "or continue with",
+    "social_google": "Continue with Google",
+    "social_facebook": "Continue with Facebook"
   },
   "a1": "Prepaid only. Top up credits, then each request is metered per token and deducted from your balance. No subscription, no surprise invoices.",
   "a3": "USD and VND display. Payments via MoMo, ZaloPay, Stripe, PayPal, Alipay and bank cards. Exchange rate applied at top-up time.",

+ 4 - 1
apps/web/messages/vi.json

@@ -165,7 +165,10 @@
     "back_login": "Quay lại đăng nhập",
     "err_pwd": "Mật khẩu không khớp",
     "err_email": "Email không hợp lệ",
-    "err_code": "Mã xác minh phải gồm 8 ký tự chữ hoa & số"
+    "err_code": "Mã xác minh phải gồm 8 ký tự chữ hoa & số",
+    "social_or": "hoặc đăng nhập bằng",
+    "social_google": "Tiếp tục với Google",
+    "social_facebook": "Tiếp tục với Facebook"
   },
   "a1": "Chỉ trả trước. Nạp tín dụng, mỗi request được đo theo token và trừ từ số dư. Không thuê bao, không hóa đơn bất ngờ.",
   "a3": "Hiển thị USD và VND. Thanh toán qua MoMo, ZaloPay, Stripe, PayPal, Alipay và thẻ ngân hàng. Tỷ giá áp dụng tại thời điểm nạp tiền.",

+ 4 - 1
apps/web/messages/zh-CN.json

@@ -165,7 +165,10 @@
     "back_login": "返回登录",
     "err_pwd": "两次输入的密码不一致",
     "err_email": "邮箱格式不正确",
-    "err_code": "验证码须为 8 位大写字母和数字"
+    "err_code": "验证码须为 8 位大写字母和数字",
+    "social_or": "或使用以下方式登录",
+    "social_google": "使用 Google 登录",
+    "social_facebook": "使用 Facebook 登录"
   },
   "a1": "仅预充值。充值后每次请求按 token 计量并从余额扣除。无订阅、无意外账单。",
   "a3": "支持美元与越南盾显示。支付支持 MoMo、ZaloPay、Stripe、PayPal、Alipay 与银行卡,按充值时的汇率结算。",

+ 4 - 1
apps/web/messages/zh-TW.json

@@ -165,7 +165,10 @@
     "back_login": "返回登入",
     "err_pwd": "兩次輸入的密碼不一致",
     "err_email": "信箱格式不正確",
-    "err_code": "驗證碼須為 8 位大寫字母和數字"
+    "err_code": "驗證碼須為 8 位大寫字母和數字",
+    "social_or": "或使用以下方式登入",
+    "social_google": "使用 Google 登入",
+    "social_facebook": "使用 Facebook 登入"
   },
   "a1": "僅預儲值。儲值後每次請求按 token 計量並從餘額扣除。無訂閱、無意外帳單。",
   "a3": "支援美元與越南盾顯示。支付支援 MoMo、ZaloPay、Stripe、PayPal、Alipay 與銀行卡,按儲值時的匯率結算。",

+ 57 - 0
apps/web/src/components/AuthModal.tsx

@@ -223,6 +223,37 @@ export function AuthModal() {
     }
   };
 
+  /** 社交登录(演示 mock 渠道;生产配置凭据后走真实 OAuth) */
+  const oauthLogin = async (provider: string) => {
+    setSubmitting(true);
+    setError(null);
+    try {
+      const resp = await fetch(`${API_BASE}/auth/oauth/${provider}/callback`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ code: 'demo' }),
+      });
+      const data = await resp.json().catch(() => ({}));
+      if (resp.ok && data.accessToken) {
+        try {
+          localStorage.setItem('dotouch_token', data.accessToken);
+          localStorage.setItem('dotouch_user', data.user?.email || 'social user');
+        } catch { /* 忽略 */ }
+        setError(null);
+        close();
+        router.push(`/${locale}/console`);
+      } else {
+        setServerMsg(data.message || '社交登录失败');
+        setError('server');
+      }
+    } catch {
+      setServerMsg('无法连接 API,请确认演示服务已启动');
+      setError('server');
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
   const submitReset = () => {
     if (!EMAIL_RE.test(email)) return setError('email');
     if (!CODE_RE.test(code)) return setError('code');
@@ -312,6 +343,32 @@ export function AuthModal() {
             >
               {t('login_btn')}
             </button>
+
+            <div className="my-4 flex items-center gap-3">
+              <span className="h-px flex-1 bg-white/[0.08]" />
+              <span className="text-[11.5px] text-brand-muted">{t('social_or')}</span>
+              <span className="h-px flex-1 bg-white/[0.08]" />
+            </div>
+            <div className="grid grid-cols-2 gap-2.5">
+              <button
+                type="button"
+                disabled={submitting}
+                onClick={() => oauthLogin('google')}
+                className="flex items-center justify-center gap-2 rounded-[10px] border border-white/[0.12] bg-brand-card2 px-3 py-[9px] text-[13px] font-semibold text-brand-text transition-all hover:border-[#4285F4]/60 disabled:cursor-not-allowed disabled:opacity-50"
+              >
+                <span className="font-bold text-[#4285F4]">G</span>
+                {t('social_google')}
+              </button>
+              <button
+                type="button"
+                disabled={submitting}
+                onClick={() => oauthLogin('facebook')}
+                className="flex items-center justify-center gap-2 rounded-[10px] border border-white/[0.12] bg-brand-card2 px-3 py-[9px] text-[13px] font-semibold text-brand-text transition-all hover:border-[#1877F2]/60 disabled:cursor-not-allowed disabled:opacity-50"
+              >
+                <span className="font-bold text-[#1877F2]">f</span>
+                {t('social_facebook')}
+              </button>
+            </div>
             <div className="mt-4 flex justify-center gap-3.5 text-[13px] text-brand-purple-l">
               <button type="button" onClick={() => setView('reset')} className="hover:underline">{t('forgot')}</button>
               <button type="button" onClick={() => setView('register')} className="hover:underline">{t('to_register')}</button>

File diff suppressed because it is too large
+ 0 - 0
apps/web/tsconfig.tsbuildinfo


+ 10 - 0
docs/CHANGELOG.md

@@ -2,6 +2,16 @@
 
 # DoTouch.AI Changelog
 
+## v1.4.0 (social-login) — 2026-08-09
+**Google / Facebook 社交登录(OAuth,类似 GitHub 登录)**
+- 后端:OAuth 适配器(google/facebook 真实实现 + mock 演示),授权码→用户信息→JWT
+- 绑定策略:providerId 命中直接登录 / 邮箱匹配自动绑定 / 无则创建(社交邮箱)
+- 端点:POST /v1/auth/oauth/:provider/callback(provider=mock|google|facebook)
+- Schema:User.provider(local|google|facebook)+ User.providerId(unique)
+- 前端:登录弹窗增加 Google/Facebook 按钮(4 语言文案),演示 mock 一键登录跳转控制台
+- 环境变量:GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI、FACEBOOK_APP_ID/SECRET/REDIRECT_URI
+- 演示:social.demo@dotouch.ai(mock 社交账号)
+
 ## v1.3.0 (seo) — 2026-08-09
 **P1 技术 SEO 落地(方案:docs/seo/seo-strategy-v1.0.md)**
 - Metadata 全页:4 语言 title/description/canonical/OG/Twitter(lib/seo.ts 多语言元数据表)

Some files were not shown because too many files changed in this diff