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:
parent
ff558ab77f
commit
d68d48cb95
|
|
@ -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),保留全量数据';
|
||||||
|
|
@ -47,6 +47,12 @@ EMAIL_MAX_VERIFY_ATTEMPTS=5
|
||||||
# GMAIL_APP_PASSWORD=xxxxxxxxxxxxxx (16位,填写时不含空格)
|
# GMAIL_APP_PASSWORD=xxxxxxxxxxxxxx (16位,填写时不含空格)
|
||||||
# EMAIL_FROM_NAME=Genex
|
# 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 (optional, events silently skipped if unavailable) ──
|
||||||
KAFKA_BROKERS=localhost:9092
|
KAFKA_BROKERS=localhost:9092
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { SmsVerification } from './domain/entities/sms-verification.entity';
|
||||||
import { SmsLog } from './domain/entities/sms-log.entity';
|
import { SmsLog } from './domain/entities/sms-log.entity';
|
||||||
import { EmailVerification } from './domain/entities/email-verification.entity';
|
import { EmailVerification } from './domain/entities/email-verification.entity';
|
||||||
import { EmailLog } from './domain/entities/email-log.entity';
|
import { EmailLog } from './domain/entities/email-log.entity';
|
||||||
|
import { SocialAccount } from './domain/entities/social-account.entity';
|
||||||
|
|
||||||
// Domain repository interfaces
|
// Domain repository interfaces
|
||||||
import { USER_REPOSITORY } from './domain/repositories/user.repository.interface';
|
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 { SMS_LOG_REPOSITORY } from './domain/repositories/sms-log.repository.interface';
|
||||||
import { EMAIL_VERIFICATION_REPOSITORY } from './domain/repositories/email-verification.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 { EMAIL_LOG_REPOSITORY } from './domain/repositories/email-log.repository.interface';
|
||||||
|
import { SOCIAL_ACCOUNT_REPOSITORY } from './domain/repositories/social-account.repository.interface';
|
||||||
|
|
||||||
// Infrastructure implementations
|
// Infrastructure implementations
|
||||||
import { UserRepository } from './infrastructure/persistence/user.repository';
|
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 { SmsLogRepository } from './infrastructure/persistence/sms-log.repository';
|
||||||
import { EmailVerificationRepository } from './infrastructure/persistence/email-verification.repository';
|
import { EmailVerificationRepository } from './infrastructure/persistence/email-verification.repository';
|
||||||
import { EmailLogRepository } from './infrastructure/persistence/email-log.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 { JwtStrategy } from './infrastructure/strategies/jwt.strategy';
|
||||||
import { TokenBlacklistService } from './infrastructure/redis/token-blacklist.service';
|
import { TokenBlacklistService } from './infrastructure/redis/token-blacklist.service';
|
||||||
import { SmsCodeService } from './infrastructure/redis/sms-code.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 { ConsoleEmailProvider } from './infrastructure/email/console-email.provider';
|
||||||
import { GmailProvider } from './infrastructure/email/gmail.provider';
|
import { GmailProvider } from './infrastructure/email/gmail.provider';
|
||||||
|
|
||||||
|
// WeChat
|
||||||
|
import { WechatProvider } from './infrastructure/wechat/wechat.provider';
|
||||||
|
|
||||||
// Application services
|
// Application services
|
||||||
import { AuthService } from './application/services/auth.service';
|
import { AuthService } from './application/services/auth.service';
|
||||||
import { TokenService } from './application/services/token.service';
|
import { TokenService } from './application/services/token.service';
|
||||||
import { SmsService } from './application/services/sms.service';
|
import { SmsService } from './application/services/sms.service';
|
||||||
import { EmailService } from './application/services/email.service';
|
import { EmailService } from './application/services/email.service';
|
||||||
|
import { WechatService } from './application/services/wechat.service';
|
||||||
import { EventPublisherService } from './application/services/event-publisher.service';
|
import { EventPublisherService } from './application/services/event-publisher.service';
|
||||||
|
|
||||||
// Interface controllers
|
// Interface controllers
|
||||||
|
|
@ -54,7 +61,7 @@ import { AdminSmsController } from './interface/http/controllers/admin-sms.contr
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([User, RefreshToken, SmsVerification, SmsLog, EmailVerification, EmailLog]),
|
TypeOrmModule.forFeature([User, RefreshToken, SmsVerification, SmsLog, EmailVerification, EmailLog, SocialAccount]),
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret',
|
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: SMS_LOG_REPOSITORY, useClass: SmsLogRepository },
|
||||||
{ provide: EMAIL_VERIFICATION_REPOSITORY, useClass: EmailVerificationRepository },
|
{ provide: EMAIL_VERIFICATION_REPOSITORY, useClass: EmailVerificationRepository },
|
||||||
{ provide: EMAIL_LOG_REPOSITORY, useClass: EmailLogRepository },
|
{ provide: EMAIL_LOG_REPOSITORY, useClass: EmailLogRepository },
|
||||||
|
{ provide: SOCIAL_ACCOUNT_REPOSITORY, useClass: SocialAccountRepository },
|
||||||
|
|
||||||
// SMS Provider: toggle by SMS_ENABLED env var
|
// SMS Provider: toggle by SMS_ENABLED env var
|
||||||
{
|
{
|
||||||
|
|
@ -94,14 +102,16 @@ import { AdminSmsController } from './interface/http/controllers/admin-sms.contr
|
||||||
TokenBlacklistService,
|
TokenBlacklistService,
|
||||||
SmsCodeService,
|
SmsCodeService,
|
||||||
EmailCodeService,
|
EmailCodeService,
|
||||||
|
WechatProvider,
|
||||||
|
|
||||||
// Application services
|
// Application services
|
||||||
AuthService,
|
AuthService,
|
||||||
TokenService,
|
TokenService,
|
||||||
SmsService,
|
SmsService,
|
||||||
EmailService,
|
EmailService,
|
||||||
|
WechatService,
|
||||||
EventPublisherService,
|
EventPublisherService,
|
||||||
],
|
],
|
||||||
exports: [AuthService, TokenService, SmsService, EmailService],
|
exports: [AuthService, TokenService, SmsService, EmailService, WechatService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|
|
||||||
|
|
@ -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<string, unknown> | null;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<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_userinfo(snsapi_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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagg
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||||
import { AuthService } from '../../../application/services/auth.service';
|
import { AuthService } from '../../../application/services/auth.service';
|
||||||
|
import { WechatService } from '../../../application/services/wechat.service';
|
||||||
import { RegisterDto } from '../dto/register.dto';
|
import { RegisterDto } from '../dto/register.dto';
|
||||||
import { LoginDto } from '../dto/login.dto';
|
import { LoginDto } from '../dto/login.dto';
|
||||||
import { RefreshTokenDto } from '../dto/refresh-token.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 { RegisterEmailDto } from '../dto/register-email.dto';
|
||||||
import { LoginEmailDto } from '../dto/login-email.dto';
|
import { LoginEmailDto } from '../dto/login-email.dto';
|
||||||
import { ResetPasswordEmailDto } from '../dto/reset-password-email.dto';
|
import { ResetPasswordEmailDto } from '../dto/reset-password-email.dto';
|
||||||
|
import { WechatLoginDto } from '../dto/wechat-login.dto';
|
||||||
|
|
||||||
@ApiTags('Auth')
|
@ApiTags('Auth')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
private readonly wechatService: WechatService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/* ── SMS 验证码 ── */
|
/* ── SMS 验证码 ── */
|
||||||
|
|
||||||
|
|
@ -256,4 +261,25 @@ export class AuthController {
|
||||||
message: '密码重置成功,请重新登录',
|
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: '登录成功',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -6,3 +6,9 @@
|
||||||
-keep class io.flutter.** { *; }
|
-keep class io.flutter.** { *; }
|
||||||
-keep class io.flutter.plugins.** { *; }
|
-keep class io.flutter.plugins.** { *; }
|
||||||
-dontwarn io.flutter.embedding.**
|
-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.** { *; }
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,22 @@
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</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>
|
</application>
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<data android:mimeType="text/plain"/>
|
||||||
</intent>
|
</intent>
|
||||||
|
<!-- 查询微信是否安装 -->
|
||||||
|
<package android:name="com.tencent.mm" />
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -45,5 +45,31 @@
|
||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|
||||||
|
<!-- 微信 SDK URL Scheme: wx{AppID}
|
||||||
|
微信授权完成后,通过此 URL Scheme 回调到本 App。
|
||||||
|
替换 WECHAT_APP_ID 为实际的微信 AppID(wx 开头,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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ const Map<String, String> en = {
|
||||||
'welcome.phoneRegister': 'Phone Sign Up',
|
'welcome.phoneRegister': 'Phone Sign Up',
|
||||||
'welcome.emailRegister': 'Email Sign Up',
|
'welcome.emailRegister': 'Email Sign Up',
|
||||||
'welcome.wechat': 'WeChat',
|
'welcome.wechat': 'WeChat',
|
||||||
|
'welcome.wechatNotInstalled': 'Please install WeChat first',
|
||||||
|
'welcome.wechatLoginFailed': 'WeChat login failed, please try again',
|
||||||
'welcome.otherLogin': 'Other Login Methods',
|
'welcome.otherLogin': 'Other Login Methods',
|
||||||
'welcome.hasAccount': 'Already have an account?',
|
'welcome.hasAccount': 'Already have an account?',
|
||||||
'welcome.login': 'Log In',
|
'welcome.login': 'Log In',
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ const Map<String, String> ja = {
|
||||||
'welcome.phoneRegister': '電話番号で登録',
|
'welcome.phoneRegister': '電話番号で登録',
|
||||||
'welcome.emailRegister': 'メールで登録',
|
'welcome.emailRegister': 'メールで登録',
|
||||||
'welcome.wechat': 'WeChat',
|
'welcome.wechat': 'WeChat',
|
||||||
|
'welcome.wechatNotInstalled': 'WeChatアプリをインストールしてください',
|
||||||
|
'welcome.wechatLoginFailed': 'WeChatログインに失敗しました。再試行してください',
|
||||||
'welcome.otherLogin': '他の方法でログイン',
|
'welcome.otherLogin': '他の方法でログイン',
|
||||||
'welcome.hasAccount': 'アカウントをお持ちですか?',
|
'welcome.hasAccount': 'アカウントをお持ちですか?',
|
||||||
'welcome.login': 'ログイン',
|
'welcome.login': 'ログイン',
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ const Map<String, String> zhCN = {
|
||||||
'welcome.phoneRegister': '手机号注册',
|
'welcome.phoneRegister': '手机号注册',
|
||||||
'welcome.emailRegister': '邮箱注册',
|
'welcome.emailRegister': '邮箱注册',
|
||||||
'welcome.wechat': '微信',
|
'welcome.wechat': '微信',
|
||||||
|
'welcome.wechatNotInstalled': '请先安装微信 App',
|
||||||
|
'welcome.wechatLoginFailed': '微信登录失败,请重试',
|
||||||
'welcome.otherLogin': '其他方式登录',
|
'welcome.otherLogin': '其他方式登录',
|
||||||
'welcome.hasAccount': '已有账号?',
|
'welcome.hasAccount': '已有账号?',
|
||||||
'welcome.login': '登录',
|
'welcome.login': '登录',
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ const Map<String, String> zhTW = {
|
||||||
'welcome.phoneRegister': '手機號註冊',
|
'welcome.phoneRegister': '手機號註冊',
|
||||||
'welcome.emailRegister': '信箱註冊',
|
'welcome.emailRegister': '信箱註冊',
|
||||||
'welcome.wechat': '微信',
|
'welcome.wechat': '微信',
|
||||||
|
'welcome.wechatNotInstalled': '請先安裝微信 App',
|
||||||
|
'welcome.wechatLoginFailed': '微信登入失敗,請重試',
|
||||||
'welcome.otherLogin': '其他方式登入',
|
'welcome.otherLogin': '其他方式登入',
|
||||||
'welcome.hasAccount': '已有帳號?',
|
'welcome.hasAccount': '已有帳號?',
|
||||||
'welcome.login': '登入',
|
'welcome.login': '登入',
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,28 @@ class AuthService {
|
||||||
return result;
|
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 获取验证码)
|
/// 通过邮件验证码重置密码(忘记密码场景,需先用 EmailCodeType.resetPassword 获取验证码)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,72 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fluwx/fluwx.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../../app/theme/app_typography.dart';
|
import '../../../../app/theme/app_typography.dart';
|
||||||
import '../../../../app/theme/app_spacing.dart';
|
import '../../../../app/theme/app_spacing.dart';
|
||||||
import '../../../../shared/widgets/genex_button.dart';
|
import '../../../../shared/widgets/genex_button.dart';
|
||||||
import '../../../../app/i18n/app_localizations.dart';
|
import '../../../../app/i18n/app_localizations.dart';
|
||||||
|
import '../../../../core/services/auth_service.dart';
|
||||||
|
|
||||||
/// A1. 欢迎页 - 品牌展示 + 注册/登录入口
|
/// A1. 欢迎页 - 品牌展示 + 注册/登录入口
|
||||||
///
|
///
|
||||||
/// 品牌Logo、Slogan、手机号注册、邮箱注册、社交登录入口(WeChat/Google/Apple)
|
/// 品牌Logo、Slogan、手机号注册、邮箱注册、社交登录入口(WeChat/Google/Apple)
|
||||||
class WelcomePage extends StatelessWidget {
|
class WelcomePage extends StatefulWidget {
|
||||||
const WelcomePage({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -113,9 +169,8 @@ class WelcomePage extends StatelessWidget {
|
||||||
icon: Icons.wechat,
|
icon: Icons.wechat,
|
||||||
label: context.t('welcome.wechat'),
|
label: context.t('welcome.wechat'),
|
||||||
color: const Color(0xFF07C160),
|
color: const Color(0xFF07C160),
|
||||||
onTap: () {
|
loading: _wechatLoading,
|
||||||
Navigator.pushReplacementNamed(context, '/main');
|
onTap: _onWechatTap,
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
_SocialLoginButton(
|
_SocialLoginButton(
|
||||||
|
|
@ -176,18 +231,20 @@ class _SocialLoginButton extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final Color? color;
|
final Color? color;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final bool loading;
|
||||||
|
|
||||||
const _SocialLoginButton({
|
const _SocialLoginButton({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.color,
|
this.color,
|
||||||
|
this.loading = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: loading ? null : onTap,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -198,7 +255,15 @@ class _SocialLoginButton extends StatelessWidget {
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: color?.withValues(alpha: 0.3) ?? AppColors.border),
|
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),
|
const SizedBox(height: 6),
|
||||||
Text(label, style: AppTypography.caption),
|
Text(label, style: AppTypography.caption),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:fluwx/fluwx.dart';
|
||||||
import 'app/theme/app_theme.dart';
|
import 'app/theme/app_theme.dart';
|
||||||
import 'app/main_shell.dart';
|
import 'app/main_shell.dart';
|
||||||
import 'app/i18n/app_localizations.dart';
|
import 'app/i18n/app_localizations.dart';
|
||||||
|
|
@ -42,6 +43,14 @@ import 'features/profile/presentation/pages/share_page.dart';
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
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 网关)
|
// 初始化升级服务(走 Nginx 反向代理 → Kong 网关)
|
||||||
UpdateService().initialize(UpdateConfig.selfHosted(
|
UpdateService().initialize(UpdateConfig.selfHosted(
|
||||||
apiBaseUrl: 'https://api.gogenex.com',
|
apiBaseUrl: 'https://api.gogenex.com',
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ dependencies:
|
||||||
qr_flutter: ^4.1.0
|
qr_flutter: ^4.1.0
|
||||||
share_plus: ^10.0.2
|
share_plus: ^10.0.2
|
||||||
flutter_secure_storage: ^9.2.2
|
flutter_secure_storage: ^9.2.2
|
||||||
|
fluwx: ^3.10.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue