feat(auth): 微信登录 / 注册完整实现 — social_accounts + fluwx 全链路

后端:
- 新增 social_accounts 表 Entity/Repository/Migration (049)
- WechatProvider: code ↔ access_token / 获取用户信息 (native https)
- WechatService: unionid 优先查找 → 自动登录/注册 → 发布事件
- POST /auth/wechat 端点 (WechatLoginDto, referralCode 支持)
- auth.module.ts 注册 SocialAccount、WechatProvider、WechatService

Flutter (genex-mobile):
- pubspec.yaml: 添加 fluwx ^3.10.0
- main.dart: registerWxApi 初始化 (WECHAT_APP_ID via --dart-define)
- AuthService: loginByWechat(code, referralCode?, deviceInfo?)
- WelcomePage: 改为 StatefulWidget,监听 weChatResponseEventHandler
  微信按钮触发 sendWeChatAuth,授权成功后自动登录 → /main
  未安装微信 / 登录失败均有 SnackBar 提示
- 4语言 i18n: wechatNotInstalled / wechatLoginFailed

Android:
- AndroidManifest: WXEntryActivity + queries(com.tencent.mm)
- WXEntryActivity.kt: 继承 fluwx 提供的基类,无额外代码
- proguard-rules.pro: keep WeChat SDK 类

iOS:
- Info.plist: CFBundleURLTypes (wx${WECHAT_APP_ID}) + LSApplicationQueriesSchemes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-04 03:37:51 -08:00
parent ff558ab77f
commit d68d48cb95
22 changed files with 816 additions and 9 deletions

View File

@ -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保留全量数据';

View File

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

View File

@ -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<AuthResult> {
// 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<string, unknown>;
// 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,
};
}
}

View File

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

View File

@ -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_accountuser_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微信 openidGoogle 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<string, unknown> | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -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<SocialAccount | null>;
/**
* provider + unionid
* unionid App 使
*/
findByProviderAndUnionid(
provider: SocialProvider,
unionid: string,
): Promise<SocialAccount | null>;
/**
* provider
* "已绑定"
*/
findByUserAndProvider(
userId: string,
provider: SocialProvider,
): Promise<SocialAccount | null>;
/** 创建社交账号绑定记录 */
create(data: {
userId: string;
provider: SocialProvider;
openid: string;
unionid?: string;
nickname?: string;
avatarUrl?: string;
rawData?: Record<string, unknown>;
}): Promise<SocialAccount>;
/** 更新社交账号信息(头像/昵称等,每次登录同步) */
save(account: SocialAccount): Promise<SocialAccount>;
/** 解绑社交账号 */
delete(id: string): Promise<void>;
}
export const SOCIAL_ACCOUNT_REPOSITORY = Symbol('ISocialAccountRepository');

View File

@ -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<SocialAccount>,
) {}
async findByProviderAndOpenid(
provider: SocialProvider,
openid: string,
): Promise<SocialAccount | null> {
return this.repo.findOne({ where: { provider, openid } });
}
async findByProviderAndUnionid(
provider: SocialProvider,
unionid: string,
): Promise<SocialAccount | null> {
return this.repo.findOne({ where: { provider, unionid } });
}
async findByUserAndProvider(
userId: string,
provider: SocialProvider,
): Promise<SocialAccount | null> {
return this.repo.findOne({ where: { userId, provider } });
}
async create(data: {
userId: string;
provider: SocialProvider;
openid: string;
unionid?: string;
nickname?: string;
avatarUrl?: string;
rawData?: Record<string, unknown>;
}): Promise<SocialAccount> {
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<SocialAccount> {
return this.repo.save(account);
}
async delete(id: string): Promise<void> {
await this.repo.delete(id);
}
}

View File

@ -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 — 微信开放平台移动应用 AppIDwx 开头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 code5
*/
async exchangeCodeForToken(code: string): Promise<WechatTokenResponse> {
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_userinfosnsapi_base openid
*
* @param accessToken exchangeCodeForToken access_token
* @param openid exchangeCodeForToken openid
*/
async getUserInfo(accessToken: string, openid: string): Promise<WechatUserInfo> {
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<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('WeChat API timeout'));
});
});
}
}

View File

@ -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: '登录成功',
};
}
}

View File

@ -0,0 +1,20 @@
import { IsString, IsOptional, Length } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class WechatLoginDto {
@ApiProperty({ description: '微信 SDK 返回的一次性 code5 分钟有效)', 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;
}

View File

@ -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.** { *; }

View File

@ -36,11 +36,22 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- WXEntryActivity: 微信 OAuth 回调入口
包名路径必须为 {applicationId}.wxapi.WXEntryActivity
exported=true 让微信 App 能唤起此 Activity -->
<activity
android:name=".wxapi.WXEntryActivity"
android:exported="true"
android:taskAffinity="${applicationId}"
android:launchMode="singleTask" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- 查询微信是否安装 -->
<package android:name="com.tencent.mm" />
</queries>
</manifest>

View File

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

View File

@ -45,5 +45,31 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<!-- 微信 SDK URL Scheme: wx{AppID}
微信授权完成后,通过此 URL Scheme 回调到本 App。
替换 WECHAT_APP_ID 为实际的微信 AppIDwx 开头16位
CFBundleURLSchemes 中填写 wx{AppID}例如wx0000000000000000 -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>weixin</string>
<key>CFBundleURLSchemes</key>
<array>
<string>wx$(WECHAT_APP_ID)</string>
</array>
</dict>
</array>
<!-- 声明可查询微信是否已安装iOS 9+
必须包含 weixin 和 weixinULAPI否则 isWeChatInstalled 始终返回 false -->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>weixin</string>
<string>weixinULAPI</string>
</array>
</dict>
</plist>

View File

@ -33,6 +33,8 @@ const Map<String, String> 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',

View File

@ -33,6 +33,8 @@ const Map<String, String> ja = {
'welcome.phoneRegister': '電話番号で登録',
'welcome.emailRegister': 'メールで登録',
'welcome.wechat': 'WeChat',
'welcome.wechatNotInstalled': 'WeChatアプリをインストールしてください',
'welcome.wechatLoginFailed': 'WeChatログインに失敗しました。再試行してください',
'welcome.otherLogin': '他の方法でログイン',
'welcome.hasAccount': 'アカウントをお持ちですか?',
'welcome.login': 'ログイン',

View File

@ -33,6 +33,8 @@ const Map<String, String> zhCN = {
'welcome.phoneRegister': '手机号注册',
'welcome.emailRegister': '邮箱注册',
'welcome.wechat': '微信',
'welcome.wechatNotInstalled': '请先安装微信 App',
'welcome.wechatLoginFailed': '微信登录失败,请重试',
'welcome.otherLogin': '其他方式登录',
'welcome.hasAccount': '已有账号?',
'welcome.login': '登录',

View File

@ -33,6 +33,8 @@ const Map<String, String> zhTW = {
'welcome.phoneRegister': '手機號註冊',
'welcome.emailRegister': '信箱註冊',
'welcome.wechat': '微信',
'welcome.wechatNotInstalled': '請先安裝微信 App',
'welcome.wechatLoginFailed': '微信登入失敗,請重試',
'welcome.otherLogin': '其他方式登入',
'welcome.hasAccount': '已有帳號?',
'welcome.login': '登入',

View File

@ -232,6 +232,28 @@ class AuthService {
return result;
}
//
/// /
///
/// [code] fluwx WXAuthResp.code SDK 5
/// [referralCode]
Future<AuthResult> 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

View File

@ -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. - + /
///
/// LogoSloganWeChat/Google/Apple
class WelcomePage extends StatelessWidget {
class WelcomePage extends StatefulWidget {
const WelcomePage({super.key});
@override
State<WelcomePage> createState() => _WelcomePageState();
}
class _WelcomePageState extends State<WelcomePage> {
bool _wechatLoading = false;
@override
void initState() {
super.initState();
// App code
weChatResponseEventHandler.distinct().listen((res) {
if (res is WXAuthResp && mounted) {
_handleWechatAuthResp(res);
}
});
}
Future<void> _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<void> _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),

View File

@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// SDKWECHAT_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',

View File

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