返回博客

多身份维度下「绑定状态」乱飞:前端自判 vs 后端权威字段

某个后台管理小程序,员工列表页上一行行显示「已绑定 / 未绑定」标签。产品说数据不对——明明好几百个人已经绑了企业微信,页面却全显示「未绑定」。翻开源码一看,WXS 里就一行逻辑:

isBound: function(clerk) {
  if (clerk.openid || clerk.mp_openid) return true;
  return false;
}

这是绑定状态判断代码的全貌。时隔半年,系统接入了企业微信,但这段 WXS 没人动过。

现象

  • 员工表活跃记录约 760 条
  • 有微信 openid 的只有 56 条(7.4%)
  • 通过手机号可关联到企业微信的有 116 条(15.2%)
  • 结果:UI 把 93% 的员工标成「未绑定」,含 116 个实际上已经绑了企业微信的人

后端接口早已 JOIN 了企业微信用户表,返回了 qrCode 等企微字段,但前端 WXS 压根没消费这些字段——它只认 openid

根因

「绑定」本质上是一个多维身份的聚合判断:用户可能只绑了小程序、只绑了企微、或者两者都绑了,甚至还有 POS 工号维度。每次新增一条身份线,就有 N 个散落的前端判断点需要同步更新。这是一个隐式的 fan-out 问题——没有人知道到底有多少个地方在做这个判断,也没有人知道哪些已经落伍了。

坑:前端各自拼凑 if(openid || unionid) 是慢性毒药

每个页面独立从原始字段推导绑定状态,看起来灵活,实则每次业务扩展(加一个身份维度)都会让已有判断静默出错。多个端(H5、小程序、Web 后台)还会互相漂移——同一条记录,A 端说「已绑定」,B 端说「未绑定」。

正解

1. 后端定义唯一权威枚举

在后端定一个 BindStatus 枚举,把所有判定条件集中写在一处,每个状态附注判断规则:

class BindStatus {
    const FULLY_BOUND = 'fully_bound';  // 小程序 + 企微 + POS 全绑
    const WECOM_ONLY  = 'wecom_only';   // 仅企微
    const MP_ONLY     = 'mp_only';      // 仅小程序
    const PHONE_ONLY  = 'phone_only';   // 仅手机号,未激活
    const NO_PHONE    = 'no_phone';     // 基础资料不全
    const DISABLED    = 'disabled';     // 已停用
}

2. 物化列 + 单一计算服务

在员工表新增 bind_status VARCHAR(32) 持久化存储,由唯一的 BindStatusService::calc() 负责计算并写入。不在接口层每次重算,让列表查询可以直接 WHERE bind_status = ? 走索引。

public function calc(array $clerk): string {
    if (($clerk['status'] ?? 1) != 1)       return BindStatus::DISABLED;
    if (empty($clerk['telephone']))         return BindStatus::NO_PHONE;
 
    $hasMp    = !empty($clerk['openid']);
    $hasWecom = !empty($clerk['wecom_userid']);
    $hasPos   = !empty($clerk['pos_code']);
 
    if ($hasMp && $hasWecom && $hasPos)     return BindStatus::FULLY_BOUND;
    if (!$hasMp && $hasWecom)               return BindStatus::WECOM_ONLY;
    if ($hasMp && !$hasWecom)               return BindStatus::MP_ONLY;
    return BindStatus::PHONE_ONLY;
}

3. 每条写路径挂钩

每个修改身份字段的写路径(微信授权回调、企微绑定、POS 同步)都必须在同一事务或 afterCommit 里调用 recalc($id)。漏掉任何一条,状态就会悄悄陈旧。

public function afterWecomBind(int $clerkId, string $wecomUserid) {
    Db::name('clerk')->where('id', $clerkId)->update(['wecom_userid' => $wecomUserid]);
    app(BindStatusService::class)->recalc($clerkId);  // 不可省
}

4. 前端纯渲染,零推导

var labels = {
  fully_bound: '已绑定', wecom_only: '未注册小程序',
  mp_only: '未绑企微', phone_only: '未激活',
  no_phone: '资料不全', disabled: '已停用'
};
module.exports.bindLabel = function(clerk) {
  return labels[clerk.bind_status] || '未知';
};

整个 WXS 文件里不出现任何 if (openid || unionid) 形式的字段拼凑。

正解:后端一列权威,前端只管翻译

bind_status 是后端计算出的业务结论,前端只负责把枚举值翻译成中文标签。新增身份维度只需改 BindStatusService::calc(),所有端自动同步,无需 grep 任何前端文件。

兜底:定时任务全量校验

即使写路径全部挂了钩,并发写和历史遗留路径仍可能导致漂移。建议每 30 分钟跑一次增量校验(近 1 小时内变更的行),每天凌晨 3 点全量重算。这是兜底,不能替代写路径钩子。

一句话外卖

任何「已绑定 / 未激活」类标签,必须来自后端计算好的权威字段,前端只翻译不推导——否则每次业务加一条身份线,散落的前端判断就静默腐烂一次。