多身份维度下「绑定状态」乱飞:前端自判 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 问题——没有人知道到底有多少个地方在做这个判断,也没有人知道哪些已经落伍了。
每个页面独立从原始字段推导绑定状态,看起来灵活,实则每次业务扩展(加一个身份维度)都会让已有判断静默出错。多个端(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 点全量重算。这是兜底,不能替代写路径钩子。
一句话外卖
任何「已绑定 / 未激活」类标签,必须来自后端计算好的权威字段,前端只翻译不推导——否则每次业务加一条身份线,散落的前端判断就静默腐烂一次。