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:
hailin 2026-03-04 04:40:31 -08:00
parent 2790d4c226
commit 1fc0fcb95e
22 changed files with 1430 additions and 29 deletions

View File

@ -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_KEYBase64无换行
# - 在开放平台获取「支付宝公钥」(非应用公钥),填入 ALIPAY_PUBLIC_KEY
#
# 3. 添加功能获取会员信息alipay.user.info.share
# 应用详情 → 添加功能 → 获取会员信息 → 签约约1个工作日审核
#
# 4. 获取 AppID16位数字如 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

View File

@ -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,
};
}
}

View File

@ -0,0 +1,131 @@
// ============================================================
// AppleService — Apple Sign In 登录 / 注册业务逻辑
//
// Apple subuser 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,
/** 客户端传入的 displayNameApple 首次授权时用户可修改,之后不再提供) */
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,
};
}
}

View File

@ -0,0 +1,134 @@
// ============================================================
// GoogleService — Google 登录 / 注册业务逻辑
//
// Google sub 字段 = 用户全局唯一 ID直接作为 openid 存储
// 每次登录同步 email、nicknamename、avatarUrlpicture
// ============================================================
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,
};
}
}

View File

@ -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 {}

View File

@ -44,8 +44,9 @@ import {
/** 已支持的第三方登录 Provider */
export enum SocialProvider {
WECHAT = 'wechat',
GOOGLE = 'google', // 预留,暂未实现
APPLE = 'apple', // 预留,暂未实现
ALIPAY = 'alipay',
GOOGLE = 'google',
APPLE = 'apple',
}
@Entity('social_accounts')

View File

@ -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位
// - 将「应用公钥」上传到开放平台
// - 将「应用私钥」配置到服务器 .envALIPAY_PRIVATE_KEY
// - 将「支付宝公钥」(平台提供的,用于验证响应签名)配置到 .envALIPAY_PUBLIC_KEY
//
// 3. 开通「获取会员信息」接口
// 应用详情 → 添加功能 → 获取会员信息alipay.user.info.share
// → 签约需审核通常1个工作日内
//
// 4. 获取 AppID16位数字如 2021003189xxxxxx
//
// ── 环境变量配置 ───────────────────────────────────────────
// ALIPAY_APP_ID — 支付宝应用 AppID16位数字
// 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();
});
}
}

View File

@ -0,0 +1,187 @@
// ============================================================
// AppleProvider — Apple Sign In Identity Token 验证
//
// ── Flutter sign_in_with_apple 流程 ─────────────────────────
// 1. 客户端调用 SignInWithApple.getAppleIDCredential(...)
// 2. 用户在 Apple 身份验证界面完成授权
// 3. 获取 AppleIDCredential.identityTokenJWT 字符串)
// + AppleIDCredential.userIdentifieruser ID18-20位字母数字
// 4. 将 identityToken 发送到 POST /api/v1/auth/apple
//
// ── Identity Token 验证流程 ─────────────────────────────────
// Apple Identity Token 是 ES256ECDSA 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之后登录不再提供需客户端缓存或提示用户输入
// - userIdentifieruser 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 modulusBase64URL
e: string; // RSA exponentBase64URL
}
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';
// 简单内存缓存 JWKSApple 公钥极少变化)
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 JWKRSA PEM
* Apple 使 RS256RSA-SHA256JWK nmodulus eexponent
*/
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'));
});
});
}
}

View File

@ -0,0 +1,109 @@
// ============================================================
// GoogleProvider — Google Sign-In ID Token 验证
//
// ── Flutter google_sign_in 流程 ─────────────────────────────
// 1. 客户端调用 GoogleSignIn().signIn()
// 2. 用户选择 Google 账号,完成授权
// 3. 获取 GoogleSignInAuthentication.idTokenJWT 字符串)
// 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'));
});
});
}
}

View File

@ -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')

View File

@ -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;
}

View File

@ -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 TokenJWT 格式)' })
@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;
}

View File

@ -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 TokenJWT 格式)' })
@IsString()
@Length(1, 2000)
idToken: string;
@ApiPropertyOptional({ description: '推荐码(仅新用户注册时有效)' })
@IsOptional()
@IsString()
@Length(1, 20)
referralCode?: string;
@ApiPropertyOptional({ description: '设备信息' })
@IsOptional()
@IsString()
deviceInfo?: string;
}

View File

@ -53,5 +53,7 @@
</intent>
<!-- 查询微信是否安装 -->
<package android:name="com.tencent.mm" />
<!-- 查询支付宝是否安装tobias 包需要) -->
<package android:name="com.eg.android.AlipayGphone" />
</queries>
</manifest>

View File

@ -46,10 +46,9 @@
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- 微信 SDK URL Scheme: wx{AppID}
微信授权完成后,通过此 URL Scheme 回调到本 App。
替换 WECHAT_APP_ID 为实际的微信 AppIDwx 开头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 为实际的支付宝 AppID16位数字 -->
<string>alipay$(ALIPAY_APP_ID)</string>
</array>
</dict>
</array>
<!-- 声明可查询微信是否已安装iOS 9+
必须包含 weixin 和 weixinULAPI否则 isWeChatInstalled 始终返回 false -->
<!-- 声明可查询已安装 AppiOS 9+
weixin/weixinULAPI: 微信
alipay/alipays: 支付宝 -->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>weixin</string>
<string>weixinULAPI</string>
<string>alipay</string>
<string>alipays</string>
</array>
</dict>
</plist>

View File

@ -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',

View File

@ -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': 'ログイン',

View File

@ -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': '登录',

View File

@ -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': '登入',

View File

@ -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.idTokenJWT
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

View File

@ -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. - + /
///
/// LogoSloganWeChat/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,
),
],
],

View File

@ -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: