返回博客

多登录路径写入不对称,同一用户身份字段悄悄漂移

一个后台管理系统接入了微信公众号 OAuth、小程序 wx.login、线下 POS 绑卡三条登录路径,三条路径写向同一张用户表。某天新增了跨渠道自动关联功能——把公众号粉丝和小程序 POS 会员打通。上线后发现:有一批用户死活关联不上,虽然他们明明在小程序里登录过。

排查到最后,unionid 字段大量为 NULL,而那些用户全部走的是一条"遗漏补丁"的 H5 授权登录路径。

现象

  • 用户表里部分记录:openid 有值,unionid = NULL
  • 这批记录的创建时间在"补充 unionid 存储"补丁之后
  • 跨渠道关联逻辑(通过 unionid 联表)对这批用户失效,返回空结果
  • 同一服务文件里,getMiniToken() 方法能正确写入 unionidgetToken() 方法(H5 授权路径)遗漏了

根因

系统有两个 Java 方法处理登录,写向同一张表,但是由不同时期不同工程师各自实现

// 路径 A:getMiniToken() —— 补丁后正确写入 unionid
if (fans.getUnionid() == null && wxMpUser.getUnionId() != null) {
    fans.setUnionid(wxMpUser.getUnionId());
    fansMapper.updateById(fans);
}
 
// 路径 B:getToken() —— 同一个文件,同一个 Service,漏掉了 unionid
UmsMember member = new UmsMember();
member.setOpenid(wxInfo.getOpenid());
member.setNickname(wxInfo.getNickname());
// ↑ 缺失:member.setUnionid(wxInfo.getUnionid())
umsMemberMapper.insert(member);

两个方法在同一个文件里,隔了几个月分别写。当初给路径 A 加 unionid 支持时,没有人检查同文件里的兄弟方法。这就是写侧不对称漂移:一次补丁只修了一条路径,其他路径继续按旧行为写入,用户数据从新记录起就已经残缺。

坑:补丁只打了一条路径,兄弟路径悄悄继续写旧数据

多条登录路径各自独立写用户表,unionid 这类字段只在某些渠道能拿到。一旦在路径 A 追加了字段写入,而没有同步更新路径 B/C,新用户通过路径 B/C 登录产生的记录就会缺列。

这类 bug 特别隐蔽:功能测试通常只走一条路径,漂移路径上的用户是"安静地"残缺,不报错,直到业务侧需要用那个字段做关联时才暴露。

commit message 里的"补充存储 unionid"/ "对称 bug" / "对齐登录路径"通常是这类问题的信号。

正解

所有登录路径收敛到一个统一的身份合并函数,各路径只负责组装自己能拿到的 key,写入逻辑全部在这一处:

public UmsMember mergeOrCreate(IdentityBundle keys) {
    // 1. 按优先级查找已有记录
    UmsMember existing = null;
    if (keys.unionid != null)     existing = mapper.selectByUnionid(keys.unionid);
    if (existing == null && keys.openid != null)     existing = mapper.selectByOpenid(keys.openid);
    if (existing == null && keys.mobile != null)     existing = mapper.selectByMobile(keys.mobile);
    if (existing == null && keys.memberCode != null) existing = mapper.selectByMemberCode(keys.memberCode);
 
    // 2. 找到 → 只补全缺失字段,不覆盖已有值
    if (existing != null) {
        UmsMember patch = new UmsMember();
        patch.setId(existing.getId());
        boolean dirty = false;
        if (existing.getOpenid()  == null && keys.openid  != null) { patch.setOpenid(keys.openid);   dirty = true; }
        if (existing.getUnionid() == null && keys.unionid != null) { patch.setUnionid(keys.unionid); dirty = true; }
        if (existing.getMobile()  == null && keys.mobile  != null) { patch.setMobile(keys.mobile);   dirty = true; }
        if (dirty) mapper.updateById(patch);  // 局部更新,避免覆盖其他渠道写入的字段
        return mapper.selectById(existing.getId());
    }
 
    // 3. 没找到 → 带着所有已知 key 新建
    UmsMember fresh = new UmsMember();
    fresh.setOpenid(keys.openid);
    fresh.setUnionid(keys.unionid);
    fresh.setMobile(keys.mobile);
    fresh.setMemberCode(keys.memberCode);
    mapper.insert(fresh);
    return fresh;
}

各登录路径变成薄薄的一层,只组装 key,调用同一个函数:

// 小程序 wx.login
public String getMiniToken(String code) {
    WxSession s = wxApi.jsCode2Session(code);
    IdentityBundle keys = new IdentityBundle();
    keys.openid  = s.getOpenid();
    keys.unionid = s.getUnionid();
    return identityService.mergeOrCreate(keys).getToken();
}
 
// H5 授权(之前漏掉 unionid 的路径)
public String getToken(String code) {
    WxMpUser u = wxMpApi.oauth2GetUser(code);
    IdentityBundle keys = new IdentityBundle();
    keys.openid  = u.getOpenid();
    keys.unionid = u.getUnionId();  // 现在靠函数签名强制传入,不会再漏
    return identityService.mergeOrCreate(keys).getToken();
}

以后新增任何身份字段(比如 email),只改 mergeOrCreate 一处,不再需要逐条路径排查。

正解:所有写路径收敛到一个 mergeOrCreate 函数

每条登录路径只提供自己能拿到的身份 key,字段补全逻辑集中在一处。局部更新(只写 NULL 列)避免并发渠道互相覆盖。验收时用 grep -rn "insert.*users\|insertUser" 确认所有写入点都通过统一函数。

排查速查

怀疑存在路径漂移时:

# 找所有直接写用户表的位置
grep -rn "insert.*UmsMember\|umsMemberMapper.insert" src/
# 每一条结果都应该指向统一的 mergeOrCreate,而不是散落的 mapper 调用

数据层核查(修复上线后新增记录应趋近于 0):

SELECT COUNT(*) FROM ums_member
WHERE openid IS NOT NULL
  AND unionid IS NULL
  AND create_at > '2026-03-29';  -- 以补丁上线时间为基准

一句话外卖

每次给一条登录路径追加字段存储,必须 grep 同文件同表的所有兄弟路径——补丁的对称性不是语言能保证的,只有 grep 能保证。