一、问题定义:多端业务的账号断层
一个典型的本地业务演化路径:先做小程序跑通业务,再做 APP 扩展体验,中间穿插公众号做触达。三端上线容易,账号断层却是普遍结局——用户在小程序里是会员,到 APP 里变新人;公众号收到的消息,关联不上小程序的订单。
断层的根源只有一个:三端各自为政地存了身份,缺少统一的账号中心。
本文结合青海青帝科技经验给出一个在云上落地的统一用户体系架构,核心决策先行陈述:
主账号以手机号为主键锚定(唯一不依赖任何平台的标识),各端第三方身份(小程序 openid、APP 微信 unionid、公众号 openid)作为关联身份挂在主账号下。用户从任何端登录,系统通过身份关联表定位到同一个主账号,数据自然贯通。
这个模型有三个推论,决定所有后续设计:
1. 用户表允许一个主账号对应多条第三方身份记录(一对多);2. 手机号未绑定时,允许"影子账号"存在(仅有微信身份),绑定手机号时执行账号合并;3. unionid 体系作为微信生态内的优化关联,与手机号主键并行存在,不承担唯一锚点职责。
二、数据模型:账号与身份分离
统一账号体系的第一块基石是数据模型。拆分原则只有一句话:账号与身份是两类实体,账号承载业务归属,身份承载登录通道。
2.1 表结构设计
统一账号体系的核心是把"账号"与"登录身份"拆成两类实体:
-- 主账号表:以手机号为锚点,无手机号时允许为空(影子账号) CREATE TABLE `account` ( `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `phone_enc` VARBINARY(256) DEFAULT NULL COMMENT '手机号 AES-GCM 加密', `phone_hash` CHAR(64) DEFAULT NULL COMMENT 'HMAC 哈希,用于等值查找', `status` TINYINT NOT NULL DEFAULT 1, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_phone_hash` (`phone_hash`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 第三方身份表:一个账号可挂多条身份(微信小程序/APP/公众号/Apple 等) CREATE TABLE `account_identity` ( `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `account_id` BIGINT UNSIGNED NOT NULL, `id_type` VARCHAR(16) NOT NULL COMMENT 'wx_mp_openid / wx_unionid / apple_sub', `identifier` VARCHAR(64) NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_type_identifier` (`id_type`, `identifier`), KEY `idx_account` (`account_id`), CONSTRAINT `fk_identity_account` FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
设计理由逐条说明:
- uk_type_identifier 唯一键保证一种身份最多关联一个账号,这是"账号唯一性"的数据库级兜底——并发登录下两个请求同时尝试用同一 openid 建账号时,只有一个成功,另一个转为查询;- phone_hash 唯一索引 + 加密列并存:加密列无法做等值查询,哈希列专门承担"手机号找账号"的路径,密钥泄露也不影响哈希列的安全性(HMAC 单向);- id_type 用枚举字符串而非数字:新增登录方式(如 Apple 登录)时不需要迁移历史数据,可读性也更好。
2.2 登录路径的三种分支
任意一端的登录请求,最终都归结为同一个函数语义:给一种身份标识,找到或创建主账号,签发登录态。
分支 1:身份已存在 identity 表 → 直接定位 account,签发 token
分支 2:身份不存在,但携带手机号(如手机号授权登录)→ 按手机号查 account:
2a. 账号存在 → 挂载新身份到该账号(账号合并点)
2b. 账号不存在 → 创建 account + identity
分支 3:身份不存在、也无手机号(首次微信静默登录)→ 创建影子账号 + identity
分支 2a 是整个模型的关键:老用户换新设备、用 APP 微信登录且授权了手机号,系统靠手机号哈希定位到小程序时代的老账号,把 APP 身份挂上去——用户在两端是同一个账号,会员等级、订单历史全部继承。这正是"以手机号为锚"的全部价值所在。
2.3 账号合并的实现
// services/account.js const db = require('./db'); const crypto = require('crypto'); /** 登录主入口:身份定位或建号 */ async function resolveAccount({ idType, identifier, phone }) { // 分支 1:身份已存在 const hit = await db.query( `SELECT account_id FROM account_identity WHERE id_type = ? AND identifier = ? LIMIT 1`, [idType, identifier] ); if (hit.length > 0) return { accountId: hit[0].account_id, isNew: false }; let accountId = null; if (phone) { // 分支 2:按手机号找已有账号 const phoneHash = hmac(phone); const acc = await db.query( 'SELECT id FROM account WHERE phone_hash = ? LIMIT 1', [phoneHash] ); if (acc.length > 0) { accountId = acc[0].id; // 分支 2a:老账号挂新身份 } else { // 分支 2b:新账号 + 手机号 const created = await db.query( 'INSERT INTO account (phone_enc, phone_hash) VALUES (?, ?)', [encryptAES(phone), phoneHash] ); accountId = created.insertId; } } else { // 分支 3:影子账号 const created = await db.query('INSERT INTO account (status) VALUES (1)'); accountId = created.insertId; } await db.query( 'INSERT INTO account_identity (account_id, id_type, identifier) VALUES (?, ?, ?)', [accountId, idType, identifier] ); return { accountId, isNew: true }; } function hmac(phone) { return crypto.createHmac('sha256', process.env.PHONE_HASH_KEY) .update(phone).digest('hex'); } module.exports = { resolveAccount };
注意事务边界:INSERT account 与 INSERT identity 应包在同一个事务中,避免极端并发下出现"有账号无身份"的脏数据。唯一键冲突时按"转为查询"处理(上一节所述),不要简单报错。
三、登录态:JWT 的正确用法与撤销补充
多端架构下的登录态有一个绕不开的矛盾:分布式环境偏爱无状态凭证,而业务安全需要即时撤销能力。本节给出的方案是两者的工程折中,取舍逻辑先说清楚。
3.1 为什么多端场景倾向 JWT
统一账号中心服务于小程序、APP、H5 多端,网关与微服务环境下,JWT 的无状态特性带来两个实际收益:鉴权不依赖单一会话存储的可用性(Redis 故障不导致全站无法鉴权),服务间传递身份无需二次查库。
但必须直视它的缺陷:签发后无法主动撤销。用户注销、账号被盗、后台踢人,都需要"让未过期的 token 立即失效"。纯 JWT 做不到,必须补一套撤销机制。
3.2 签发:claim 里放什么、不放什么
// services/token.js const jwt = require('jsonwebtoken'); const ACCESS_TTL = '2h'; const REFRESH_TTL = '30d'; const ISSUER = 'account-center'; function signAccess(accountId, deviceInfo) { return jwt.sign( { iss: ISSUER, sub: String(accountId), // 只放账号 ID dev: deviceInfo, // 设备指纹摘要,用于异常检测 }, process.env.JWT_PRIVATE_KEY, { algorithm: 'RS256', expiresIn: ACCESS_TTL } ); } function signRefresh(accountId) { return jwt.sign( { iss: ISSUER, sub: String(accountId), typ: 'refresh' }, process.env.JWT_PRIVATE_KEY, { algorithm: 'RS256', expiresIn: REFRESH_TTL } ); } function verify(token) { try { return jwt.verify(token, process.env.JWT_PUBLIC_KEY, { algorithms: ['RS256'], issuer: ISSUER }); } catch { return null; } } module.exports = { signAccess, signRefresh, verify };
三条纪律:
1. claim 里只放账号 ID,不放手机号、openid、权限明细——JWT 默认可被 base64 解码读取,敏感信息放进去等于公开;2. 用 RS256 而非 HS256——非对称算法下微服务只持有公钥,能验签不能签发,密钥泄露面最小化;3. 明确 algorithms 白名单——历史上大量 JWT 库的漏洞源于允许 alg: none,显式声明可杜绝该类攻击。
3.3 撤销:黑名单方案
// services/revocation.js —— 基于 Redis 的黑名单 const redis = require('./redis'); /** * 吊销一个 access token。 * jti(JWT ID)需在签发时写入 claim,这里以 jti 为黑名单键, * TTL 设为该 token 的剩余有效期——过期后自然无需再拦。 */ async function revoke(tokenPayload) { const { jti, exp } = tokenPayload; const remain = exp - Math.floor(Date.now() / 1000); if (remain > 0) { await redis.set(`bl:${jti}`, '1', 'EX', remain); } } async function isRevoked(tokenPayload) { const r = await redis.get(`bl:${tokenPayload.jti}`); return r !== null; }
module.exports = { revoke, isRevoked };
代价说明要诚实:黑名单方案让"每次请求多一次 Redis 查询",本质上把无状态拉回了半有状态。这是无法回避的权衡——即时撤销能力与完全无状态不可兼得。工程上的常见取舍是:黑名单只用于撤销场景高频的业务(含资金、账号操作),内容浏览类接口可放宽校验。
3.4 网关层统一鉴权
多端请求先过 API 网关,在网关完成验签与黑名单检查,业务微服务信任网关透传的 X-Account-Id:
API 网关(JWT 验签 + 黑名单检查 + 限流)
├─ 透传 X-Account-Id → 订单服务
├─ 透传 X-Account-Id → 会员服务
└─ 拒绝未认证请求 → 401 直接返回
网关层做鉴权的收益是一致性与收敛:鉴权逻辑只有一处实现、一处更新,避免各微服务各写一套、版本漂移。业务服务侧仍需遵守"身份只信网关头、不信请求参数"的纪律——网关头本身也可能被伪造,内网环境下可通过服务间 mTLS 或网关移除外部传入的同名头来兜底。
四、APP 端微信登录:与小程序的打通点
APP 的微信登录走微信开放平台的 OAuth 授权(移动应用需先创建并过审),得到的身份是 unionid + openid(该移动应用维度)。打通逻辑:
小程序登录 → code2Session → openid_mp + unionid
APP 登录 → OAuth → openid_app + unionid
↓
两者 unionid 相同(同一开放平台主体)
→ account_identity 表中以 wx_unionid 为键
→ 定位到同一个 account
两个前置条件必须在项目初期确认:小程序与移动应用绑定在同一个开放平台账号下(否则 unionid 不同);移动应用审核通常要求应用已上架或提供软著等材料——这影响 APP 登录的上线排期,应在项目计划中预留。
若 APP 端用户还授权了手机号(微信登录时勾选或短信验证码兜底),则走 2.2 的分支 2a,即使 unionid 缺失也能靠手机号锚定账号——两条关联路径互为冗余,任何一条通,账号就能打通。
五、安全工程细节清单
按云上生产环境的标准,统一账号中心上线前应逐项确认:
[ ] JWT 使用 RS256,公私钥分离,私钥仅存在于签发服务
[ ] JWT claim 中无手机号 / openid / 权限明细等敏感信息
[ ] 验签显式限定 algorithms,杜绝 alg 混淆攻击
[ ] refresh token 旋转:旧 refresh 一次性,使用后立即作废
[ ] 注销接口:吊销 access(黑名单)+ 删除 refresh + 匿名化决策
[ ] 手机号 AES-GCM 加密 + HMAC 哈希列,密钥托管于 KMS
[ ] 登录接口限流:按 IP 与账号双维度(防撞库、防爆破)
[ ] 异常登录检测:新设备指纹 + 异地告警(claim 中的 dev 字段)
[ ] 数据库账密分离:账号中心库独立实例,与应用库物理隔离
[ ] 审计日志:登录、绑定、合并、注销全量记录,含时间与来源端
其中账号合并(分支 2a)必须有审计记录——合并动作一旦错误(两个真实不同的用户因手机号复用被误合),影响的是两个用户的全部数据。审计日志是事后定责与拆分恢复的唯一依据。
六、小结
统一用户体系的技术方案可以浓缩为三句话:数据模型上,账号与身份分离,手机号为锚、身份为链;登录态上,JWT 承载、黑名单兜底、网关统一验签;多端打通上,unionid 与手机号双路径冗余。
方案本身没有高深技术,全部是"标准件"的组合——真正的门槛在于前置规划:开放平台账号的绑定、表结构中 unionid 与手机号列的预留、注销与合并的边界定义,这些决策必须发生在第一行业务代码之前。