diff --git a/backend/migrations/049_create_social_accounts.sql b/backend/migrations/049_create_social_accounts.sql new file mode 100644 index 0000000..a4f7560 --- /dev/null +++ b/backend/migrations/049_create_social_accounts.sql @@ -0,0 +1,55 @@ +-- ============================================================ +-- Migration 049: 创建 social_accounts 表 (第三方社交账号绑定) +-- +-- 支持微信 / Google / Apple 等第三方 OAuth 登录。 +-- 一个用户可绑定多个 Provider,通过 userId 外键关联到 users 表。 +-- +-- openid: Provider 内唯一(微信 openid 每个 App 不同) +-- unionid: 微信开放平台跨 App 唯一标识(优先用于用户查找) +-- raw_data: 完整 provider 响应(JSONB),保留全量信息备用 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS social_accounts ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider VARCHAR(20) NOT NULL, + openid VARCHAR(128) NOT NULL, + unionid VARCHAR(128), + nickname VARCHAR(100), + avatar_url VARCHAR(500), + raw_data JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- provider + openid 唯一索引(防止同一 App 下重复绑定) +CREATE UNIQUE INDEX IF NOT EXISTS idx_social_provider_openid + ON social_accounts(provider, openid); + +-- provider + unionid 索引(优先用 unionid 查找用户) +CREATE INDEX IF NOT EXISTS idx_social_provider_unionid + ON social_accounts(provider, unionid) + WHERE unionid IS NOT NULL; + +-- userId 索引(查某用户绑定了哪些 provider) +CREATE INDEX IF NOT EXISTS idx_social_user_id + ON social_accounts(user_id); + +-- updated_at 自动更新触发器 +CREATE OR REPLACE FUNCTION update_social_accounts_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_social_accounts_updated_at + BEFORE UPDATE ON social_accounts + FOR EACH ROW EXECUTE FUNCTION update_social_accounts_updated_at(); + +COMMENT ON TABLE social_accounts IS '第三方社交账号绑定记录(微信/Google/Apple 等)'; +COMMENT ON COLUMN social_accounts.provider IS 'Provider 标识: wechat | google | apple'; +COMMENT ON COLUMN social_accounts.openid IS 'Provider 内用户唯一 ID(微信 openid)'; +COMMENT ON COLUMN social_accounts.unionid IS '微信开放平台跨 App 唯一标识(优先用于查找)'; +COMMENT ON COLUMN social_accounts.raw_data IS 'Provider 原始响应(JSONB),保留全量数据'; diff --git a/backend/services/auth-service/.env.example b/backend/services/auth-service/.env.example index 696ca2f..68d6480 100644 --- a/backend/services/auth-service/.env.example +++ b/backend/services/auth-service/.env.example @@ -47,6 +47,12 @@ EMAIL_MAX_VERIFY_ATTEMPTS=5 # GMAIL_APP_PASSWORD=xxxxxxxxxxxxxx (16位,填写时不含空格) # EMAIL_FROM_NAME=Genex +# ── WeChat OAuth (微信开放平台移动应用) ── +# 在微信开放平台 (open.weixin.qq.com) 创建移动应用后获取 +# AppID 以 wx 开头,16位;AppSecret 32位,严格保密,不可泄露到客户端 +# WECHAT_APP_ID=wx0000000000000000 +# WECHAT_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + # ── Kafka (optional, events silently skipped if unavailable) ── KAFKA_BROKERS=localhost:9092 diff --git a/backend/services/auth-service/src/application/services/wechat.service.ts b/backend/services/auth-service/src/application/services/wechat.service.ts new file mode 100644 index 0000000..ed531ab --- /dev/null +++ b/backend/services/auth-service/src/application/services/wechat.service.ts @@ -0,0 +1,178 @@ +// ============================================================ +// WechatService — 微信登录 / 注册业务逻辑 +// +// 流程 (行业标准方案): +// 1. 客户端通过微信 SDK 获取 code(一次性,5 分钟有效) +// 2. 后端用 code 换取 access_token + openid + unionid +// 3. 用 access_token + openid 获取微信用户信息(昵称、头像) +// 4. 按 unionid(优先)或 openid 查找 social_accounts 表 +// - 已存在 → 老用户,同步最新 nickname/avatar,返回 JWT +// - 不存在 → 新用户,自动注册(创建 user + social_account) +// 5. 发布 UserRegistered 事件(仅新注册时,携带 referralCode) +// +// unionid 优先策略: +// 微信的 openid 是每个 App 不同的,同一用户在不同 App 下 openid 不同。 +// unionid 是绑定到微信开放平台账号后跨 App 统一的。 +// 用 unionid 可避免:用户同时安装 genex-mobile 和 admin-app, +// 被识别为两个不同用户。 +// +// 自动生成账号信息: +// - nickname: 来自微信昵称,若为空则生成 "wx_{openid 前8位}" +// - passwordHash: 随机生成(用户无法用密码登录,只能微信登录) +// - walletMode: 默认 'standard' +// ============================================================ + +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { WechatProvider } from '../../infrastructure/wechat/wechat.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 * as crypto from 'crypto'; +import * as bcrypt from 'bcrypt'; + +@Injectable() +export class WechatService { + private readonly logger = new Logger('WechatService'); + + constructor( + private readonly wechatProvider: WechatProvider, + @Inject(SOCIAL_ACCOUNT_REPOSITORY) + private readonly socialAccountRepo: ISocialAccountRepository, + @Inject(USER_REPOSITORY) + private readonly userRepo: IUserRepository, + private readonly tokenService: TokenService, + private readonly eventPublisher: EventPublisherService, + ) {} + + /** + * 微信一键登录 / 自动注册 + * + * @param code 客户端从微信 SDK 获取的 code + * @param referralCode 推荐人的推荐码(可选,仅新用户注册时使用) + * @param deviceInfo 设备信息(可选) + * @param ipAddress 客户端 IP(可选) + */ + async loginOrRegister( + code: string, + referralCode?: string, + deviceInfo?: string, + ipAddress?: string, + ): Promise { + // Step 1: code → access_token + openid + unionid + const tokenResp = await this.wechatProvider.exchangeCodeForToken(code); + const { access_token, openid, unionid } = tokenResp; + + // Step 2: 获取微信用户信息(昵称、头像) + const userInfo = await this.wechatProvider.getUserInfo(access_token, openid); + + const nickname = userInfo.nickname?.trim() || `wx_${openid.slice(0, 8)}`; + const avatarUrl = userInfo.headimgurl || null; + const rawData = userInfo as unknown as Record; + + // Step 3: 查找已有 social_account + let socialAccount = await this.findExistingSocialAccount(openid, unionid); + + if (socialAccount) { + // ── 老用户:同步最新信息并登录 ── + socialAccount.nickname = nickname; + socialAccount.avatarUrl = avatarUrl; + if (unionid && !socialAccount.unionid) { + // 补充 unionid(首次绑定开放平台时 unionid 才出现) + socialAccount.unionid = unionid; + } + 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(`WeChat login: userId=${user.id} openid=${openid.slice(0, 8)}...`); + return { user: this.toUserDto(user), tokens }; + } + + // ── 新用户:自动注册 ── + const randomPasswordHash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10); + + 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.WECHAT, + openid, + unionid: unionid || undefined, + 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(`WeChat new user registered: userId=${user.id} openid=${openid.slice(0, 8)}...`); + return { user: this.toUserDto(user), tokens }; + } + + /** + * 查找已有的 social_account + * 优先用 unionid(跨 App 唯一),回退到 openid + */ + private async findExistingSocialAccount(openid: string, unionid?: string) { + if (unionid) { + const byUnionid = await this.socialAccountRepo.findByProviderAndUnionid( + SocialProvider.WECHAT, + unionid, + ); + if (byUnionid) return byUnionid; + } + return this.socialAccountRepo.findByProviderAndOpenid(SocialProvider.WECHAT, openid); + } + + private toUserDto(user: any) { + return { + id: user.id, + phone: user.phone ?? null, + email: user.email ?? null, + nickname: user.nickname ?? null, + avatarUrl: user.avatarUrl ?? null, + role: user.role, + kycLevel: user.kycLevel, + walletMode: user.walletMode, + }; + } +} diff --git a/backend/services/auth-service/src/auth.module.ts b/backend/services/auth-service/src/auth.module.ts index 8600235..3deea33 100644 --- a/backend/services/auth-service/src/auth.module.ts +++ b/backend/services/auth-service/src/auth.module.ts @@ -10,6 +10,7 @@ import { SmsVerification } from './domain/entities/sms-verification.entity'; import { SmsLog } from './domain/entities/sms-log.entity'; import { EmailVerification } from './domain/entities/email-verification.entity'; import { EmailLog } from './domain/entities/email-log.entity'; +import { SocialAccount } from './domain/entities/social-account.entity'; // Domain repository interfaces import { USER_REPOSITORY } from './domain/repositories/user.repository.interface'; @@ -18,6 +19,7 @@ import { SMS_VERIFICATION_REPOSITORY } from './domain/repositories/sms-verificat import { SMS_LOG_REPOSITORY } from './domain/repositories/sms-log.repository.interface'; import { EMAIL_VERIFICATION_REPOSITORY } from './domain/repositories/email-verification.repository.interface'; import { EMAIL_LOG_REPOSITORY } from './domain/repositories/email-log.repository.interface'; +import { SOCIAL_ACCOUNT_REPOSITORY } from './domain/repositories/social-account.repository.interface'; // Infrastructure implementations import { UserRepository } from './infrastructure/persistence/user.repository'; @@ -26,6 +28,7 @@ import { SmsVerificationRepository } from './infrastructure/persistence/sms-veri import { SmsLogRepository } from './infrastructure/persistence/sms-log.repository'; import { EmailVerificationRepository } from './infrastructure/persistence/email-verification.repository'; import { EmailLogRepository } from './infrastructure/persistence/email-log.repository'; +import { SocialAccountRepository } from './infrastructure/persistence/social-account.repository'; import { JwtStrategy } from './infrastructure/strategies/jwt.strategy'; import { TokenBlacklistService } from './infrastructure/redis/token-blacklist.service'; import { SmsCodeService } from './infrastructure/redis/sms-code.service'; @@ -41,11 +44,15 @@ import { EMAIL_PROVIDER } from './infrastructure/email/email-provider.interface' import { ConsoleEmailProvider } from './infrastructure/email/console-email.provider'; import { GmailProvider } from './infrastructure/email/gmail.provider'; +// WeChat +import { WechatProvider } from './infrastructure/wechat/wechat.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 { EventPublisherService } from './application/services/event-publisher.service'; // Interface controllers @@ -54,7 +61,7 @@ import { AdminSmsController } from './interface/http/controllers/admin-sms.contr @Module({ imports: [ - TypeOrmModule.forFeature([User, RefreshToken, SmsVerification, SmsLog, EmailVerification, EmailLog]), + TypeOrmModule.forFeature([User, RefreshToken, SmsVerification, SmsLog, EmailVerification, EmailLog, SocialAccount]), PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret', @@ -70,6 +77,7 @@ import { AdminSmsController } from './interface/http/controllers/admin-sms.contr { provide: SMS_LOG_REPOSITORY, useClass: SmsLogRepository }, { provide: EMAIL_VERIFICATION_REPOSITORY, useClass: EmailVerificationRepository }, { provide: EMAIL_LOG_REPOSITORY, useClass: EmailLogRepository }, + { provide: SOCIAL_ACCOUNT_REPOSITORY, useClass: SocialAccountRepository }, // SMS Provider: toggle by SMS_ENABLED env var { @@ -94,14 +102,16 @@ import { AdminSmsController } from './interface/http/controllers/admin-sms.contr TokenBlacklistService, SmsCodeService, EmailCodeService, + WechatProvider, // Application services AuthService, TokenService, SmsService, EmailService, + WechatService, EventPublisherService, ], - exports: [AuthService, TokenService, SmsService, EmailService], + exports: [AuthService, TokenService, SmsService, EmailService, WechatService], }) export class AuthModule {} diff --git a/backend/services/auth-service/src/domain/entities/social-account.entity.ts b/backend/services/auth-service/src/domain/entities/social-account.entity.ts new file mode 100644 index 0000000..783ce4d --- /dev/null +++ b/backend/services/auth-service/src/domain/entities/social-account.entity.ts @@ -0,0 +1,96 @@ +// ============================================================ +// SocialAccount Entity — 第三方社交账号绑定记录 +// +// 表名: social_accounts +// +// 设计思路 (主流大厂方案): +// 用 social_accounts 表存储第三方账号与系统用户的映射关系, +// 而不是在 users 表加大量字段。 +// 好处: +// - 一个用户可绑定多个第三方账号(微信 + Google + Apple) +// - 方便扩展新的 provider,无需改 users 表 +// - 第三方 token 隔离存储,不影响主账号安全边界 +// +// Provider 设计: +// provider — 'wechat' | 'google' | 'apple' (预留) +// openid — 微信中为 openid(每个应用不同),Google 中为 sub +// unionid — 微信专有:同一开放平台账号下所有应用共享 +// 是微信跨 App 识别同一用户的唯一手段 +// +// openid vs unionid (微信特有): +// - openid: 同一用户在不同应用下不同(不能跨 App 识别用户) +// - unionid: 绑定到微信开放平台账号后,跨所有 App 唯一 +// ⇒ 优先用 unionid 查找用户;没有 unionid 时 fallback 到 openid +// +// 用户数据来源 (nickname/avatarUrl/rawData): +// 微信提供:nickname(可能含 Emoji)、headimgurl(头像,132x132) +// rawData: 完整 provider 响应,保留全量信息备用 +// +// 生命周期: +// 新用户 WeChat 登录 → create user + create social_account +// 老用户关联微信 → 仅 create social_account(user_id 已有) +// 取消关联 → delete social_account(保留 user) +// ============================================================ + +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +/** 已支持的第三方登录 Provider */ +export enum SocialProvider { + WECHAT = 'wechat', + GOOGLE = 'google', // 预留,暂未实现 + APPLE = 'apple', // 预留,暂未实现 +} + +@Entity('social_accounts') +@Index('idx_social_provider_openid', ['provider', 'openid'], { unique: true }) +@Index('idx_social_provider_unionid', ['provider', 'unionid']) +@Index('idx_social_user_id', ['userId']) +export class SocialAccount { + @PrimaryGeneratedColumn('increment', { type: 'bigint' }) + id: string; + + /** 系统用户 ID(外键 → users.id) */ + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + /** Provider 标识 */ + @Column({ type: 'varchar', length: 20 }) + provider: SocialProvider; + + /** Provider 用户唯一 ID(微信 openid,Google sub 等) */ + @Column({ type: 'varchar', length: 128 }) + openid: string; + + /** + * 微信 unionid(跨 App 唯一标识) + * 仅在用户将 WeChat 绑定到同一开放平台账号时有值。 + * 如果有 unionid,优先以 unionid 查找用户,避免跨 App 重复注册。 + */ + @Column({ type: 'varchar', length: 128, nullable: true }) + unionid: string | null; + + /** Provider 返回的显示名(微信昵称等) */ + @Column({ type: 'varchar', length: 100, nullable: true }) + nickname: string | null; + + /** Provider 返回的头像 URL */ + @Column({ name: 'avatar_url', type: 'varchar', length: 500, nullable: true }) + avatarUrl: string | null; + + /** Provider 完整原始响应(JSONB),保留全量字段备用 */ + @Column({ name: 'raw_data', type: 'jsonb', nullable: true }) + rawData: Record | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/backend/services/auth-service/src/domain/repositories/social-account.repository.interface.ts b/backend/services/auth-service/src/domain/repositories/social-account.repository.interface.ts new file mode 100644 index 0000000..65ed3a5 --- /dev/null +++ b/backend/services/auth-service/src/domain/repositories/social-account.repository.interface.ts @@ -0,0 +1,49 @@ +import { SocialAccount, SocialProvider } from '../entities/social-account.entity'; + +export interface ISocialAccountRepository { + /** + * 按 provider + openid 查找社交账号 + * 微信场景:openid 唯一标识当前 App 下的用户 + */ + findByProviderAndOpenid( + provider: SocialProvider, + openid: string, + ): Promise; + + /** + * 按 provider + unionid 查找社交账号 + * 微信场景:unionid 可跨 App 识别同一微信用户(优先使用) + */ + findByProviderAndUnionid( + provider: SocialProvider, + unionid: string, + ): Promise; + + /** + * 查找某用户绑定的指定 provider 账号 + * 用于"已绑定"检查,防止重复绑定 + */ + findByUserAndProvider( + userId: string, + provider: SocialProvider, + ): Promise; + + /** 创建社交账号绑定记录 */ + create(data: { + userId: string; + provider: SocialProvider; + openid: string; + unionid?: string; + nickname?: string; + avatarUrl?: string; + rawData?: Record; + }): Promise; + + /** 更新社交账号信息(头像/昵称等,每次登录同步) */ + save(account: SocialAccount): Promise; + + /** 解绑社交账号 */ + delete(id: string): Promise; +} + +export const SOCIAL_ACCOUNT_REPOSITORY = Symbol('ISocialAccountRepository'); diff --git a/backend/services/auth-service/src/infrastructure/persistence/social-account.repository.ts b/backend/services/auth-service/src/infrastructure/persistence/social-account.repository.ts new file mode 100644 index 0000000..b0c4c61 --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/persistence/social-account.repository.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SocialAccount, SocialProvider } from '../../domain/entities/social-account.entity'; +import { ISocialAccountRepository } from '../../domain/repositories/social-account.repository.interface'; + +@Injectable() +export class SocialAccountRepository implements ISocialAccountRepository { + constructor( + @InjectRepository(SocialAccount) + private readonly repo: Repository, + ) {} + + async findByProviderAndOpenid( + provider: SocialProvider, + openid: string, + ): Promise { + return this.repo.findOne({ where: { provider, openid } }); + } + + async findByProviderAndUnionid( + provider: SocialProvider, + unionid: string, + ): Promise { + return this.repo.findOne({ where: { provider, unionid } }); + } + + async findByUserAndProvider( + userId: string, + provider: SocialProvider, + ): Promise { + return this.repo.findOne({ where: { userId, provider } }); + } + + async create(data: { + userId: string; + provider: SocialProvider; + openid: string; + unionid?: string; + nickname?: string; + avatarUrl?: string; + rawData?: Record; + }): Promise { + const entity = this.repo.create({ + userId: data.userId, + provider: data.provider, + openid: data.openid, + unionid: data.unionid ?? null, + nickname: data.nickname ?? null, + avatarUrl: data.avatarUrl ?? null, + rawData: data.rawData ?? null, + }); + return this.repo.save(entity); + } + + async save(account: SocialAccount): Promise { + return this.repo.save(account); + } + + async delete(id: string): Promise { + await this.repo.delete(id); + } +} diff --git a/backend/services/auth-service/src/infrastructure/wechat/wechat.provider.ts b/backend/services/auth-service/src/infrastructure/wechat/wechat.provider.ts new file mode 100644 index 0000000..e0f502d --- /dev/null +++ b/backend/services/auth-service/src/infrastructure/wechat/wechat.provider.ts @@ -0,0 +1,130 @@ +// ============================================================ +// WechatProvider — 微信开放平台 HTTP API 封装 +// +// 实现微信 OAuth 2.0 授权码模式(移动应用): +// +// Step 1 (客户端): 微信 SDK 向微信 App 发起授权 → 获得 code +// Step 2 (本文件): code → access_token + openid + unionid +// Step 3 (本文件): access_token + openid → 用户信息(昵称、头像等) +// +// 微信 API 特点: +// - 返回格式始终是 200 OK;错误通过 errcode 字段区分 +// - access_token 有效期 7200 秒(2小时) +// - code 一次性使用,5 分钟内有效 +// - unionid 仅在 snsapi_userinfo scope 且应用已接入开放平台时有值 +// +// 必要环境变量: +// WECHAT_APP_ID — 微信开放平台移动应用 AppID(wx 开头,16位) +// WECHAT_APP_SECRET — 应用密钥(32位,严格保密,不可泄露到客户端) +// +// 安全注意事项: +// - APP_SECRET 只在服务端使用,绝不下发给 App +// - code 交换 access_token 在服务端完成(防 MitM 攻击) +// - 生产环境应对 code 做防重放检测(Redis 标记已使用的 code) +// ============================================================ + +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import * as https from 'https'; + +/** 微信 access_token 接口响应 */ +export interface WechatTokenResponse { + access_token: string; + expires_in: number; + refresh_token: string; + openid: string; + scope: string; + unionid?: string; // 绑定开放平台账号时才有 + errcode?: number; + errmsg?: string; +} + +/** 微信用户信息接口响应 */ +export interface WechatUserInfo { + openid: string; + nickname: string; + sex: number; // 0=未知 1=男 2=女 + province: string; + city: string; + country: string; + headimgurl: string; // 用户头像 URL,最后一个数值为图片大小(0=640x640) + privilege: string[]; + unionid?: string; + errcode?: number; + errmsg?: string; +} + +@Injectable() +export class WechatProvider { + private readonly logger = new Logger('WechatProvider'); + private readonly appId = process.env.WECHAT_APP_ID; + private readonly appSecret = process.env.WECHAT_APP_SECRET; + + /** + * Step 2: 用 code 换取 access_token + * + * API: GET https://api.weixin.qq.com/sns/oauth2/access_token + * ?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code + * + * @param code 客户端从微信 SDK 获取的一次性 code(5 分钟有效) + */ + async exchangeCodeForToken(code: string): Promise { + const url = + `https://api.weixin.qq.com/sns/oauth2/access_token` + + `?appid=${this.appId}&secret=${this.appSecret}` + + `&code=${code}&grant_type=authorization_code`; + + const data = await this.httpsGet(url); + const result = JSON.parse(data) as WechatTokenResponse; + + if (result.errcode) { + this.logger.error(`WeChat token exchange failed: [${result.errcode}] ${result.errmsg}`); + throw new BadRequestException(`微信授权失败: ${result.errmsg} (code: ${result.errcode})`); + } + + this.logger.log(`WeChat token exchanged: openid=${result.openid.slice(0, 8)}...`); + return result; + } + + /** + * Step 3: 用 access_token 获取微信用户信息 + * + * API: GET https://api.weixin.qq.com/sns/userinfo + * ?access_token=TOKEN&openid=OPENID&lang=zh_CN + * + * 需要 scope = snsapi_userinfo(snsapi_base 只能获取 openid) + * + * @param accessToken 来自 exchangeCodeForToken 的 access_token + * @param openid 来自 exchangeCodeForToken 的 openid + */ + async getUserInfo(accessToken: string, openid: string): Promise { + const url = + `https://api.weixin.qq.com/sns/userinfo` + + `?access_token=${accessToken}&openid=${openid}&lang=zh_CN`; + + const data = await this.httpsGet(url); + const result = JSON.parse(data) as WechatUserInfo; + + if (result.errcode) { + this.logger.error(`WeChat userinfo failed: [${result.errcode}] ${result.errmsg}`); + throw new BadRequestException(`获取微信用户信息失败: ${result.errmsg}`); + } + + return result; + } + + /** 简单的 HTTPS GET 封装(不引入外部 HTTP 库) */ + private httpsGet(url: string): Promise { + return new Promise((resolve, reject) => { + const req = https.get(url, (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => resolve(body)); + }); + req.on('error', reject); + req.setTimeout(10000, () => { + req.destroy(); + reject(new Error('WeChat API timeout')); + }); + }); + } +} diff --git a/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts b/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts index 3a86024..6c0c844 100644 --- a/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts +++ b/backend/services/auth-service/src/interface/http/controllers/auth.controller.ts @@ -12,6 +12,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagg 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 { RegisterDto } from '../dto/register.dto'; import { LoginDto } from '../dto/login.dto'; import { RefreshTokenDto } from '../dto/refresh-token.dto'; @@ -24,11 +25,15 @@ import { SendEmailCodeDto } from '../dto/send-email-code.dto'; 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'; @ApiTags('Auth') @Controller('auth') export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly wechatService: WechatService, + ) {} /* ── SMS 验证码 ── */ @@ -256,4 +261,25 @@ export class AuthController { message: '密码重置成功,请重新登录', }; } + + /* ── 微信登录 / 注册 ── */ + + @Post('wechat') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '微信一键登录(新用户自动注册)' }) + @ApiResponse({ status: 200, description: '登录成功,返回 JWT' }) + @ApiResponse({ status: 400, description: '微信 code 无效或已过期' }) + async wechatLogin(@Body() dto: WechatLoginDto, @Ip() ip: string) { + const result = await this.wechatService.loginOrRegister( + dto.code, + dto.referralCode, + dto.deviceInfo, + ip, + ); + return { + code: 0, + data: result, + message: '登录成功', + }; + } } diff --git a/backend/services/auth-service/src/interface/http/dto/wechat-login.dto.ts b/backend/services/auth-service/src/interface/http/dto/wechat-login.dto.ts new file mode 100644 index 0000000..6ecd291 --- /dev/null +++ b/backend/services/auth-service/src/interface/http/dto/wechat-login.dto.ts @@ -0,0 +1,20 @@ +import { IsString, IsOptional, Length } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class WechatLoginDto { + @ApiProperty({ description: '微信 SDK 返回的一次性 code(5 分钟有效)', example: '0b1234...' }) + @IsString() + @Length(1, 200) + code: string; + + @ApiPropertyOptional({ description: '推荐人的推荐码(新用户注册时使用)', example: 'ABCD1234' }) + @IsOptional() + @IsString() + @Length(1, 20) + referralCode?: string; + + @ApiPropertyOptional({ description: '设备信息', example: 'iPhone 15 iOS 17' }) + @IsOptional() + @IsString() + deviceInfo?: string; +} diff --git a/frontend/genex-mobile/android/app/proguard-rules.pro b/frontend/genex-mobile/android/app/proguard-rules.pro index d6d76a5..268d90a 100644 --- a/frontend/genex-mobile/android/app/proguard-rules.pro +++ b/frontend/genex-mobile/android/app/proguard-rules.pro @@ -6,3 +6,9 @@ -keep class io.flutter.** { *; } -keep class io.flutter.plugins.** { *; } -dontwarn io.flutter.embedding.** + +# WeChat SDK (fluwx) +-keep class com.tencent.mm.opensdk.** { *; } +-keep class com.tencent.wxop.** { *; } +-keep class com.tencent.mm.sdk.** { *; } +-keep class cn.gogenex.consumer.wxapi.** { *; } diff --git a/frontend/genex-mobile/android/app/src/main/AndroidManifest.xml b/frontend/genex-mobile/android/app/src/main/AndroidManifest.xml index 5f44d93..fdd414e 100644 --- a/frontend/genex-mobile/android/app/src/main/AndroidManifest.xml +++ b/frontend/genex-mobile/android/app/src/main/AndroidManifest.xml @@ -36,11 +36,22 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> + + + + + diff --git a/frontend/genex-mobile/android/app/src/main/kotlin/cn/gogenex/consumer/wxapi/WXEntryActivity.kt b/frontend/genex-mobile/android/app/src/main/kotlin/cn/gogenex/consumer/wxapi/WXEntryActivity.kt new file mode 100644 index 0000000..a4eca58 --- /dev/null +++ b/frontend/genex-mobile/android/app/src/main/kotlin/cn/gogenex/consumer/wxapi/WXEntryActivity.kt @@ -0,0 +1,26 @@ +// ============================================================ +// WXEntryActivity — 微信 OAuth 授权回调 Activity +// +// 微信 SDK 要求:授权完成后,微信会唤起宿主 App 的 +// {applicationId}.wxapi.WXEntryActivity 来传递授权结果(code)。 +// +// fluwx 已封装好处理逻辑,只需继承 FluwxWXEntryActivity 即可, +// 无需额外代码。 +// +// 注意: +// 1. AndroidManifest 中必须声明 android:exported="true" +// 2. launchMode 必须为 singleTask(微信规范要求) +// 3. 此文件路径必须严格遵循 {applicationId}/wxapi/WXEntryActivity +// ============================================================ + +package cn.gogenex.consumer.wxapi + +import com.tencent.mm.opensdk.openapi.IWXAPIEventHandler +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import com.tencent.mm.opensdk.modelbase.BaseReq +import com.tencent.mm.opensdk.modelbase.BaseResp +import com.jarvanmo.fluwx.WXEntryActivity + +class WXEntryActivity : WXEntryActivity() diff --git a/frontend/genex-mobile/ios/Runner/Info.plist b/frontend/genex-mobile/ios/Runner/Info.plist index 741e60e..1541342 100644 --- a/frontend/genex-mobile/ios/Runner/Info.plist +++ b/frontend/genex-mobile/ios/Runner/Info.plist @@ -45,5 +45,31 @@ UIApplicationSupportsIndirectInputEvents + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + weixin + CFBundleURLSchemes + + wx$(WECHAT_APP_ID) + + + + + + LSApplicationQueriesSchemes + + weixin + weixinULAPI + diff --git a/frontend/genex-mobile/lib/app/i18n/strings/en.dart b/frontend/genex-mobile/lib/app/i18n/strings/en.dart index 78ab329..93ce6e6 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/en.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/en.dart @@ -33,6 +33,8 @@ const Map en = { 'welcome.phoneRegister': 'Phone Sign Up', 'welcome.emailRegister': 'Email Sign Up', 'welcome.wechat': 'WeChat', + 'welcome.wechatNotInstalled': 'Please install WeChat first', + 'welcome.wechatLoginFailed': 'WeChat login failed, please try again', 'welcome.otherLogin': 'Other Login Methods', 'welcome.hasAccount': 'Already have an account?', 'welcome.login': 'Log In', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart index 30493b6..0826f6c 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart @@ -33,6 +33,8 @@ const Map ja = { 'welcome.phoneRegister': '電話番号で登録', 'welcome.emailRegister': 'メールで登録', 'welcome.wechat': 'WeChat', + 'welcome.wechatNotInstalled': 'WeChatアプリをインストールしてください', + 'welcome.wechatLoginFailed': 'WeChatログインに失敗しました。再試行してください', 'welcome.otherLogin': '他の方法でログイン', 'welcome.hasAccount': 'アカウントをお持ちですか?', 'welcome.login': 'ログイン', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart index 023ca2d..5f015c0 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart @@ -33,6 +33,8 @@ const Map zhCN = { 'welcome.phoneRegister': '手机号注册', 'welcome.emailRegister': '邮箱注册', 'welcome.wechat': '微信', + 'welcome.wechatNotInstalled': '请先安装微信 App', + 'welcome.wechatLoginFailed': '微信登录失败,请重试', 'welcome.otherLogin': '其他方式登录', 'welcome.hasAccount': '已有账号?', 'welcome.login': '登录', diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart index b34a450..3382a39 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart @@ -33,6 +33,8 @@ const Map zhTW = { 'welcome.phoneRegister': '手機號註冊', 'welcome.emailRegister': '信箱註冊', 'welcome.wechat': '微信', + 'welcome.wechatNotInstalled': '請先安裝微信 App', + 'welcome.wechatLoginFailed': '微信登入失敗,請重試', 'welcome.otherLogin': '其他方式登入', 'welcome.hasAccount': '已有帳號?', 'welcome.login': '登入', diff --git a/frontend/genex-mobile/lib/core/services/auth_service.dart b/frontend/genex-mobile/lib/core/services/auth_service.dart index 4ad5ccd..3093800 100644 --- a/frontend/genex-mobile/lib/core/services/auth_service.dart +++ b/frontend/genex-mobile/lib/core/services/auth_service.dart @@ -232,6 +232,28 @@ class AuthService { return result; } + // ── 微信登录 ────────────────────────────────────────────────────────────── + + /// 微信一键登录 / 自动注册 + /// + /// [code] 来自 fluwx WXAuthResp.code(微信 SDK 授权后返回,一次性,5 分钟有效) + /// [referralCode] 可选推荐码,仅新用户注册时生效 + Future loginByWechat({ + required String code, + String? referralCode, + String? deviceInfo, + }) async { + final resp = await _api.post('/api/v1/auth/wechat', data: { + 'code': code, + if (referralCode != null && referralCode.isNotEmpty) + 'referralCode': referralCode.toUpperCase(), + if (deviceInfo != null) 'deviceInfo': deviceInfo, + }); + final result = AuthResult.fromJson(resp.data['data']); + await _setAuth(result); + return result; + } + // ── 邮件重置密码 ────────────────────────────────────────────────────────── /// 通过邮件验证码重置密码(忘记密码场景,需先用 EmailCodeType.resetPassword 获取验证码) diff --git a/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart b/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart index f33c4e9..bf3329c 100644 --- a/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart +++ b/frontend/genex-mobile/lib/features/auth/presentation/pages/welcome_page.dart @@ -1,16 +1,72 @@ import 'package:flutter/material.dart'; +import 'package:fluwx/fluwx.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../../app/theme/app_typography.dart'; import '../../../../app/theme/app_spacing.dart'; import '../../../../shared/widgets/genex_button.dart'; import '../../../../app/i18n/app_localizations.dart'; +import '../../../../core/services/auth_service.dart'; /// A1. 欢迎页 - 品牌展示 + 注册/登录入口 /// /// 品牌Logo、Slogan、手机号注册、邮箱注册、社交登录入口(WeChat/Google/Apple) -class WelcomePage extends StatelessWidget { +class WelcomePage extends StatefulWidget { const WelcomePage({super.key}); + @override + State createState() => _WelcomePageState(); +} + +class _WelcomePageState extends State { + bool _wechatLoading = false; + + @override + void initState() { + super.initState(); + // 监听微信授权回调(用户授权后,微信 App 返回 code) + weChatResponseEventHandler.distinct().listen((res) { + if (res is WXAuthResp && mounted) { + _handleWechatAuthResp(res); + } + }); + } + + Future _onWechatTap() async { + final installed = await isWeChatInstalled; + if (!installed) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.t('welcome.wechatNotInstalled'))), + ); + } + return; + } + setState(() => _wechatLoading = true); + // 发起微信授权,scope = snsapi_userinfo 可获取用户信息(昵称、头像) + await sendWeChatAuth(scope: 'snsapi_userinfo', state: 'genex_login'); + // 授权结果通过 weChatResponseEventHandler 异步回调 + } + + Future _handleWechatAuthResp(WXAuthResp resp) async { + setState(() => _wechatLoading = false); + if (resp.errCode != 0 || resp.code == null) { + // 用户取消授权或授权失败,不做任何处理 + return; + } + try { + await AuthService.instance.loginByWechat(code: resp.code!); + if (mounted) { + Navigator.pushReplacementNamed(context, '/main'); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.t('welcome.wechatLoginFailed'))), + ); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -113,9 +169,8 @@ class WelcomePage extends StatelessWidget { icon: Icons.wechat, label: context.t('welcome.wechat'), color: const Color(0xFF07C160), - onTap: () { - Navigator.pushReplacementNamed(context, '/main'); - }, + loading: _wechatLoading, + onTap: _onWechatTap, ), const SizedBox(width: 24), _SocialLoginButton( @@ -176,18 +231,20 @@ class _SocialLoginButton extends StatelessWidget { final String label; final Color? color; final VoidCallback onTap; + final bool loading; const _SocialLoginButton({ required this.icon, required this.label, required this.onTap, this.color, + this.loading = false, }); @override Widget build(BuildContext context) { return GestureDetector( - onTap: onTap, + onTap: loading ? null : onTap, child: Column( children: [ Container( @@ -198,7 +255,15 @@ class _SocialLoginButton extends StatelessWidget { shape: BoxShape.circle, border: Border.all(color: color?.withValues(alpha: 0.3) ?? AppColors.border), ), - child: Icon(icon, size: 28, color: color ?? AppColors.textPrimary), + child: loading + ? Padding( + padding: const EdgeInsets.all(14), + child: CircularProgressIndicator( + strokeWidth: 2, + color: color ?? AppColors.textPrimary, + ), + ) + : Icon(icon, size: 28, color: color ?? AppColors.textPrimary), ), const SizedBox(height: 6), Text(label, style: AppTypography.caption), diff --git a/frontend/genex-mobile/lib/main.dart b/frontend/genex-mobile/lib/main.dart index 2b16a42..615feb4 100644 --- a/frontend/genex-mobile/lib/main.dart +++ b/frontend/genex-mobile/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:fluwx/fluwx.dart'; import 'app/theme/app_theme.dart'; import 'app/main_shell.dart'; import 'app/i18n/app_localizations.dart'; @@ -42,6 +43,14 @@ import 'features/profile/presentation/pages/share_page.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + // 初始化微信 SDK(WECHAT_APP_ID 在构建时注入,通过 --dart-define 传入) + // iOS: CFBundleURLSchemes 中必须已配置 wx{AppID} + // Android: WXEntryActivity 必须在 AndroidManifest 中声明 + const wechatAppId = String.fromEnvironment('WECHAT_APP_ID', defaultValue: ''); + if (wechatAppId.isNotEmpty) { + await registerWxApi(appId: wechatAppId, universalLink: 'https://www.gogenex.com/wechat/'); + } + // 初始化升级服务(走 Nginx 反向代理 → Kong 网关) UpdateService().initialize(UpdateConfig.selfHosted( apiBaseUrl: 'https://api.gogenex.com', diff --git a/frontend/genex-mobile/pubspec.yaml b/frontend/genex-mobile/pubspec.yaml index a95a5d2..d540b10 100644 --- a/frontend/genex-mobile/pubspec.yaml +++ b/frontend/genex-mobile/pubspec.yaml @@ -26,6 +26,7 @@ dependencies: qr_flutter: ^4.1.0 share_plus: ^10.0.2 flutter_secure_storage: ^9.2.2 + fluwx: ^3.10.0 dev_dependencies: flutter_test: