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位,填写时不含空格)
|
||||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { 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 {}
|
||||
|
|
|
|||
|
|
@ -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 { 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: '登录成功',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.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.** { *; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': 'ログイン',
|
||||
|
|
|
|||
|
|
@ -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': '登录',
|
||||
|
|
|
|||
|
|
@ -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': '登入',
|
||||
|
|
|
|||
|
|
@ -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 获取验证码)
|
||||
|
|
|
|||
|
|
@ -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<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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
// 初始化微信 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',
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue