docs(auth): 完善微信登录模块注释 — 含申请移动应用完整步骤

wechat.provider.ts:
  - 补充微信开放平台申请移动应用的完整流程(企业资质、创建应用、
    获取 AppID/AppSecret、启用 unionid、获取 Android 签名 MD5)
  - 说明常见 errcode 含义(40029/42003/40163)
  - 安全注意事项(AppSecret 保密、服务端换 token、防重放建议)

wechat.service.ts:
  - 完整业务流程注释(6步,含新老用户分支逻辑)
  - unionid 优先策略原理(跨 App 唯一性,防重复注册)
  - 每次登录同步微信信息说明
  - 自动生成账号字段说明(nickname/email/phone/password/kycLevel)

.env.example:
  - 微信开放平台申请步骤(注册/认证/创建/获取/启用 unionid)
  - Android 签名 MD5 获取命令
  - Flutter --dart-define 构建参数说明
  - Universal Links 说明

WXEntryActivity.kt:
  - 包名路径规范说明(applicationId vs namespace 区别)
  - AndroidManifest 配置要求
  - 回调未触发的排查步骤(签名/包名/debug包名)
  - 获取 Android 签名 MD5 命令

main.dart:
  - registerWxApi 传参说明(--dart-define 用法)
  - 未配置 WECHAT_APP_ID 时的降级行为
  - Universal Links 配置说明

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-04 04:04:26 -08:00
parent c9100d3262
commit 44de21a733
5 changed files with 182 additions and 52 deletions

View File

@ -47,9 +47,44 @@ 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 OAuth (微信开放平台移动应用) ──────────────────────────────────────
#
# 申请步骤(需企业资质,个人无法申请移动应用 OAuth
#
# 1. 注册/登录微信开放平台
# https://open.weixin.qq.com
# → 账号中心 → 开发者资质认证(年费 300 元,需营业执照/组织机构代码证)
#
# 2. 创建移动应用
# 管理中心 → 移动应用 → 创建移动应用
# 填写基本信息应用名称、简介、图标512×512 PNG无圆角
# 填写平台信息:
# Android — 应用包名: cn.gogenex.consumer
# 应用签名: release keystore 的 MD532位小写无冒号分隔
# 获取签名: keytool -exportcert -keystore release.jks \
# -alias release -storepass <密码> | md5sum
# iOS — Bundle ID: cn.gogenex.consumer
# 提交审核,等待 1-7 个工作日
#
# 3. 审核通过后在「应用详情」页面获取:
# AppID (16位wx 开头) — 填入 WECHAT_APP_ID客户端和服务端均需要
# AppSecret (32位) — 填入 WECHAT_APP_SECRET仅服务端使用严格保密
#
# 4. 启用 unionid强烈建议
# 开放平台 → 账号中心 → 公众号/小程序/移动应用 绑定到同一开放平台账号
# 绑定后,同一微信用户在所有绑定应用中 unionid 相同,可跨 App 识别用户
# 若不绑定,不同 App 下同一用户的 openid 不同,会注册出多个账号
#
# 5. Flutter 构建时传入 AppID在 Android Studio / Xcode / CI 脚本中配置):
# flutter build apk --dart-define=WECHAT_APP_ID=wx0000000000000000
# flutter build ipa --dart-define=WECHAT_APP_ID=wx0000000000000000
#
# 注意事项:
# - AppSecret 严禁写入客户端代码或提交到 Git只在服务端 .env 中配置
# - Android 发布正式版本时,签名 MD5 必须与申请时一致,否则微信授权失败
# - iOS Universal Links 需要在开放平台填写 https://www.gogenex.com/wechat/
# 并确保该 URL 可访问 apple-app-site-association 文件
#
# WECHAT_APP_ID=wx0000000000000000
# WECHAT_APP_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

View File

@ -1,25 +1,53 @@
// ============================================================
// 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
// ── 完整流程 ──────────────────────────────────────────────
// 1. Flutter 客户端调用 fluwx.sendWeChatAuth(scope: 'snsapi_userinfo')
// 拉起微信 App → 用户确认授权 → 微信回传 codeWXAuthResp.code
//
// unionid 优先策略:
// 微信的 openid 是每个 App 不同的,同一用户在不同 App 下 openid 不同。
// unionid 是绑定到微信开放平台账号后跨 App 统一的。
// 用 unionid 可避免:用户同时安装 genex-mobile 和 admin-app
// 被识别为两个不同用户。
// 2. 客户端将 code 发送到 POST /api/v1/auth/wechat
// (可同时传 referralCode 推荐码,仅新用户注册时有效)
//
// 自动生成账号信息:
// - nickname: 来自微信昵称,若为空则生成 "wx_{openid 前8位}"
// - passwordHash: 随机生成(用户无法用密码登录,只能微信登录)
// 3. 本服务调用 WechatProvider.exchangeCodeForToken(code)
// → 获得 access_token、openid、unionid
//
// 4. 调用 WechatProvider.getUserInfo(access_token, openid)
// → 获得 nickname、headimgurl头像 URL
//
// 5. 在 social_accounts 表查找已有绑定:
// - 优先用 unionid 查找(跨 App 唯一,避免重复注册)
// - 若无 unionid 则 fallback 到 openid
//
// 6a. 老用户(找到 social_account
// - 同步最新 nickname / avatarUrl头像 URL 可能变化)
// - 若原记录无 unionid 但本次有,则补充 unionid
// - 返回 JWT access_token + refresh_token
//
// 6b. 新用户(未找到 social_account
// - 创建 users 记录phone/email 为 null随机密码哈希
// - 创建 social_accounts 绑定记录
// - 发布 UserRegistered Kafka 事件(含 referralCode
// - 返回 JWT access_token + refresh_token
//
// ── unionid 优先策略原理 ───────────────────────────────────
// 微信 openid 是「每个应用 × 每个用户」独立的,同一人在 genex-mobile
// 和 admin-app 下 openid 不同,如果只用 openid 会产生两个账号。
// unionid 是「同一开放平台账号下所有 App × 用户」统一的,可以跨 App
// 识别同一用户 → 因此优先用 unionid 查找,防止同一人注册两次。
// 注意: unionid 只在应用接入了微信开放平台且 scope=snsapi_userinfo 时才有。
//
// ── 自动生成账号字段 ───────────────────────────────────────
// - nickname : 取自微信昵称;若昵称为空则生成 "wx_{openid 前8位}"
// - email : null微信不提供邮箱可由用户事后在设置中绑定
// - phone : null微信仅在特定场景授权当前不请求手机号权限
// - password : 随机 32 字节 hex → bcrypt 哈希(用户无法用密码登录)
// - walletMode: 默认 'standard'
// - kycLevel : 默认 0需用户主动完成实名认证
//
// ── 每次登录同步微信信息 ───────────────────────────────────
// 微信用户可能修改昵称/头像,每次登录时同步到 social_accounts
// 上层业务可从 social_accounts 获取最新头像 URL 展示。
// users 表的 avatarUrl 不自动更新,避免覆盖用户手动设置的头像)
// ============================================================
import { Injectable, Logger, Inject } from '@nestjs/common';

View File

@ -1,26 +1,62 @@
// ============================================================
// WechatProvider — 微信开放平台 HTTP API 封装
//
// 实现微信 OAuth 2.0 授权码模式(移动应用):
// ── 功能 ──────────────────────────────────────────────────
// 实现微信 OAuth 2.0 授权码模式(移动应用),完整流程如下:
//
// Step 1 (客户端): 微信 SDK 向微信 App 发起授权 → 获得 code
// Step 2 (本文件): code → access_token + openid + unionid
// Step 3 (本文件): access_token + openid → 用户信息(昵称、头像等)
// Step 1 (客户端 fluwx):
// 调用 sendWeChatAuth(scope: 'snsapi_userinfo') 拉起微信 App
// 用户点「允许」→ 微信 App 通过 URL Scheme (wx{AppID}://) 回传 code
// code 一次性5 分钟有效,只能在服务端使用一次
//
// 微信 API 特点:
// - 返回格式始终是 200 OK错误通过 errcode 字段区分
// - access_token 有效期 7200 秒2小时
// - code 一次性使用5 分钟内有效
// - unionid 仅在 snsapi_userinfo scope 且应用已接入开放平台时有值
// Step 2 (本文件 exchangeCodeForToken):
// GET https://api.weixin.qq.com/sns/oauth2/access_token
// ?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code
// 成功返回: access_token, refresh_token, openid, scope, unionid
//
// 必要环境变量:
// WECHAT_APP_ID — 微信开放平台移动应用 AppIDwx 开头16位
// WECHAT_APP_SECRET — 应用密钥32位严格保密不可泄露到客户端
// Step 3 (本文件 getUserInfo):
// GET https://api.weixin.qq.com/sns/userinfo
// ?access_token=TOKEN&openid=OPENID&lang=zh_CN
// 返回: nickname, headimgurl, sex, province, city, country, unionid
//
// 安全注意事项:
// - APP_SECRET 只在服务端使用,绝不下发给 App
// - code 交换 access_token 在服务端完成(防 MitM 攻击)
// - 生产环境应对 code 做防重放检测Redis 标记已使用的 code
// ── 微信 AppID 申请步骤 ────────────────────────────────────
// 1. 注册微信开放平台账号 (需企业认证,个人无法申请移动应用)
// https://open.weixin.qq.com → 注册/登录 → 完成开发者资质认证300元/年)
//
// 2. 创建移动应用
// 管理中心 → 移动应用 → 创建移动应用
// 填写: 应用名称、应用简介、应用图标 (512x512)、应用官网
// 平台信息:
// Android: 填写应用包名 (cn.gogenex.consumer) + 应用签名 (keystore MD532位小写无冒号)
// iOS: 填写 Bundle ID (cn.gogenex.consumer)
// 提交审核1-7 个工作日)
//
// 3. 审核通过后获得:
// AppID (16位wx 开头) — 公开,可在客户端使用
// AppSecret (32位) — 严格保密,只在服务端使用
//
// 4. 接入微信开放平台(启用 unionid
// 开放平台账号绑定微信公众号 / 小程序 / 移动应用后,
// 同一用户在所有绑定应用中 unionid 相同,可跨 App 识别用户
//
// ── 环境变量配置 ───────────────────────────────────────────
// WECHAT_APP_ID — wx 开头的16位 AppID客户端和服务端都需要
// WECHAT_APP_SECRET — 32位应用密钥严格保密仅服务端使用
//
// ── API 特点 ───────────────────────────────────────────────
// - HTTP 响应始终 200 OK错误通过响应体 errcode 字段区分
// - 常见 errcode:
// 40029 = code 无效(已使用或已过期)
// 42003 = code 过期(超过 5 分钟)
// 40163 = code 已被使用
// - access_token 有效期 7200 秒2小时可用 refresh_token 续期
// - unionid 仅在 snsapi_userinfo scope 且应用已接入开放平台时返回
//
// ── 安全注意事项 ───────────────────────────────────────────
// - APP_SECRET 只在服务端使用,绝不下发给 App防止密钥泄露
// - code 换 access_token 在服务端完成(防中间人攻击)
// - state 参数用于防 CSRF客户端生成随机串服务端验证当前简化实现
// - 生产环境建议对 code 做防重放Redis SET NX 标记已用 codeTTL 5 分钟
// ============================================================
import { Injectable, Logger, BadRequestException } from '@nestjs/common';

View File

@ -1,26 +1,48 @@
// ============================================================
// WXEntryActivity — 微信 OAuth 授权回调 Activity
//
// 微信 SDK 要求:授权完成后,微信会唤起宿主 App 的
// {applicationId}.wxapi.WXEntryActivity 来传递授权结果code
// ── 作用 ─────────────────────────────────────────────────
// 微信 SDK 规范:用户在微信 App 完成授权后,微信会通过 Intent 唤起
// 宿主 App 中固定路径的 WXEntryActivity将授权结果code / errCode
// 回传给宿主 App。
//
// fluwx 已封装好处理逻辑,只需继承 FluwxWXEntryActivity 即可,
// 无需额外代码。
// ── 路径规范(必须严格遵守,否则微信无法回调)───────────────
// 包名路径: {applicationId}.wxapi.WXEntryActivity
// 本应用: cn.gogenex.consumer.wxapi.WXEntryActivity
//
// 注意:
// 1. AndroidManifest 中必须声明 android:exported="true"
// 2. launchMode 必须为 singleTask微信规范要求
// 3. 此文件路径必须严格遵循 {applicationId}/wxapi/WXEntryActivity
// 注意: applicationId 与 namespace 可能不同:
// namespace = "cn.gogenex.genex_consumer"Kotlin 源码包名)
// applicationId = "cn.gogenex.consumer"build.gradle 中定义的 App ID
// 微信按 applicationId 查找 WXEntryActivity因此此文件放在
// cn/gogenex/consumer/wxapi/ 目录package 声明也必须是 cn.gogenex.consumer.wxapi
//
// ── fluwx 集成方式 ────────────────────────────────────────
// fluwx 插件已提供 com.jarvanmo.fluwx.WXEntryActivity 基类,
// 内部实现了 IWXAPIEventHandler 接口并通过 EventChannel 将结果
// 传递给 Dart 层的 weChatResponseEventHandler Stream。
// 只需继承该基类,无需任何额外代码。
//
// ── AndroidManifest 配置要求 ──────────────────────────────
// android:exported="true" — 允许微信 App 从外部唤起
// android:launchMode="singleTask" — 微信规范要求,防止多实例
// android:taskAffinity="${applicationId}" — 确保在正确任务栈
//
// ── 调试常见问题 ──────────────────────────────────────────
// 1. 微信授权后 App 没有收到回调:
// - 检查 applicationId 是否与微信开放平台填写的包名一致
// - 检查签名 MD5 是否与开放平台填写的一致debug/release 签名不同)
// - debug 构建的 applicationId 为 cn.gogenex.consumer.debug
// 开放平台通常只填 release 包名,需要单独添加 debug 包名
// - 可安装「微信开放平台助手」App 验证签名是否正确
//
// 2. 获取 Android 签名 MD5 的方法:
// keytool -exportcert -keystore release.jks -alias release \
// -storepass <密码> | md5sum
// 输出的 32 位小写 hex 即为需要填写到微信开放平台的「应用签名」
// ============================================================
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

@ -43,13 +43,22 @@ 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
// SDK
// WECHAT_APP_ID --dart-define
// flutter build apk --dart-define=WECHAT_APP_ID=wx0000000000000000
// flutter build ipa --dart-define=WECHAT_APP_ID=wx0000000000000000
//
// WECHAT_APP_ID / CI
// WelcomePage AppisWeChatInstalled=false
//
// universalLink: iOS Universal Links
// apple-app-site-association : https://www.gogenex.com/wechat/apple-app-site-association
// : https://developers.weixin.qq.com/doc/oplatform/Mobile_App/Universal_Links/Universal_Links.html
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(