From 44de21a7336bcc76cdae34f6c97615d9210fb2ec Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 4 Mar 2026 04:04:26 -0800 Subject: [PATCH] =?UTF-8?q?docs(auth):=20=E5=AE=8C=E5=96=84=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E7=99=BB=E5=BD=95=E6=A8=A1=E5=9D=97=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=20=E2=80=94=20=E5=90=AB=E7=94=B3=E8=AF=B7=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=AE=8C=E6=95=B4=E6=AD=A5=E9=AA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/services/auth-service/.env.example | 41 ++++++++++- .../application/services/wechat.service.ts | 60 +++++++++++----- .../infrastructure/wechat/wechat.provider.ts | 68 ++++++++++++++----- .../gogenex/consumer/wxapi/WXEntryActivity.kt | 50 ++++++++++---- frontend/genex-mobile/lib/main.dart | 15 +++- 5 files changed, 182 insertions(+), 52 deletions(-) diff --git a/backend/services/auth-service/.env.example b/backend/services/auth-service/.env.example index 68d6480..74ef3ec 100644 --- a/backend/services/auth-service/.env.example +++ b/backend/services/auth-service/.env.example @@ -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 的 MD5(32位小写,无冒号分隔) +# 获取签名: 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 diff --git a/backend/services/auth-service/src/application/services/wechat.service.ts b/backend/services/auth-service/src/application/services/wechat.service.ts index 2bea9de..bdcdbb0 100644 --- a/backend/services/auth-service/src/application/services/wechat.service.ts +++ b/backend/services/auth-service/src/application/services/wechat.service.ts @@ -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 → 用户确认授权 → 微信回传 code(WXAuthResp.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'; diff --git a/backend/services/auth-service/src/infrastructure/wechat/wechat.provider.ts b/backend/services/auth-service/src/infrastructure/wechat/wechat.provider.ts index e0f502d..8493d05 100644 --- a/backend/services/auth-service/src/infrastructure/wechat/wechat.provider.ts +++ b/backend/services/auth-service/src/infrastructure/wechat/wechat.provider.ts @@ -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 — 微信开放平台移动应用 AppID(wx 开头,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 MD5,32位小写无冒号) +// 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 标记已用 code,TTL 5 分钟 // ============================================================ import { Injectable, Logger, BadRequestException } from '@nestjs/common'; diff --git a/frontend/genex-mobile/android/app/src/main/kotlin/cn/gogenex/consumer/wxapi/WXEntryActivity.kt b/frontend/genex-mobile/android/app/src/main/kotlin/cn/gogenex/consumer/wxapi/WXEntryActivity.kt index a4eca58..a4d1fe7 100644 --- a/frontend/genex-mobile/android/app/src/main/kotlin/cn/gogenex/consumer/wxapi/WXEntryActivity.kt +++ b/frontend/genex-mobile/android/app/src/main/kotlin/cn/gogenex/consumer/wxapi/WXEntryActivity.kt @@ -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() diff --git a/frontend/genex-mobile/lib/main.dart b/frontend/genex-mobile/lib/main.dart index 615feb4..0cb728f 100644 --- a/frontend/genex-mobile/lib/main.dart +++ b/frontend/genex-mobile/lib/main.dart @@ -43,13 +43,22 @@ import 'features/profile/presentation/pages/share_page.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - // 初始化微信 SDK(WECHAT_APP_ID 在构建时注入,通过 --dart-define 传入) - // iOS: CFBundleURLSchemes 中必须已配置 wx{AppID} - // Android: WXEntryActivity 必须在 AndroidManifest 中声明 + // ── 微信 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 中点击微信按钮会提示「请先安装微信 App」(isWeChatInstalled=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(