feat(auth): 支付宝 + Google + Apple 三方登录
后端 (auth-service): - 新增 AlipayProvider (RSA2签名) + AlipayService + POST /auth/alipay - 新增 GoogleProvider (tokeninfo验证) + GoogleService + POST /auth/google - 新增 AppleProvider (JWKS验证ES256 JWT) + AppleService + POST /auth/apple - SocialProvider 枚举新增 ALIPAY - .env.example 补充三方登录申请步骤文档 Flutter (genex-mobile): - pubspec.yaml: 新增 tobias / google_sign_in / sign_in_with_apple - auth_service.dart: loginByAlipay / loginByGoogle / loginByApple - welcome_page.dart: Android=微信+支付宝+Google, iOS=+Apple - AndroidManifest: 添加支付宝包名查询 - Info.plist: 支付宝 URL Scheme + alipay/alipays queries - i18n: 4 语言补充失败提示文案 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2790d4c226
commit
1fc0fcb95e
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AuthResult> {
|
||||
// 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<string, unknown>;
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AuthResult> {
|
||||
// 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<string, unknown>;
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AuthResult> {
|
||||
// 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<string, unknown>;
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -44,8 +44,9 @@ import {
|
|||
/** 已支持的第三方登录 Provider */
|
||||
export enum SocialProvider {
|
||||
WECHAT = 'wechat',
|
||||
GOOGLE = 'google', // 预留,暂未实现
|
||||
APPLE = 'apple', // 预留,暂未实现
|
||||
ALIPAY = 'alipay',
|
||||
GOOGLE = 'google',
|
||||
APPLE = 'apple',
|
||||
}
|
||||
|
||||
@Entity('social_accounts')
|
||||
|
|
|
|||
|
|
@ -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<AlipayTokenResponse> {
|
||||
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<AlipayUserInfo> {
|
||||
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<string, string>,
|
||||
authToken?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.substring(0, 19);
|
||||
|
||||
// 公共参数
|
||||
const params: Record<string, string> = {
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA2 (SHA256WithRSA) 签名
|
||||
* 1. 参数字典序升序
|
||||
* 2. 拼接 key=value
|
||||
* 3. SHA256WithRSA 签名 → Base64
|
||||
*/
|
||||
private sign(params: Record<string, string>): 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<string> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AppleUserInfo> {
|
||||
// 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<AppleJWK[]> {
|
||||
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<string> {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GoogleTokenInfo> {
|
||||
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<string> {
|
||||
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'));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -53,5 +53,7 @@
|
|||
</intent>
|
||||
<!-- 查询微信是否安装 -->
|
||||
<package android:name="com.tencent.mm" />
|
||||
<!-- 查询支付宝是否安装(tobias 包需要) -->
|
||||
<package android:name="com.eg.android.AlipayGphone" />
|
||||
</queries>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -46,10 +46,9 @@
|
|||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
|
||||
<!-- 微信 SDK URL Scheme: wx{AppID}
|
||||
微信授权完成后,通过此 URL Scheme 回调到本 App。
|
||||
替换 WECHAT_APP_ID 为实际的微信 AppID(wx 开头,16位)。
|
||||
CFBundleURLSchemes 中填写 wx{AppID},例如:wx0000000000000000 -->
|
||||
<!-- URL Schemes — 各第三方 SDK 回调路径
|
||||
微信: wx{AppID} → fluwx 回调
|
||||
支付宝: alipay{AppID} → tobias 回调 -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
|
@ -62,14 +61,29 @@
|
|||
<string>wx$(WECHAT_APP_ID)</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>alipay</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<!-- 支付宝 URL Scheme: alipay{数字AppID},如 alipay2021003189xxxxxx
|
||||
替换 ALIPAY_APP_ID 为实际的支付宝 AppID(16位数字) -->
|
||||
<string>alipay$(ALIPAY_APP_ID)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<!-- 声明可查询微信是否已安装(iOS 9+)
|
||||
必须包含 weixin 和 weixinULAPI,否则 isWeChatInstalled 始终返回 false -->
|
||||
<!-- 声明可查询已安装 App(iOS 9+)
|
||||
weixin/weixinULAPI: 微信
|
||||
alipay/alipays: 支付宝 -->
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>weixin</string>
|
||||
<string>weixinULAPI</string>
|
||||
<string>alipay</string>
|
||||
<string>alipays</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ const Map<String, String> 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',
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ const Map<String, String> 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': 'ログイン',
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ const Map<String, String> 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': '登录',
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ const Map<String, String> 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': '登入',
|
||||
|
|
|
|||
|
|
@ -254,6 +254,72 @@ class AuthService {
|
|||
return result;
|
||||
}
|
||||
|
||||
// ── 支付宝登录 ────────────────────────────────────────────────────────────
|
||||
|
||||
/// 支付宝一键登录 / 自动注册
|
||||
///
|
||||
/// [authCode] 来自 tobias Alipay.authCode()(一次性,3 分钟有效)
|
||||
Future<AuthResult> 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<AuthResult> 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<AuthResult> 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 获取验证码)
|
||||
|
|
|
|||
|
|
@ -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<WelcomePage> {
|
||||
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<WelcomePage> {
|
|||
});
|
||||
}
|
||||
|
||||
// ── 微信 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _onWechatTap() async {
|
||||
final installed = await isWeChatInstalled;
|
||||
if (!installed) {
|
||||
|
|
@ -43,22 +55,16 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||
return;
|
||||
}
|
||||
setState(() => _wechatLoading = true);
|
||||
// 发起微信授权,scope = snsapi_userinfo 可获取用户信息(昵称、头像)
|
||||
await sendWeChatAuth(scope: 'snsapi_userinfo', state: 'genex_login');
|
||||
// 授权结果通过 weChatResponseEventHandler 异步回调
|
||||
}
|
||||
|
||||
Future<void> _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<WelcomePage> {
|
|||
}
|
||||
}
|
||||
|
||||
// ── 支付宝 ─────────────────────────────────────────────────────────────────
|
||||
// tobias 包使用方法:
|
||||
// isAliPayInstalled() → Future<bool>
|
||||
// aliPay(String orderStr) — 用于支付
|
||||
// 但 OAuth 授权用的是 Alipay.authCode(appId, scope) 返回 auth_code
|
||||
//
|
||||
// tobias v3.x API: aliPayAuth(appId, scope) → Future<Map>
|
||||
// 返回 {'resultStatus': '9000', 'memo': '', 'result': '...(含 auth_code)...'}
|
||||
// resultStatus: 9000=成功, 6001=取消, 4000=错误
|
||||
|
||||
Future<void> _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<void> _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<void> _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<WelcomePage> {
|
|||
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<WelcomePage> {
|
|||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue