diff --git a/backend/services/auth-service/.env.example b/backend/services/auth-service/.env.example index 74ef3ec..ad225eb 100644 --- a/backend/services/auth-service/.env.example +++ b/backend/services/auth-service/.env.example @@ -88,6 +88,66 @@ EMAIL_MAX_VERIFY_ATTEMPTS=5 # WECHAT_APP_ID=wx0000000000000000 # WECHAT_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# ── 支付宝 OAuth(移动应用) ───────────────────────────────────────────────── +# +# 申请步骤: +# 1. 登录支付宝开放平台 https://open.alipay.com +# → 控制台 → 创建应用 → 移动应用 → 填写 App 名称/图标/Bundle ID +# +# 2. 配置 RSA2 密钥对(SHA256WithRSA,推荐 2048 位) +# 下载「支付宝开放平台密钥工具」生成密钥对: +# https://opendocs.alipay.com/common/02kipl +# - 将「应用公钥」上传到支付宝开放平台 +# - 将「应用私钥(PKCS8 格式)」填入 ALIPAY_PRIVATE_KEY(Base64,无换行) +# - 在开放平台获取「支付宝公钥」(非应用公钥),填入 ALIPAY_PUBLIC_KEY +# +# 3. 添加功能:获取会员信息(alipay.user.info.share) +# 应用详情 → 添加功能 → 获取会员信息 → 签约(约1个工作日审核) +# +# 4. 获取 AppID(16位数字,如 2021003189xxxxxx) +# +# ALIPAY_APP_ID=2021003189xxxxxx +# ALIPAY_PRIVATE_KEY=MIIEvAIBADANBgkqhkiG9w0BAQEFAASC...(PKCS8 Base64,无换行) +# ALIPAY_PUBLIC_KEY=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMI...(支付宝公钥,无换行) +# ALIPAY_GATEWAY=openapi.alipay.com # 沙箱: openapi-sandbox.dl.alipaydev.com + +# ── Google Sign-In ──────────────────────────────────────────────────────────── +# +# 申请步骤: +# 1. 登录 Google Cloud Console https://console.cloud.google.com +# → 选择/创建项目 +# +# 2. 启用 API +# API 和服务 → 库 → 搜索「Google Sign-In API」→ 启用 +# +# 3. 创建 OAuth 2.0 凭据 +# API 和服务 → 凭据 → 创建凭据 → OAuth 2.0 客户端 ID +# 分别创建: +# Android 类型: 包名 cn.gogenex.consumer + SHA-1 指纹(debug/release 各一) +# SHA-1 获取: keytool -exportcert -keystore debug.keystore \ +# -alias androiddebugkey -storepass android | openssl sha1 -binary | xxd +# iOS 类型: Bundle ID cn.gogenex.consumer +# +# 4. 服务端获取一个「Web 应用」类型的 Client ID(可选,用于验证 aud 字段) +# 若不配置,GOOGLE_CLIENT_ID 可留空(跳过 aud 验证,安全性略降低) +# +# GOOGLE_CLIENT_ID=xxxxxxxxxx.apps.googleusercontent.com + +# ── Apple Sign In ───────────────────────────────────────────────────────────── +# +# 申请步骤: +# 1. Apple Developer 账号(个人 $99/年)https://developer.apple.com +# +# 2. 开启 Sign In with Apple 能力 +# Certificates, Identifiers & Profiles +# → Identifiers → 选择 App ID (cn.gogenex.consumer) +# → Capabilities → 勾选「Sign In with Apple」→ Save +# +# 3. iOS Xcode 配置 +# Target → Signing & Capabilities → + Capability → Sign In with Apple +# +# APPLE_CLIENT_ID=cn.gogenex.consumer # 填 Bundle ID 即可 + # ── Kafka (optional, events silently skipped if unavailable) ── KAFKA_BROKERS=localhost:9092 diff --git a/backend/services/auth-service/src/application/services/alipay.service.ts b/backend/services/auth-service/src/application/services/alipay.service.ts new file mode 100644 index 0000000..2fe11f6 --- /dev/null +++ b/backend/services/auth-service/src/application/services/alipay.service.ts @@ -0,0 +1,157 @@ +// ============================================================ +// AlipayService — 支付宝登录 / 注册业务逻辑 +// +// ── 完整流程 ────────────────────────────────────────────── +// 1. Flutter tobias.authCode() 拉起支付宝 App +// 用户确认授权 → 支付宝 App 回传 auth_code +// +// 2. 客户端将 auth_code 发送到 POST /api/v1/auth/alipay +// +// 3. 本服务调用 AlipayProvider.exchangeAuthCode(authCode) +// → 获得 access_token、user_id +// +// 4. 调用 AlipayProvider.getUserInfo(access_token) +// → 获得 nick_name、avatar、user_id +// +// 5. 用 user_id 在 social_accounts 表查找已有绑定 +// (支付宝 user_id 全局唯一,等同于微信的 unionid,无需两步查找) +// +// 6a. 老用户: 同步昵称/头像 → 返回 JWT +// 6b. 新用户: 创建 users + social_accounts → 发布 UserRegistered 事件 → 返回 JWT +// +// ── 支付宝 user_id 特性 ─────────────────────────────────── +// 支付宝的 user_id 是全局唯一的(2088 开头的16位数字), +// 同一用户在不同应用下 user_id 相同 → 直接用 user_id 作为 openid 查找即可, +// 无需像微信那样区分 openid vs unionid。 +// ============================================================ + +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { AlipayProvider } from '../../infrastructure/alipay/alipay.provider'; +import { SOCIAL_ACCOUNT_REPOSITORY, ISocialAccountRepository } from '../../domain/repositories/social-account.repository.interface'; +import { SocialProvider } from '../../domain/entities/social-account.entity'; +import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface'; +import { TokenService } from './token.service'; +import { EventPublisherService } from './event-publisher.service'; +import { UserRole, UserStatus } from '../../domain/entities/user.entity'; +import { AuthResult } from './auth.service'; +import { Password } from '../../domain/value-objects/password.vo'; +import * as crypto from 'crypto'; + +@Injectable() +export class AlipayService { + private readonly logger = new Logger('AlipayService'); + + constructor( + private readonly alipayProvider: AlipayProvider, + @Inject(SOCIAL_ACCOUNT_REPOSITORY) + private readonly socialAccountRepo: ISocialAccountRepository, + @Inject(USER_REPOSITORY) + private readonly userRepo: IUserRepository, + private readonly tokenService: TokenService, + private readonly eventPublisher: EventPublisherService, + ) {} + + async loginOrRegister( + authCode: string, + referralCode?: string, + deviceInfo?: string, + ipAddress?: string, + ): Promise { + // Step 1: auth_code → access_token + user_id + const tokenResp = await this.alipayProvider.exchangeAuthCode(authCode); + const { access_token, user_id } = tokenResp; + + // Step 2: 获取支付宝用户信息 + const userInfo = await this.alipayProvider.getUserInfo(access_token); + + const nickname = userInfo.nick_name?.trim() || `alipay_${user_id.slice(-6)}`; + const avatarUrl = userInfo.avatar || null; + const rawData = userInfo as unknown as Record; + + // Step 3: 查找已有绑定(支付宝 user_id 全局唯一,直接用 openid 查找) + let socialAccount = await this.socialAccountRepo.findByProviderAndOpenid( + SocialProvider.ALIPAY, + user_id, + ); + + if (socialAccount) { + // ── 老用户:同步最新信息 ── + socialAccount.nickname = nickname; + socialAccount.avatarUrl = avatarUrl; + await this.socialAccountRepo.save(socialAccount); + + const user = await this.userRepo.findById(socialAccount.userId); + if (!user || user.status !== UserStatus.ACTIVE) { + throw new Error('账号已禁用,请联系客服'); + } + + user.recordLoginSuccess(ipAddress); + await this.userRepo.save(user); + + const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel); + await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, deviceInfo, ipAddress); + + await this.eventPublisher.publishUserLoggedIn({ + userId: user.id, + ipAddress: ipAddress || null, + deviceInfo: deviceInfo || null, + timestamp: new Date().toISOString(), + }); + + this.logger.log(`Alipay login: userId=${user.id} alipayUserId=${user_id}`); + return { user: this.toUserDto(user), tokens }; + } + + // ── 新用户:自动注册 ── + const randomPw = await Password.create(crypto.randomBytes(32).toString('hex')); + const randomPasswordHash = randomPw.value; + + const user = await this.userRepo.create({ + phone: null, + email: null, + passwordHash: randomPasswordHash, + nickname, + role: UserRole.USER, + status: UserStatus.ACTIVE, + kycLevel: 0, + walletMode: 'standard', + }); + + await this.socialAccountRepo.create({ + userId: user.id, + provider: SocialProvider.ALIPAY, + openid: user_id, + nickname, + avatarUrl: avatarUrl || undefined, + rawData, + }); + + const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel); + await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, deviceInfo, ipAddress); + + await this.eventPublisher.publishUserRegistered({ + userId: user.id, + phone: null, + email: null, + role: user.role, + referralCode: referralCode?.toUpperCase() ?? null, + timestamp: new Date().toISOString(), + }); + + this.logger.log(`Alipay new user registered: userId=${user.id} alipayUserId=${user_id}`); + return { user: this.toUserDto(user), tokens }; + } + + private toUserDto(user: any) { + return { + id: user.id, + phone: user.phone ?? null, + email: user.email ?? null, + nickname: user.nickname ?? null, + avatarUrl: user.avatarUrl ?? null, + role: user.role, + kycLevel: user.kycLevel, + walletMode: user.walletMode, + }; + } +} diff --git a/backend/services/auth-service/src/application/services/apple.service.ts b/backend/services/auth-service/src/application/services/apple.service.ts new file mode 100644 index 0000000..aa46bf1 --- /dev/null +++ b/backend/services/auth-service/src/application/services/apple.service.ts @@ -0,0 +1,131 @@ +// ============================================================ +// AppleService — Apple Sign In 登录 / 注册业务逻辑 +// +// Apple sub(user identifier)是全局唯一的,直接用作 openid +// 注意: Apple 只在首次授权时返回 email,之后登录不再提供 +// → 首次注册时将 email 写入 users 表;后续登录无需更新 +// ============================================================ + +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { AppleProvider } from '../../infrastructure/apple/apple.provider'; +import { SOCIAL_ACCOUNT_REPOSITORY, ISocialAccountRepository } from '../../domain/repositories/social-account.repository.interface'; +import { SocialProvider } from '../../domain/entities/social-account.entity'; +import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface'; +import { TokenService } from './token.service'; +import { EventPublisherService } from './event-publisher.service'; +import { UserRole, UserStatus } from '../../domain/entities/user.entity'; +import { AuthResult } from './auth.service'; +import { Password } from '../../domain/value-objects/password.vo'; +import * as crypto from 'crypto'; + +@Injectable() +export class AppleService { + private readonly logger = new Logger('AppleService'); + + constructor( + private readonly appleProvider: AppleProvider, + @Inject(SOCIAL_ACCOUNT_REPOSITORY) + private readonly socialAccountRepo: ISocialAccountRepository, + @Inject(USER_REPOSITORY) + private readonly userRepo: IUserRepository, + private readonly tokenService: TokenService, + private readonly eventPublisher: EventPublisherService, + ) {} + + async loginOrRegister( + identityToken: string, + /** 客户端传入的 displayName(Apple 首次授权时用户可修改,之后不再提供) */ + displayName?: string, + referralCode?: string, + deviceInfo?: string, + ipAddress?: string, + ): Promise { + // Step 1: 验证 Apple Identity Token + const appleUser = await this.appleProvider.verifyIdentityToken(identityToken); + const { sub, email } = appleUser; + + const nickname = displayName?.trim() || `apple_${sub.slice(-6)}`; + const rawData = appleUser as unknown as Record; + + // Step 2: 查找已有绑定 + let socialAccount = await this.socialAccountRepo.findByProviderAndOpenid( + SocialProvider.APPLE, + sub, + ); + + if (socialAccount) { + // ── 老用户(Apple 后续登录昵称不更新,因为 Apple 不再提供) ── + const user = await this.userRepo.findById(socialAccount.userId); + if (!user || user.status !== UserStatus.ACTIVE) { + throw new Error('账号已禁用,请联系客服'); + } + + user.recordLoginSuccess(ipAddress); + await this.userRepo.save(user); + + const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel); + await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, deviceInfo, ipAddress); + + await this.eventPublisher.publishUserLoggedIn({ + userId: user.id, + ipAddress: ipAddress || null, + deviceInfo: deviceInfo || null, + timestamp: new Date().toISOString(), + }); + + this.logger.log(`Apple login: userId=${user.id} sub=${sub.slice(0, 6)}...`); + return { user: this.toUserDto(user), tokens }; + } + + // ── 新用户 ── + const randomPw = await Password.create(crypto.randomBytes(32).toString('hex')); + const randomPasswordHash = randomPw.value; + + const user = await this.userRepo.create({ + phone: null, + email: email || null, + passwordHash: randomPasswordHash, + nickname, + role: UserRole.USER, + status: UserStatus.ACTIVE, + kycLevel: 0, + walletMode: 'standard', + }); + + await this.socialAccountRepo.create({ + userId: user.id, + provider: SocialProvider.APPLE, + openid: sub, + nickname, + rawData, + }); + + const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel); + await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, deviceInfo, ipAddress); + + await this.eventPublisher.publishUserRegistered({ + userId: user.id, + phone: null, + email: email || null, + role: user.role, + referralCode: referralCode?.toUpperCase() ?? null, + timestamp: new Date().toISOString(), + }); + + this.logger.log(`Apple new user registered: userId=${user.id} sub=${sub.slice(0, 6)}...`); + return { user: this.toUserDto(user), tokens }; + } + + private toUserDto(user: any) { + return { + id: user.id, + phone: user.phone ?? null, + email: user.email ?? null, + nickname: user.nickname ?? null, + avatarUrl: user.avatarUrl ?? null, + role: user.role, + kycLevel: user.kycLevel, + walletMode: user.walletMode, + }; + } +} diff --git a/backend/services/auth-service/src/application/services/google.service.ts b/backend/services/auth-service/src/application/services/google.service.ts new file mode 100644 index 0000000..7fc2b13 --- /dev/null +++ b/backend/services/auth-service/src/application/services/google.service.ts @@ -0,0 +1,134 @@ +// ============================================================ +// GoogleService — Google 登录 / 注册业务逻辑 +// +// Google sub 字段 = 用户全局唯一 ID,直接作为 openid 存储 +// 每次登录同步 email、nickname(name)、avatarUrl(picture) +// ============================================================ + +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { GoogleProvider } from '../../infrastructure/google/google.provider'; +import { SOCIAL_ACCOUNT_REPOSITORY, ISocialAccountRepository } from '../../domain/repositories/social-account.repository.interface'; +import { SocialProvider } from '../../domain/entities/social-account.entity'; +import { USER_REPOSITORY, IUserRepository } from '../../domain/repositories/user.repository.interface'; +import { TokenService } from './token.service'; +import { EventPublisherService } from './event-publisher.service'; +import { UserRole, UserStatus } from '../../domain/entities/user.entity'; +import { AuthResult } from './auth.service'; +import { Password } from '../../domain/value-objects/password.vo'; +import * as crypto from 'crypto'; + +@Injectable() +export class GoogleService { + private readonly logger = new Logger('GoogleService'); + + constructor( + private readonly googleProvider: GoogleProvider, + @Inject(SOCIAL_ACCOUNT_REPOSITORY) + private readonly socialAccountRepo: ISocialAccountRepository, + @Inject(USER_REPOSITORY) + private readonly userRepo: IUserRepository, + private readonly tokenService: TokenService, + private readonly eventPublisher: EventPublisherService, + ) {} + + async loginOrRegister( + idToken: string, + referralCode?: string, + deviceInfo?: string, + ipAddress?: string, + ): Promise { + // Step 1: 验证 Google ID Token + const googleUser = await this.googleProvider.verifyIdToken(idToken); + const { sub, email, name, picture } = googleUser; + + const nickname = name?.trim() || `google_${sub.slice(-6)}`; + const avatarUrl = picture || null; + const rawData = googleUser as unknown as Record; + + // Step 2: 查找已有绑定(Google sub 全局唯一) + let socialAccount = await this.socialAccountRepo.findByProviderAndOpenid( + SocialProvider.GOOGLE, + sub, + ); + + if (socialAccount) { + // ── 老用户 ── + socialAccount.nickname = nickname; + socialAccount.avatarUrl = avatarUrl; + await this.socialAccountRepo.save(socialAccount); + + const user = await this.userRepo.findById(socialAccount.userId); + if (!user || user.status !== UserStatus.ACTIVE) { + throw new Error('账号已禁用,请联系客服'); + } + + user.recordLoginSuccess(ipAddress); + await this.userRepo.save(user); + + const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel); + await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, deviceInfo, ipAddress); + + await this.eventPublisher.publishUserLoggedIn({ + userId: user.id, + ipAddress: ipAddress || null, + deviceInfo: deviceInfo || null, + timestamp: new Date().toISOString(), + }); + + this.logger.log(`Google login: userId=${user.id} sub=${sub.slice(0, 6)}...`); + return { user: this.toUserDto(user), tokens }; + } + + // ── 新用户 ── + const randomPw = await Password.create(crypto.randomBytes(32).toString('hex')); + const randomPasswordHash = randomPw.value; + + const user = await this.userRepo.create({ + phone: null, + email: email || null, + passwordHash: randomPasswordHash, + nickname, + role: UserRole.USER, + status: UserStatus.ACTIVE, + kycLevel: 0, + walletMode: 'standard', + }); + + await this.socialAccountRepo.create({ + userId: user.id, + provider: SocialProvider.GOOGLE, + openid: sub, + nickname, + avatarUrl: avatarUrl || undefined, + rawData, + }); + + const tokens = await this.tokenService.generateTokenPair(user.id, user.role, user.kycLevel); + await this.tokenService.storeRefreshToken(user.id, tokens.refreshToken, deviceInfo, ipAddress); + + await this.eventPublisher.publishUserRegistered({ + userId: user.id, + phone: null, + email: email || null, + role: user.role, + referralCode: referralCode?.toUpperCase() ?? null, + timestamp: new Date().toISOString(), + }); + + this.logger.log(`Google new user registered: userId=${user.id} sub=${sub.slice(0, 6)}...`); + return { user: this.toUserDto(user), tokens }; + } + + private toUserDto(user: any) { + return { + id: user.id, + phone: user.phone ?? null, + email: user.email ?? null, + nickname: user.nickname ?? null, + avatarUrl: user.avatarUrl ?? null, + role: user.role, + kycLevel: user.kycLevel, + walletMode: user.walletMode, + }; + } +} diff --git a/backend/services/auth-service/src/auth.module.ts b/backend/services/auth-service/src/auth.module.ts index 3deea33..f9ae80f 100644 --- a/backend/services/auth-service/src/auth.module.ts +++ b/backend/services/auth-service/src/auth.module.ts @@ -47,12 +47,24 @@ import { GmailProvider } from './infrastructure/email/gmail.provider'; // WeChat import { WechatProvider } from './infrastructure/wechat/wechat.provider'; +// Alipay +import { AlipayProvider } from './infrastructure/alipay/alipay.provider'; + +// Google +import { GoogleProvider } from './infrastructure/google/google.provider'; + +// Apple +import { AppleProvider } from './infrastructure/apple/apple.provider'; + // Application services import { AuthService } from './application/services/auth.service'; import { TokenService } from './application/services/token.service'; import { SmsService } from './application/services/sms.service'; import { EmailService } from './application/services/email.service'; import { WechatService } from './application/services/wechat.service'; +import { AlipayService } from './application/services/alipay.service'; +import { GoogleService } from './application/services/google.service'; +import { AppleService } from './application/services/apple.service'; import { EventPublisherService } from './application/services/event-publisher.service'; // Interface controllers @@ -103,6 +115,9 @@ import { AdminSmsController } from './interface/http/controllers/admin-sms.contr SmsCodeService, EmailCodeService, WechatProvider, + AlipayProvider, + GoogleProvider, + AppleProvider, // Application services AuthService, @@ -110,8 +125,11 @@ import { AdminSmsController } from './interface/http/controllers/admin-sms.contr SmsService, EmailService, WechatService, + AlipayService, + GoogleService, + AppleService, EventPublisherService, ], - exports: [AuthService, TokenService, SmsService, EmailService, WechatService], + exports: [AuthService, TokenService, SmsService, EmailService, WechatService, AlipayService, GoogleService, AppleService], }) export class AuthModule {} diff --git a/backend/services/auth-service/src/domain/entities/social-account.entity.ts b/backend/services/auth-service/src/domain/entities/social-account.entity.ts index 783ce4d..57c89ee 100644 --- a/backend/services/auth-service/src/domain/entities/social-account.entity.ts +++ b/backend/services/auth-service/src/domain/entities/social-account.entity.ts @@ -44,8 +44,9 @@ import { /** 已支持的第三方登录 Provider */ export enum SocialProvider { WECHAT = 'wechat', - GOOGLE = 'google', // 预留,暂未实现 - APPLE = 'apple', // 预留,暂未实现 + ALIPAY = 'alipay', + GOOGLE = 'google', + APPLE = 'apple', } @Entity('social_accounts') diff --git a/backend/services/auth-service/src/infrastructure/alipay/alipay.provider.ts b/backend/services/auth-service/src/infrastructure/alipay/alipay.provider.ts new file mode 100644 index 0000000..55fc5f1 --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/alipay/alipay.provider.ts @@ -0,0 +1,233 @@ +// ============================================================ +// AlipayProvider — 支付宝开放平台 HTTP API 封装 +// +// ── 支付宝 OAuth 移动应用授权流程 ─────────────────────────── +// Step 1 (客户端 tobias 包): +// 调用 Alipay.authCode() 拉起支付宝 App +// 用户点「确认授权」→ 支付宝 App 回传 auth_code +// auth_code 一次性,有效期 3 分钟 +// +// Step 2 (本文件 exchangeAuthCode): +// POST https://openapi.alipay.com/gateway.do +// method=alipay.system.oauth.token +// grant_type=authorization_code&code=AUTH_CODE +// 返回: access_token, user_id, expires_in +// +// Step 3 (本文件 getUserInfo): +// POST https://openapi.alipay.com/gateway.do +// method=alipay.user.info.share +// auth_token=ACCESS_TOKEN +// 返回: user_id, nick_name, avatar +// +// ── 申请支付宝 AppID 步骤 ─────────────────────────────────── +// 1. 登录支付宝开放平台 +// https://open.alipay.com +// → 控制台 → 创建应用 → 移动应用 → 填写 App 名称 / 图标 / Bundle ID +// +// 2. 配置密钥 +// 应用详情 → 开发设置 → 接口加签方式 → RSA2(推荐) +// 用「支付宝开放平台密钥工具」生成 RSA2 密钥对(2048位): +// - 将「应用公钥」上传到开放平台 +// - 将「应用私钥」配置到服务器 .env(ALIPAY_PRIVATE_KEY) +// - 将「支付宝公钥」(平台提供的,用于验证响应签名)配置到 .env(ALIPAY_PUBLIC_KEY) +// +// 3. 开通「获取会员信息」接口 +// 应用详情 → 添加功能 → 获取会员信息(alipay.user.info.share) +// → 签约(需审核,通常1个工作日内) +// +// 4. 获取 AppID(16位数字,如 2021003189xxxxxx) +// +// ── 环境变量配置 ─────────────────────────────────────────── +// ALIPAY_APP_ID — 支付宝应用 AppID(16位数字) +// ALIPAY_PRIVATE_KEY — 应用 RSA2 私钥(PKCS8 格式,无换行符,Base64) +// ALIPAY_PUBLIC_KEY — 支付宝平台公钥(用于验证响应签名) +// ALIPAY_GATEWAY — 网关 URL(正式: openapi.alipay.com,沙箱: openapi-sandbox.dl.alipaydev.com) +// +// ── RSA2 签名算法(SHA256WithRSA)───────────────────────── +// 1. 将所有请求参数(除 sign)按 key 字典序升序排列 +// 2. 拼接为 key=value&key2=value2(不 URL 编码) +// 3. 用 RSA2 私钥(SHA256WithRSA)对拼接字符串签名 +// 4. Base64 编码签名结果后 URL 编码,加入 sign 参数 +// +// ── 常见错误码 ───────────────────────────────────────────── +// 40001: auth_code 无效或已使用 +// 40006: auth_code 已过期 +// 20001: alipay.user.info.share 接口未开通 +// ============================================================ + +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import * as https from 'https'; +import * as crypto from 'crypto'; +import * as querystring from 'querystring'; + +/** 支付宝 token 接口业务响应 */ +export interface AlipayTokenResponse { + user_id: string; + access_token: string; + expires_in: number; + refresh_token: string; + re_expires_in: number; +} + +/** 支付宝用户信息业务响应 */ +export interface AlipayUserInfo { + user_id: string; + nick_name?: string; + avatar?: string; + gender?: string; // m=男 f=女 + province?: string; + city?: string; + is_student_certified?: string; + user_type?: string; // 1=个人用户 2=企业用户 +} + +@Injectable() +export class AlipayProvider { + private readonly logger = new Logger('AlipayProvider'); + + private readonly appId = process.env.ALIPAY_APP_ID; + private readonly privateKey = process.env.ALIPAY_PRIVATE_KEY; + private readonly gateway = + process.env.ALIPAY_GATEWAY || 'openapi.alipay.com'; + + /** + * Step 2: 用 auth_code 换取 access_token + * + * API: alipay.system.oauth.token (grant_type=authorization_code) + */ + async exchangeAuthCode(authCode: string): Promise { + const result = await this.callGateway('alipay.system.oauth.token', { + grant_type: 'authorization_code', + code: authCode, + }); + + const resp = result['alipay_system_oauth_token_response']; + if (!resp || result['error_response']) { + const err = result['error_response'] || {}; + this.logger.error(`Alipay token exchange failed: ${JSON.stringify(err)}`); + throw new BadRequestException( + `支付宝授权失败: ${err.sub_msg || err.msg || '未知错误'} (code: ${err.code || '?'})`, + ); + } + + this.logger.log(`Alipay token exchanged: userId=${resp.user_id}`); + return resp as AlipayTokenResponse; + } + + /** + * Step 3: 用 access_token 获取用户信息 + * + * API: alipay.user.info.share (需开通「获取会员信息」接口权限) + */ + async getUserInfo(accessToken: string): Promise { + const result = await this.callGateway( + 'alipay.user.info.share', + {}, + accessToken, + ); + + const resp = result['alipay_user_info_share_response']; + if (!resp || result['error_response'] || resp.code !== '10000') { + const err = result['error_response'] || resp || {}; + this.logger.error(`Alipay userinfo failed: ${JSON.stringify(err)}`); + throw new BadRequestException( + `获取支付宝用户信息失败: ${err.sub_msg || err.msg || '请确认已开通「获取会员信息」接口权限'}`, + ); + } + + return resp as AlipayUserInfo; + } + + /** + * 调用支付宝网关 — 构建 RSA2 签名请求 + */ + private async callGateway( + method: string, + bizContent: Record, + authToken?: string, + ): Promise> { + const timestamp = new Date() + .toISOString() + .replace('T', ' ') + .substring(0, 19); + + // 公共参数 + const params: Record = { + app_id: this.appId!, + method, + charset: 'utf-8', + sign_type: 'RSA2', + timestamp, + version: '1.0', + }; + + if (authToken) { + params['auth_token'] = authToken; + } + + if (Object.keys(bizContent).length > 0) { + params['biz_content'] = JSON.stringify(bizContent); + } + + // 生成 RSA2 签名 + params['sign'] = this.sign(params); + + // POST 请求体(URL 编码) + const postBody = querystring.stringify(params); + + const data = await this.httpsPost(this.gateway, postBody); + return JSON.parse(data) as Record; + } + + /** + * RSA2 (SHA256WithRSA) 签名 + * 1. 参数字典序升序 + * 2. 拼接 key=value + * 3. SHA256WithRSA 签名 → Base64 + */ + private sign(params: Record): string { + const sortedKeys = Object.keys(params) + .filter((k) => k !== 'sign') + .sort(); + const signString = sortedKeys.map((k) => `${k}=${params[k]}`).join('&'); + + // PKCS8 格式私钥(ALIPAY_PRIVATE_KEY 存储的是 Base64,无 header/footer) + const privateKeyPem = `-----BEGIN PRIVATE KEY-----\n${this.chunkBase64(this.privateKey!)}\n-----END PRIVATE KEY-----`; + + const signer = crypto.createSign('RSA-SHA256'); + signer.update(signString, 'utf8'); + return signer.sign(privateKeyPem, 'base64'); + } + + /** 每64字符换行(PEM 格式要求) */ + private chunkBase64(base64: string): string { + return base64.match(/.{1,64}/g)?.join('\n') || base64; + } + + /** HTTPS POST */ + private httpsPost(host: string, body: string): Promise { + return new Promise((resolve, reject) => { + const options = { + hostname: host, + path: '/gateway.do', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }, + }; + const req = https.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => resolve(data)); + }); + req.on('error', reject); + req.setTimeout(10000, () => { + req.destroy(); + reject(new Error('Alipay API timeout')); + }); + req.write(body); + req.end(); + }); + } +} diff --git a/backend/services/auth-service/src/infrastructure/apple/apple.provider.ts b/backend/services/auth-service/src/infrastructure/apple/apple.provider.ts new file mode 100644 index 0000000..9d4a951 --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/apple/apple.provider.ts @@ -0,0 +1,187 @@ +// ============================================================ +// AppleProvider — Apple Sign In Identity Token 验证 +// +// ── Flutter sign_in_with_apple 流程 ───────────────────────── +// 1. 客户端调用 SignInWithApple.getAppleIDCredential(...) +// 2. 用户在 Apple 身份验证界面完成授权 +// 3. 获取 AppleIDCredential.identityToken(JWT 字符串) +// + AppleIDCredential.userIdentifier(user ID,18-20位字母数字) +// 4. 将 identityToken 发送到 POST /api/v1/auth/apple +// +// ── Identity Token 验证流程 ───────────────────────────────── +// Apple Identity Token 是 ES256(ECDSA P-256 SHA-256)签名的 JWT +// +// Step 1: 解析 JWT header,获取 kid(密钥 ID) +// Step 2: 获取 Apple 公钥集合(JWKS) +// GET https://appleid.apple.com/auth/keys +// Step 3: 找到 kid 匹配的公钥(JWK 格式转 PEM) +// Step 4: 验证 JWT 签名(crypto.createVerify('SHA256')) +// Step 5: 验证 claims: +// - iss = 'https://appleid.apple.com' +// - aud = APPLE_CLIENT_ID (Bundle ID or Service ID) +// - exp > now(未过期) +// +// ── 申请 Apple Sign In 步骤 ──────────────────────────────── +// 1. Apple Developer 账号(个人 $99/年) +// https://developer.apple.com +// +// 2. 开启 Sign In with Apple 能力 +// Certificates, Identifiers & Profiles +// → Identifiers → 选择 App ID (cn.gogenex.consumer) +// → Capabilities → 勾选「Sign In with Apple」 +// +// 3. iOS 项目配置(Xcode) +// Target → Signing & Capabilities → + Capability → Sign In with Apple +// +// 4. 环境变量 +// APPLE_CLIENT_ID — Bundle ID (cn.gogenex.consumer) 或 Service ID +// +// ── 注意事项 ─────────────────────────────────────────────── +// - Apple 首次登录会提供 email,之后登录不再提供(需客户端缓存或提示用户输入) +// - userIdentifier(user ID)是稳定的,可跨设备识别同一用户 +// - JWKS 可缓存(Cache-Control),无需每次请求 +// ============================================================ + +import { + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import * as https from 'https'; +import * as crypto from 'crypto'; + +/** Apple JWKS 公钥 */ +interface AppleJWK { + kty: string; + kid: string; + use: string; + alg: string; + n: string; // RSA modulus(Base64URL) + e: string; // RSA exponent(Base64URL) +} + +interface AppleJWKS { + keys: AppleJWK[]; +} + +/** Apple Identity Token 验证后的用户信息 */ +export interface AppleUserInfo { + sub: string; // user identifier(稳定唯一 ID) + email?: string; // 仅首次登录时返回 + emailVerified?: boolean; + isPrivateEmail?: boolean; // Apple 隐藏真实邮箱时为 true +} + +@Injectable() +export class AppleProvider { + private readonly logger = new Logger('AppleProvider'); + private readonly clientId = process.env.APPLE_CLIENT_ID || 'cn.gogenex.consumer'; + + // 简单内存缓存 JWKS(Apple 公钥极少变化) + private cachedJwks: AppleJWK[] | null = null; + private jwksCachedAt = 0; + private readonly JWKS_CACHE_TTL = 24 * 60 * 60 * 1000; // 24小时 + + /** + * 验证 Apple Identity Token 并返回用户信息 + * + * @param identityToken Flutter sign_in_with_apple 返回的 JWT + */ + async verifyIdentityToken(identityToken: string): Promise { + // 1. 解析 JWT(不验证签名,只读 header + payload) + const parts = identityToken.split('.'); + if (parts.length !== 3) { + throw new UnauthorizedException('Apple Identity Token 格式无效'); + } + + const header = JSON.parse(this.base64urlDecode(parts[0])); + const payload = JSON.parse(this.base64urlDecode(parts[1])); + + // 2. 基本 claims 验证 + const now = Math.floor(Date.now() / 1000); + if (payload.exp < now) { + throw new UnauthorizedException('Apple Identity Token 已过期'); + } + if (payload.iss !== 'https://appleid.apple.com') { + throw new UnauthorizedException('Apple Identity Token issuer 无效'); + } + if (payload.aud !== this.clientId) { + this.logger.error(`Apple token aud mismatch: ${payload.aud} != ${this.clientId}`); + throw new UnauthorizedException('Apple Token 不属于此应用'); + } + + // 3. 获取 Apple 公钥 + 验证签名 + const jwks = await this.getAppleJWKS(); + const matchingKey = jwks.find((k) => k.kid === header.kid); + if (!matchingKey) { + throw new UnauthorizedException(`未找到 Apple 公钥 (kid=${header.kid})`); + } + + const publicKeyPem = this.jwkToPublicKeyPem(matchingKey); + const signedData = `${parts[0]}.${parts[1]}`; + const signature = Buffer.from(parts[2], 'base64url'); + + const verifier = crypto.createVerify('SHA256'); + verifier.update(signedData); + const valid = verifier.verify(publicKeyPem, signature); + + if (!valid) { + throw new UnauthorizedException('Apple Identity Token 签名验证失败'); + } + + this.logger.log(`Apple token verified: sub=${payload.sub?.slice(0, 6)}...`); + + return { + sub: payload.sub, + email: payload.email, + emailVerified: payload.email_verified === 'true' || payload.email_verified === true, + isPrivateEmail: payload.is_private_email === 'true' || payload.is_private_email === true, + }; + } + + /** 获取 Apple JWKS 公钥集合(带内存缓存) */ + private async getAppleJWKS(): Promise { + const now = Date.now(); + if (this.cachedJwks && now - this.jwksCachedAt < this.JWKS_CACHE_TTL) { + return this.cachedJwks; + } + + const data = await this.httpsGet('https://appleid.apple.com/auth/keys'); + const jwks = JSON.parse(data) as AppleJWKS; + this.cachedJwks = jwks.keys; + this.jwksCachedAt = now; + return jwks.keys; + } + + /** + * 将 Apple JWK(RSA 公钥)转换为 PEM 格式 + * Apple 使用 RS256(RSA-SHA256),JWK 包含 n(modulus)和 e(exponent) + */ + private jwkToPublicKeyPem(jwk: AppleJWK): string { + // Node.js 18+ 支持直接从 JWK 创建 KeyObject + const key = crypto.createPublicKey({ key: jwk as any, format: 'jwk' }); + return key.export({ type: 'spki', format: 'pem' }) as string; + } + + /** Base64URL → UTF-8 字符串 */ + private base64urlDecode(input: string): string { + const base64 = input.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + return Buffer.from(padded, 'base64').toString('utf8'); + } + + private httpsGet(url: string): Promise { + return new Promise((resolve, reject) => { + const req = https.get(url, (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => resolve(body)); + }); + req.on('error', reject); + req.setTimeout(10000, () => { + req.destroy(); + reject(new Error('Apple JWKS API timeout')); + }); + }); + } +} diff --git a/backend/services/auth-service/src/infrastructure/google/google.provider.ts b/backend/services/auth-service/src/infrastructure/google/google.provider.ts new file mode 100644 index 0000000..1fdfe15 --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/google/google.provider.ts @@ -0,0 +1,109 @@ +// ============================================================ +// GoogleProvider — Google Sign-In ID Token 验证 +// +// ── Flutter google_sign_in 流程 ───────────────────────────── +// 1. 客户端调用 GoogleSignIn().signIn() +// 2. 用户选择 Google 账号,完成授权 +// 3. 获取 GoogleSignInAuthentication.idToken(JWT 字符串) +// 4. 将 idToken 发送到 POST /api/v1/auth/google +// +// ── 服务端验证方式 ────────────────────────────────────────── +// GET https://oauth2.googleapis.com/tokeninfo?id_token=TOKEN +// Google 服务器验证 Token 签名 + 有效期,返回用户信息 +// 响应字段: sub (user ID), email, name, picture +// +// 注意: 此方法简单可靠,但每次登录都会请求 Google API。 +// 生产优化: 缓存 Google JWKS 公钥 (https://www.googleapis.com/oauth2/v3/certs), +// 本地验证 JWT 签名(减少 RTT,提升性能)。 +// +// ── 申请 Google OAuth Client ID 步骤 ────────────────────── +// 1. 登录 Google Cloud Console +// https://console.cloud.google.com → 选择/创建项目 +// +// 2. 启用 API +// API 和服务 → 库 → 搜索「Google Sign-In API」→ 启用 +// +// 3. 创建凭据 +// API 和服务 → 凭据 → 创建凭据 → OAuth 2.0 客户端 ID +// 应用类型: +// Android: 包名 cn.gogenex.consumer + SHA-1 指纹(debug/release 各一个) +// iOS: Bundle ID cn.gogenex.consumer +// Web: (可选,用于服务端验证 Client ID) +// +// 4. 获取 Client ID(形如 xxxxxxxx.apps.googleusercontent.com) +// Android/iOS 客户端 ID 填入 Flutter 配置 +// 服务端用同一 Client ID 验证 idToken 的 aud 字段 +// +// ── 环境变量配置 ─────────────────────────────────────────── +// GOOGLE_CLIENT_ID — OAuth 2.0 Client ID,用于验证 idToken 的 audience +// +// ── 安全注意事项 ─────────────────────────────────────────── +// - 务必验证 aud 字段 = GOOGLE_CLIENT_ID,防止其他应用的 token 被接受 +// - token 有效期通常 1 小时,tokeninfo 接口会自动验证 exp 字段 +// ============================================================ + +import { Injectable, Logger, BadRequestException, UnauthorizedException } from '@nestjs/common'; +import * as https from 'https'; + +/** Google tokeninfo 接口响应 */ +export interface GoogleTokenInfo { + sub: string; // 用户唯一 ID(全局不变) + email?: string; // 邮箱(若授权了 email scope) + email_verified?: string; + name?: string; // 显示名 + picture?: string; // 头像 URL + given_name?: string; + family_name?: string; + aud: string; // Client ID,必须与 GOOGLE_CLIENT_ID 匹配 + exp: string; // 过期时间(Unix 秒) + error?: string; // 若 token 无效时返回 + error_description?: string; +} + +@Injectable() +export class GoogleProvider { + private readonly logger = new Logger('GoogleProvider'); + private readonly clientId = process.env.GOOGLE_CLIENT_ID; + + /** + * 验证 Google ID Token 并返回用户信息 + * + * @param idToken Flutter google_sign_in 返回的 idToken + */ + async verifyIdToken(idToken: string): Promise { + const data = await this.httpsGet( + `https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(idToken)}`, + ); + + const info = JSON.parse(data) as GoogleTokenInfo; + + if (info.error) { + this.logger.error(`Google token invalid: ${info.error} - ${info.error_description}`); + throw new UnauthorizedException(`Google 登录失败: ${info.error_description || info.error}`); + } + + // 验证 audience(防止接受其他应用的 token) + if (this.clientId && info.aud !== this.clientId) { + this.logger.error(`Google token audience mismatch: ${info.aud} != ${this.clientId}`); + throw new UnauthorizedException('Google Token 不属于此应用'); + } + + this.logger.log(`Google token verified: sub=${info.sub.slice(0, 6)}... email=${info.email}`); + return info; + } + + private httpsGet(url: string): Promise { + return new Promise((resolve, reject) => { + const req = https.get(url, (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => resolve(body)); + }); + req.on('error', reject); + req.setTimeout(10000, () => { + req.destroy(); + reject(new Error('Google API timeout')); + }); + }); + } +} diff --git a/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts b/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts index 6c0c844..35ff8c6 100644 --- a/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts +++ b/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts @@ -13,6 +13,9 @@ import { AuthGuard } from '@nestjs/passport'; import { ThrottlerGuard } from '@nestjs/throttler'; import { AuthService } from '../../../application/services/auth.service'; import { WechatService } from '../../../application/services/wechat.service'; +import { AlipayService } from '../../../application/services/alipay.service'; +import { GoogleService } from '../../../application/services/google.service'; +import { AppleService } from '../../../application/services/apple.service'; import { RegisterDto } from '../dto/register.dto'; import { LoginDto } from '../dto/login.dto'; import { RefreshTokenDto } from '../dto/refresh-token.dto'; @@ -26,6 +29,9 @@ import { RegisterEmailDto } from '../dto/register-email.dto'; import { LoginEmailDto } from '../dto/login-email.dto'; import { ResetPasswordEmailDto } from '../dto/reset-password-email.dto'; import { WechatLoginDto } from '../dto/wechat-login.dto'; +import { AlipayLoginDto } from '../dto/alipay-login.dto'; +import { GoogleLoginDto } from '../dto/google-login.dto'; +import { AppleLoginDto } from '../dto/apple-login.dto'; @ApiTags('Auth') @Controller('auth') @@ -33,6 +39,9 @@ export class AuthController { constructor( private readonly authService: AuthService, private readonly wechatService: WechatService, + private readonly alipayService: AlipayService, + private readonly googleService: GoogleService, + private readonly appleService: AppleService, ) {} /* ── SMS 验证码 ── */ @@ -262,6 +271,58 @@ export class AuthController { }; } + /* ── 支付宝登录 / 注册 ── */ + + @Post('alipay') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '支付宝一键登录(新用户自动注册)' }) + @ApiResponse({ status: 200, description: '登录成功,返回 JWT' }) + @ApiResponse({ status: 400, description: '支付宝 auth_code 无效或已过期' }) + async alipayLogin(@Body() dto: AlipayLoginDto, @Ip() ip: string) { + const result = await this.alipayService.loginOrRegister( + dto.authCode, + dto.referralCode, + dto.deviceInfo, + ip, + ); + return { code: 0, data: result, message: '登录成功' }; + } + + /* ── Google 登录 / 注册 ── */ + + @Post('google') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Google 一键登录(新用户自动注册)' }) + @ApiResponse({ status: 200, description: '登录成功,返回 JWT' }) + @ApiResponse({ status: 401, description: 'Google ID Token 无效' }) + async googleLogin(@Body() dto: GoogleLoginDto, @Ip() ip: string) { + const result = await this.googleService.loginOrRegister( + dto.idToken, + dto.referralCode, + dto.deviceInfo, + ip, + ); + return { code: 0, data: result, message: '登录成功' }; + } + + /* ── Apple 登录 / 注册 ── */ + + @Post('apple') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Apple 一键登录(仅 iOS,新用户自动注册)' }) + @ApiResponse({ status: 200, description: '登录成功,返回 JWT' }) + @ApiResponse({ status: 401, description: 'Apple Identity Token 无效' }) + async appleLogin(@Body() dto: AppleLoginDto, @Ip() ip: string) { + const result = await this.appleService.loginOrRegister( + dto.identityToken, + dto.displayName, + dto.referralCode, + dto.deviceInfo, + ip, + ); + return { code: 0, data: result, message: '登录成功' }; + } + /* ── 微信登录 / 注册 ── */ @Post('wechat') diff --git a/backend/services/auth-service/src/interface/http/dto/alipay-login.dto.ts b/backend/services/auth-service/src/interface/http/dto/alipay-login.dto.ts new file mode 100644 index 0000000..aa30fcd --- /dev/null +++ b/backend/services/auth-service/src/interface/http/dto/alipay-login.dto.ts @@ -0,0 +1,20 @@ +import { IsString, Length, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AlipayLoginDto { + @ApiProperty({ description: '支付宝 SDK 返回的 auth_code(一次性,3分钟有效)' }) + @IsString() + @Length(1, 200) + authCode: string; + + @ApiPropertyOptional({ description: '推荐码(仅新用户注册时有效)' }) + @IsOptional() + @IsString() + @Length(1, 20) + referralCode?: string; + + @ApiPropertyOptional({ description: '设备信息(型号/OS 版本等)' }) + @IsOptional() + @IsString() + deviceInfo?: string; +} diff --git a/backend/services/auth-service/src/interface/http/dto/apple-login.dto.ts b/backend/services/auth-service/src/interface/http/dto/apple-login.dto.ts new file mode 100644 index 0000000..99baeb8 --- /dev/null +++ b/backend/services/auth-service/src/interface/http/dto/apple-login.dto.ts @@ -0,0 +1,26 @@ +import { IsString, Length, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AppleLoginDto { + @ApiProperty({ description: 'Flutter sign_in_with_apple 返回的 Identity Token(JWT 格式)' }) + @IsString() + @Length(1, 2000) + identityToken: string; + + @ApiPropertyOptional({ description: '用户显示名(Apple 首次授权时返回,之后不再提供)' }) + @IsOptional() + @IsString() + @Length(1, 50) + displayName?: string; + + @ApiPropertyOptional({ description: '推荐码(仅新用户注册时有效)' }) + @IsOptional() + @IsString() + @Length(1, 20) + referralCode?: string; + + @ApiPropertyOptional({ description: '设备信息' }) + @IsOptional() + @IsString() + deviceInfo?: string; +} diff --git a/backend/services/auth-service/src/interface/http/dto/google-login.dto.ts b/backend/services/auth-service/src/interface/http/dto/google-login.dto.ts new file mode 100644 index 0000000..f73689c --- /dev/null +++ b/backend/services/auth-service/src/interface/http/dto/google-login.dto.ts @@ -0,0 +1,20 @@ +import { IsString, Length, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class GoogleLoginDto { + @ApiProperty({ description: 'Flutter google_sign_in 返回的 ID Token(JWT 格式)' }) + @IsString() + @Length(1, 2000) + idToken: string; + + @ApiPropertyOptional({ description: '推荐码(仅新用户注册时有效)' }) + @IsOptional() + @IsString() + @Length(1, 20) + referralCode?: string; + + @ApiPropertyOptional({ description: '设备信息' }) + @IsOptional() + @IsString() + deviceInfo?: string; +} diff --git a/frontend/genex-mobile/android/app/src/main/AndroidManifest.xml b/frontend/genex-mobile/android/app/src/main/AndroidManifest.xml index fdd414e..1739a5f 100644 --- a/frontend/genex-mobile/android/app/src/main/AndroidManifest.xml +++ b/frontend/genex-mobile/android/app/src/main/AndroidManifest.xml @@ -53,5 +53,7 @@ + + diff --git a/frontend/genex-mobile/ios/Runner/Info.plist b/frontend/genex-mobile/ios/Runner/Info.plist index 1541342..0ff8e19 100644 --- a/frontend/genex-mobile/ios/Runner/Info.plist +++ b/frontend/genex-mobile/ios/Runner/Info.plist @@ -46,10 +46,9 @@ UIApplicationSupportsIndirectInputEvents - + CFBundleURLTypes @@ -62,14 +61,29 @@ wx$(WECHAT_APP_ID) + + CFBundleTypeRole + Editor + CFBundleURLName + alipay + CFBundleURLSchemes + + + alipay$(ALIPAY_APP_ID) + + - + LSApplicationQueriesSchemes weixin weixinULAPI + alipay + alipays diff --git a/frontend/genex-mobile/lib/app/i18n/strings/en.dart b/frontend/genex-mobile/lib/app/i18n/strings/en.dart index 93ce6e6..e5d6592 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/en.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/en.dart @@ -35,6 +35,11 @@ const Map en = { 'welcome.wechat': 'WeChat', 'welcome.wechatNotInstalled': 'Please install WeChat first', 'welcome.wechatLoginFailed': 'WeChat login failed, please try again', + 'welcome.alipay': 'Alipay', + 'welcome.alipayNotInstalled': 'Please install Alipay first', + 'welcome.alipayLoginFailed': 'Alipay login failed, please try again', + 'welcome.googleLoginFailed': 'Google login failed, please try again', + 'welcome.appleLoginFailed': 'Apple login failed, please try again', 'welcome.otherLogin': 'Other Login Methods', 'welcome.hasAccount': 'Already have an account?', 'welcome.login': 'Log In', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart index 0826f6c..f147d56 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart @@ -35,6 +35,11 @@ const Map ja = { 'welcome.wechat': 'WeChat', 'welcome.wechatNotInstalled': 'WeChatアプリをインストールしてください', 'welcome.wechatLoginFailed': 'WeChatログインに失敗しました。再試行してください', + 'welcome.alipay': 'Alipay', + 'welcome.alipayNotInstalled': 'Alipayアプリをインストールしてください', + 'welcome.alipayLoginFailed': 'Alipayログインに失敗しました。再試行してください', + 'welcome.googleLoginFailed': 'Googleログインに失敗しました。再試行してください', + 'welcome.appleLoginFailed': 'Appleログインに失敗しました。再試行してください', 'welcome.otherLogin': '他の方法でログイン', 'welcome.hasAccount': 'アカウントをお持ちですか?', 'welcome.login': 'ログイン', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart index 5f015c0..36a9bd1 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart @@ -35,6 +35,11 @@ const Map zhCN = { 'welcome.wechat': '微信', 'welcome.wechatNotInstalled': '请先安装微信 App', 'welcome.wechatLoginFailed': '微信登录失败,请重试', + 'welcome.alipay': '支付宝', + 'welcome.alipayNotInstalled': '请先安装支付宝 App', + 'welcome.alipayLoginFailed': '支付宝登录失败,请重试', + 'welcome.googleLoginFailed': 'Google 登录失败,请重试', + 'welcome.appleLoginFailed': 'Apple 登录失败,请重试', 'welcome.otherLogin': '其他方式登录', 'welcome.hasAccount': '已有账号?', 'welcome.login': '登录', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart index 3382a39..3cee934 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart @@ -35,6 +35,11 @@ const Map zhTW = { 'welcome.wechat': '微信', 'welcome.wechatNotInstalled': '請先安裝微信 App', 'welcome.wechatLoginFailed': '微信登入失敗,請重試', + 'welcome.alipay': '支付寶', + 'welcome.alipayNotInstalled': '請先安裝支付寶 App', + 'welcome.alipayLoginFailed': '支付寶登入失敗,請重試', + 'welcome.googleLoginFailed': 'Google 登入失敗,請重試', + 'welcome.appleLoginFailed': 'Apple 登入失敗,請重試', 'welcome.otherLogin': '其他方式登入', 'welcome.hasAccount': '已有帳號?', 'welcome.login': '登入', diff --git a/frontend/genex-mobile/lib/core/services/auth_service.dart b/frontend/genex-mobile/lib/core/services/auth_service.dart index 3093800..19cbca4 100644 --- a/frontend/genex-mobile/lib/core/services/auth_service.dart +++ b/frontend/genex-mobile/lib/core/services/auth_service.dart @@ -254,6 +254,72 @@ class AuthService { return result; } + // ── 支付宝登录 ──────────────────────────────────────────────────────────── + + /// 支付宝一键登录 / 自动注册 + /// + /// [authCode] 来自 tobias Alipay.authCode()(一次性,3 分钟有效) + Future loginByAlipay({ + required String authCode, + String? referralCode, + String? deviceInfo, + }) async { + final resp = await _api.post('/api/v1/auth/alipay', data: { + 'authCode': authCode, + if (referralCode != null && referralCode.isNotEmpty) + 'referralCode': referralCode.toUpperCase(), + if (deviceInfo != null) 'deviceInfo': deviceInfo, + }); + final result = AuthResult.fromJson(resp.data['data']); + await _setAuth(result); + return result; + } + + // ── Google 登录 ──────────────────────────────────────────────────────────── + + /// Google 一键登录 / 自动注册 + /// + /// [idToken] 来自 google_sign_in GoogleSignInAuthentication.idToken(JWT) + Future loginByGoogle({ + required String idToken, + String? referralCode, + String? deviceInfo, + }) async { + final resp = await _api.post('/api/v1/auth/google', data: { + 'idToken': idToken, + if (referralCode != null && referralCode.isNotEmpty) + 'referralCode': referralCode.toUpperCase(), + if (deviceInfo != null) 'deviceInfo': deviceInfo, + }); + final result = AuthResult.fromJson(resp.data['data']); + await _setAuth(result); + return result; + } + + // ── Apple 登录 ──────────────────────────────────────────────────────────── + + /// Apple 一键登录 / 自动注册(仅 iOS) + /// + /// [identityToken] 来自 sign_in_with_apple AppleIDCredential.identityToken + /// [displayName] 用户授权时填写的显示名(Apple 首次授权时提供,之后为 null) + Future loginByApple({ + required String identityToken, + String? displayName, + String? referralCode, + String? deviceInfo, + }) async { + final resp = await _api.post('/api/v1/auth/apple', data: { + 'identityToken': identityToken, + if (displayName != null && displayName.isNotEmpty) 'displayName': displayName, + if (referralCode != null && referralCode.isNotEmpty) + 'referralCode': referralCode.toUpperCase(), + if (deviceInfo != null) 'deviceInfo': deviceInfo, + }); + final result = AuthResult.fromJson(resp.data['data']); + await _setAuth(result); + return result; + } + // ── 邮件重置密码 ────────────────────────────────────────────────────────── /// 通过邮件验证码重置密码(忘记密码场景,需先用 EmailCodeType.resetPassword 获取验证码) diff --git a/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart b/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart index e1a70ad..4d0d020 100644 --- a/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart +++ b/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatform; import 'package:fluwx/fluwx.dart'; +import 'package:tobias/tobias.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:sign_in_with_apple/sign_in_with_apple.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/theme/app_typography.dart'; import '../../../../app/theme/app_spacing.dart'; @@ -10,7 +13,9 @@ import '../../../../core/services/auth_service.dart'; /// A1. 欢迎页 - 品牌展示 + 注册/登录入口 /// -/// 品牌Logo、Slogan、手机号注册、邮箱注册、社交登录入口(WeChat/Google/Apple) +/// 平台差异(运行时判断): +/// Android: 微信 + 支付宝 + Google +/// iOS: 微信 + 支付宝 + Google + Apple class WelcomePage extends StatefulWidget { const WelcomePage({super.key}); @@ -20,6 +25,11 @@ class WelcomePage extends StatefulWidget { class _WelcomePageState extends State { bool _wechatLoading = false; + bool _alipayLoading = false; + bool _googleLoading = false; + bool _appleLoading = false; + + final _googleSignIn = GoogleSignIn(scopes: ['email', 'profile']); @override void initState() { @@ -32,6 +42,8 @@ class _WelcomePageState extends State { }); } + // ── 微信 ───────────────────────────────────────────────────────────────── + Future _onWechatTap() async { final installed = await isWeChatInstalled; if (!installed) { @@ -43,22 +55,16 @@ class _WelcomePageState extends State { return; } setState(() => _wechatLoading = true); - // 发起微信授权,scope = snsapi_userinfo 可获取用户信息(昵称、头像) await sendWeChatAuth(scope: 'snsapi_userinfo', state: 'genex_login'); // 授权结果通过 weChatResponseEventHandler 异步回调 } Future _handleWechatAuthResp(WXAuthResp resp) async { setState(() => _wechatLoading = false); - if (resp.errCode != 0 || resp.code == null) { - // 用户取消授权或授权失败,不做任何处理 - return; - } + if (resp.errCode != 0 || resp.code == null) return; try { await AuthService.instance.loginByWechat(code: resp.code!); - if (mounted) { - Navigator.pushReplacementNamed(context, '/main'); - } + if (mounted) Navigator.pushReplacementNamed(context, '/main'); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -68,8 +74,134 @@ class _WelcomePageState extends State { } } + // ── 支付宝 ───────────────────────────────────────────────────────────────── + // tobias 包使用方法: + // isAliPayInstalled() → Future + // aliPay(String orderStr) — 用于支付 + // 但 OAuth 授权用的是 Alipay.authCode(appId, scope) 返回 auth_code + // + // tobias v3.x API: aliPayAuth(appId, scope) → Future + // 返回 {'resultStatus': '9000', 'memo': '', 'result': '...(含 auth_code)...'} + // resultStatus: 9000=成功, 6001=取消, 4000=错误 + + Future _onAlipayTap() async { + final installed = await isAliPayInstalled; + if (!installed) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.t('welcome.alipayNotInstalled'))), + ); + } + return; + } + + setState(() => _alipayLoading = true); + try { + // appId 与后端 ALIPAY_APP_ID 一致 + const alipayAppId = String.fromEnvironment('ALIPAY_APP_ID', defaultValue: ''); + final result = await aliPayAuth(alipayAppId, 'auth_user'); + + final status = result['resultStatus']?.toString() ?? ''; + if (status != '9000') { + // 用户取消或错误 + return; + } + + // 从 result 字符串中解析 auth_code + // result 格式: "auth_code=AP_xxxxxxxx&scope=auth_user&state=..." + final resultStr = result['result']?.toString() ?? ''; + final authCode = _parseParam(resultStr, 'auth_code'); + if (authCode == null || authCode.isEmpty) { + throw Exception('未获取到 auth_code'); + } + + await AuthService.instance.loginByAlipay(authCode: authCode); + if (mounted) Navigator.pushReplacementNamed(context, '/main'); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.t('welcome.alipayLoginFailed'))), + ); + } + } finally { + if (mounted) setState(() => _alipayLoading = false); + } + } + + /// 从 URL Query 格式字符串中提取参数值 + String? _parseParam(String str, String key) { + final uri = Uri(query: str); + return uri.queryParameters[key]; + } + + // ── Google ───────────────────────────────────────────────────────────────── + + Future _onGoogleTap() async { + setState(() => _googleLoading = true); + try { + final account = await _googleSignIn.signIn(); + if (account == null) return; // 用户取消 + + final auth = await account.authentication; + final idToken = auth.idToken; + if (idToken == null) throw Exception('未获取到 ID Token'); + + await AuthService.instance.loginByGoogle(idToken: idToken); + if (mounted) Navigator.pushReplacementNamed(context, '/main'); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.t('welcome.googleLoginFailed'))), + ); + } + } finally { + if (mounted) setState(() => _googleLoading = false); + } + } + + // ── Apple(仅 iOS)──────────────────────────────────────────────────────── + + Future _onAppleTap() async { + setState(() => _appleLoading = true); + try { + final credential = await SignInWithApple.getAppleIDCredential( + scopes: [ + AppleIDAuthorizationScopes.email, + AppleIDAuthorizationScopes.fullName, + ], + ); + + final identityToken = credential.identityToken; + if (identityToken == null) throw Exception('未获取到 Identity Token'); + + // 拼接用户显示名(Apple 首次登录时提供,之后为 null) + final displayName = [ + credential.givenName, + credential.familyName, + ].where((s) => s != null && s.isNotEmpty).join(' '); + + await AuthService.instance.loginByApple( + identityToken: identityToken, + displayName: displayName.isNotEmpty ? displayName : null, + ); + if (mounted) Navigator.pushReplacementNamed(context, '/main'); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.t('welcome.appleLoginFailed'))), + ); + } + } finally { + if (mounted) setState(() => _appleLoading = false); + } + } + + // ── Build ───────────────────────────────────────────────────────────────── + @override Widget build(BuildContext context) { + final isIos = defaultTargetPlatform == TargetPlatform.iOS; + return Scaffold( body: SafeArea( child: Padding( @@ -163,8 +295,8 @@ class _WelcomePageState extends State { const SizedBox(height: 16), // Social Login Buttons - // Apple Sign In 仅在 iOS 上显示(Apple 账号体系限定 iOS/macOS 生态) - // Android 上只显示 WeChat + Google + // Android: 微信 + 支付宝 + Google + // iOS: 微信 + 支付宝 + Google + Apple Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -175,22 +307,29 @@ class _WelcomePageState extends State { loading: _wechatLoading, onTap: _onWechatTap, ), - const SizedBox(width: 24), + const SizedBox(width: 20), + _SocialLoginButton( + // 支付宝官方蓝色 + icon: Icons.account_balance_wallet_outlined, + label: context.t('welcome.alipay'), + color: const Color(0xFF1677FF), + loading: _alipayLoading, + onTap: _onAlipayTap, + ), + const SizedBox(width: 20), _SocialLoginButton( icon: Icons.g_mobiledata_rounded, label: 'Google', - onTap: () { - Navigator.pushReplacementNamed(context, '/main'); - }, + loading: _googleLoading, + onTap: _onGoogleTap, ), - if (defaultTargetPlatform == TargetPlatform.iOS) ...[ - const SizedBox(width: 24), + if (isIos) ...[ + const SizedBox(width: 20), _SocialLoginButton( icon: Icons.apple_rounded, label: 'Apple', - onTap: () { - Navigator.pushReplacementNamed(context, '/main'); - }, + loading: _appleLoading, + onTap: _onAppleTap, ), ], ], diff --git a/frontend/genex-mobile/pubspec.yaml b/frontend/genex-mobile/pubspec.yaml index d540b10..1c034d5 100644 --- a/frontend/genex-mobile/pubspec.yaml +++ b/frontend/genex-mobile/pubspec.yaml @@ -27,6 +27,9 @@ dependencies: share_plus: ^10.0.2 flutter_secure_storage: ^9.2.2 fluwx: ^3.10.0 + tobias: ^3.0.0 + google_sign_in: ^6.2.1 + sign_in_with_apple: ^6.1.0 dev_dependencies: flutter_test: