要用户手机号真的是为了打骚扰电话吗?浅谈微信生态会员账号体系与资产合并

每次注册一个新的小程序、想看一篇付费文章、或者刚要下单买个课,弹出来一行"请先绑定手机号",很多人的第一反应是:又来了,是不是又要把我号卖给做贷款、卖保险、搞装修的?

这个警惕不算错------确实有产品拿手机号干这事,监管这几年也在收紧。但今天想聊的是另一面:在微信生态里做产品,手机号往往不是为了营销,而是技术上几乎绕不开的账号锚点。如果你买过的课、充过的会员、攒了半年的学习进度,在某次产品升级后突然"不见了",或者更糟------你登录后看到了别人的订单------根子很可能就在于"没有一个稳定的账号体系"。

这篇文章想把这件事讲透:微信给开发者的身份标识到底是怎么回事,为什么它们靠不住,一个靠谱的账号与资产体系该怎么设计(含每个表、每个字段的定义),以及在八个真实会发生的场景下,数据具体怎么变。


一、先认识微信给你的几个"身份证号"

你在一个微信生态产品里的身份,主要由三个东西描述:

AppID:应用的 id。一个公众号是一个 AppID,一个小程序是另一个 AppID。

OpenID:你在「某一个应用(AppID)」里的唯一标识。注意------同一个人,在不同的应用里,OpenID 是不一样的

UnionID:你在「某一个微信开放平台账号」下、所有绑定到它的应用之间的统一标识。一家公司把公众号、小程序、APP 都绑到同一个开放平台账号下,那么同一个用户在这些应用里的 UnionID 是同一个。

层级关系是这样:开放平台账号(主体)下面挂着多个应用(AppID),每个应用看到的是 OpenID,而 UnionID 在同主体的应用之间共享。

这里有一个决定一切的关键事实 :OpenID 和 UnionID 绑的是"微信号 × 应用"和"微信号 × 开放平台",而不是绑你这个人。它们更像是"你用哪个微信、在哪个应用里"的组合编号,而不是你的身份证。

把它们按"稳不稳"排个序:

OpenID 最不稳:换一个应用(AppID)就变。

UnionID 比较稳:只要还在同一个开放平台主体下,换应用它都不变;但换了开放平台主体,它就变。

即便是 UnionID,也只在"微信号没换人"的前提下才代表同一个人------而微信号是会转卖、会换绑、会被回收的。

记住这几句,下面几乎全是它们的推论。


二、为什么这是个问题:把资产挂在会移动的东西上

很多产品早期图省事,订单表、购买表、用户行为表,主键或外键直接用 OpenID。能跑,而且在"永远只有一个小程序、永远不换主体"的世界里也确实没问题。作为企业微信核心服务商的语鹦企服私域管家,帮助众多500强企业服务大量toC客户,在给他们设计面向C端的微信生态会员体系的时候,会遇到非常复杂的账号资产合并问题。常见的几件事:

业务调整,从旧小程序迁到新小程序;

公司主体变更、或要换一套技术服务商,整个开放平台账号都换了;

用户自己换了微信登录;

甚至,某个用过的微信号,辗转到了另一个人手里。

每发生一件,OpenID 或 UnionID 就可能变。如果你的资产是挂在这些会变的标识上的,就会出两类问题,方向相反但后果都很严重:

资产丢失 / 分裂:标识变了,系统不认识老用户了,他买的课就"读不到"了;或者同一个人被拆成两个账号,资产散在两边。

资产泄漏 / 串号:一个标识被另一个人继承了(比如微信号转手),新用户登录后解析到了前任的账号,看到了别人的订单和课程。

第一类让用户投诉、退款、流失;第二类是数据安全问题,更严重。


三、正确的数据模型:先给"人"一个永不变的身份

解法的核心只有一句:不要把任何资产挂在 OpenID / UnionID 上,而是给"人"一个你自己生成、永不变的内部 id。

落到表上是三层:用户主体表 user、身份映射表 user_identity、以及把所有资产外键指向 user_id

字段定义

user ------ 用户主体表(一个真实的人 = 一行)

| 字段 | 含义 | | --- | --- | | user_id | 内部用户主键,系统自己生成(雪花 / 自增),永不变 ,是所有资产的归属锚 | | primary_phone | 当前生效的手机号,冗余缓存(与 user_identity 中 active 的 phone 行保持一致),便于快速读取 | | merged_into | 该用户若被合并进另一个用户,指向合并后保留 的那个 user_id(被合并方仅留指向、不再参与解析);为空表示自身就是当前有效账号 | | created_at | 创建时间 |

user_identity ------ 身份映射表(一个用户挂多条登录方式,一条 = 一行)

| 字段 | 含义 | | --- | --- | | user_id | 这条身份属于哪个用户,外键指向 user.user_id | | id_type | 身份类型:phone / UnionID / OpenID / external_userid(企微外部联系人) | | id_value | 该身份的具体值(手机号、UnionID 串、OpenID 串等) | | app_id | 当 id_type=OpenID 时必填------OpenID 只在某个 app 下唯一 | | corp_id | 当 id_type=external_userid 时必填------企微外部联系人只在某企业主体下唯一 | | status | active / inactive;inactive 的记录只保留痕迹,不参与身份解析 | | verified_at | 该身份最近一次验证时间;phone 行尤其重要(判断绑定是否新鲜) | | verify_level | (phone 行专用)核验级别:sms 短信 / three_factor 三要素 / face 人脸核验 | | realname_token | (phone 行专用)姓名+身份证经不可逆哈希 后的实名标识,用于"是否同一个人"的比对,不存明文 |

非 phone 的行(UnionID / OpenID / external_userid),verify_levelrealname_token 留空即可。

为什么是"一对多的一张 user_identity 表",而不是"在用户表上加 OpenID、UnionID 几个列"?因为一个人会同时有多个 OpenID(每换一个应用就多一个),列只能存一个,第二个没地方放。一对多的行结构天生能装下"一个人多个身份",列结构装不下。

一条关键规则:这几行身份不是平权的

phone 行是权威锚------它最接近"这个人"。

UnionID / OpenID可改绑的登录凭证------它们只代表"当前谁在用这个微信号 / 这个应用"。

解析身份时,优先级是:已验证手机号 > UnionID > OpenID

当一个会话带来的已验证手机号,和某个 UnionID 当前绑定用户的手机号对不上时,信手机号------说明微信号换人了,把这条 UnionID 改绑过去。

到这里,标题其实已经能回答一半了:手机号在这套体系里扮演的,是那个"永不变的人"的锚。 不是为了给你打电话,是为了在 OpenID、UnionID 全都靠不住的时候,系统还能认出"这是同一个人,这些是他的资产"。如果你是做会员系统、CDP、CRM、SaaS 的欢迎添加微信公众号Jinkey和我交流。

下面用八个场景把数据变更过一遍。每个场景给一张三列对照表------数据对象 / 变更前 / 变更后 ,每行直接标出所属表(user / user_identity)和该行的字段值,看一眼就知道哪条记录、哪个字段动了。为直观,假设用户张三,user_id = 1001,买过课程 X。


四、八个场景下的数据变更

场景 1:换小程序,OpenID 变(UnionID 不变)

业务从小程序 A(AppID=wxAAA)迁到小程序 B(AppID=wxBBB),但还在同一个开放平台主体下。张三在新小程序登录,OpenID 变成新的,但 UnionID 没变。桥就是 UnionID:拿 UnionID 反查到 1001,把新 OpenID 作为新的一行插进去。

| 数据对象 | 变更前 | 变更后 | | --- | --- | --- | | user 表:1001 | primary_phone=136 | 不变 | | user_identity (UnionID) | id_type=UnionID , id_value=U1, status=active, →1001 | 不变(它就是桥) | | user_identity (旧小程序 OpenID) | id_type=OpenID , id_value=o_A, app_id=wxAAA, status=active, →1001 | 不变 | | user_identity (新小程序 OpenID) | 不存在 | id_type=OpenID , id_value=o_B, app_id=wxBBB, status=active, →1001【新增】 | | 资产:课程 X | 外键 →1001 | 外键 →1001(无变化) |

一句话:UnionID 不变就用它当桥,几乎零迁移。 前提是小程序从一开始就绑了开放平台、在持续攒 UnionID------没绑的话桥根本不存在。如果同时使用服务号登录和小程序登录,也是用UnionID当桥。

场景 2:换开放平台,UnionID 变(OpenID 不变)

小程序本身没换(AppID 还是 wxAAA),只是把它从旧开放平台账号解绑、再绑到新的开放平台账号(这里按"AppID 不变、仅换开放平台绑定"来谈,具体也取决于迁移方式)。OpenID 只认 AppID,所以不变;UnionID 认开放平台,所以变了。这次桥换成 OpenID。

| 数据对象 | 变更前 | 变更后 | | --- | --- | --- | | user 表:1001 | primary_phone=136 | 不变 | | user_identity (OpenID) | id_type=OpenID , id_value=o_A, app_id=wxAAA, status=active, →1001 | 不变(这次它当桥) | | user_identity (旧主体 UnionID) | id_type=UnionID , id_value=U1, status=active, →1001 | status 改为 inactive【停用,不再参与解析】 | | user_identity (新主体 UnionID) | 不存在 | id_type=UnionID , id_value=U2, status=active, →1001【新增】 | | 资产:课程 X | 外键 →1001 | 外键 →1001(无变化) |

一句话:总有一个标识符有效,就用有效的那个当桥。 场景 1 靠 UnionID,场景 2 靠 OpenID,是对称的。

场景 3:两个同时变(OpenID 变、UnionID 变)

最硬的情况:你重建了一个全新的小程序(新 AppID),挂在一个全新的开放平台主体下。OpenID、UnionID 都变了,微信体系里没有任何标识符有效 。微信不提供跨主体的稳定 id,只能靠"微信体系之外的锚"------张三在新应用里验证手机号 136 反查到 1001(没绑手机号的,靠新旧并行窗口期里的一次性迁移口令 / 二维码把新身份绑回 1001)。

| 数据对象 | 变更前 | 变更后 | | --- | --- | --- | | user 表:1001 | primary_phone=136 | 不变 | | user_identity (phone) | id_type=phone , id_value=136, status=active, →1001 | 不变(唯一能跨主体的桥) | | user_identity (旧 OpenID) | id_type=OpenID , id_value=o_A, app_id=wxAAA, status=active, →1001 | status 改为 inactive【停用】 | | user_identity (旧 UnionID) | id_type=UnionID , id_value=U1, status=active, →1001 | status 改为 inactive【停用】 | | user_identity (新 OpenID) | 不存在 | id_type=OpenID , id_value=o_new, app_id=wxNEW, status=active, →1001【新增】 | | user_identity (新 UnionID) | 不存在 | id_type=UnionID , id_value=U_new, status=active, →1001【新增】 | | 资产:课程 X | 外键 →1001 | 外键 →1001(无变化) |

一句话:两个都变了,就只剩手机号(或提前埋好的迁移口令)能救。 所以"换主体"必须提前设计------等老应用下线、用户又没留手机号,旧资产和新身份之间就再无信号可连。

场景 4:同一个人换微信号登录(手机号不变,UnionID 和 OpenID 同时多了一套)

张三换了个微信号 b 来登录(同一开放平台主体下)。新微信号 → 新 UnionID + 新 OpenID。但他验证的还是同一个手机号 136。按手机号查到 1001,把新的 UnionID、OpenID 都挂到 1001。

| 数据对象 | 变更前 | 变更后 | | --- | --- | --- | | user 表:1001 | primary_phone=136 | 不变 | | user_identity (phone) | id_type=phone , id_value=136, status=active, →1001 | 不变(按手机号归并的依据) | | user_identity (旧微信号 UnionID) | id_type=UnionID , id_value=U1, status=active, →1001 | 不变 | | user_identity (旧微信号 OpenID) | id_type=OpenID , id_value=o1, app_id=wxAAA, status=active, →1001 | 不变 | | user_identity (新微信号 UnionID) | 不存在 | id_type=UnionID , id_value=U2, status=active, →1001【新增】 | | user_identity (新微信号 OpenID) | 不存在 | id_type=OpenID , id_value=o2, app_id=wxAAA, status=active, →1001【新增】 | | 资产:课程 X | 外键 →1001 | 外键 →1001(未分裂) |

一句话:同一个人有多个微信号,靠手机号把他们收成一个 user_id,不分裂。 前提:新微信号登录时得拿到手机号------这也是为什么手机号最好在"购买 / 进入付费区"这种关键节点就要到。

场景 5:别人用新手机号登录了旧微信号(旧 UnionID/OpenID 需要改绑)

旧微信号 a 转手给了别人(转卖 / 换绑)。新主人用它登录------因为是同一个微信号,UnionID/OpenID 还是老的那套(U1 / o1),但他绑的是另一个手机号 155。会话带来手机号 155,查不到用户,新建 user 1002(锚=155);同时发现 U1 当前还绑在 1001、但这次手机号变了,于是把 U1、o1 从 1001 摘下,改绑到 1002。

| 数据对象 | 变更前 | 变更后 | | --- | --- | --- | | user 表:1001(张三) | primary_phone=136 ,资产=课程 X | 不变(张三的资产原地不动) | | user 表:1002(新主人) | 不存在 | user_id=1002 , primary_phone=155,资产=空【新建】 | | user_identity (UnionID, U1) | id_type=UnionID , id_value=U1, status=active, →1001 | →1002【改绑】 | | user_identity (OpenID, o1) | id_type=OpenID , id_value=o1, app_id=wxAAA, status=active, →1001 | →1002【改绑】 | | user_identity (phone, 136,张三) | id_type=phone , id_value=136, status=active, →1001 | 不变 | | user_identity (phone, 155,新主人) | 不存在 | id_type=phone , id_value=155, status=active, →1002【新增】 | | 资产:课程 X | 外键 →1001 | 外键 →1001(张三仍持有) |

新主人 → 1002,干干净净;张三的课还在 1001(他用别的微信登录、或重验 136 还能看到)。

一句话:UnionID/OpenID 跟着"当前谁在用这个微信号"走,靠手机号不匹配触发改绑------这正是防"串号泄漏"的关键。 注意手机号变了也可能 是张三自己换了号,所以稳妥做法是把"绑定 / 解绑登录方式"做成需要认证的显式动作,不要后台一看手机号变了就静默搬资产。

场景 6:手机号改绑

张三在设置里主动把绑定手机从 136 改成 188。这是这套结构里最便宜的操作------也正是当初不拿手机号当主键的回报。

| 数据对象 | 变更前 | 变更后 | | --- | --- | --- | | user 表:1001(primary_phone 字段) | primary_phone=136 | primary_phone=188 【改值】 | | user_identity (phone, 136) | id_type=phone , id_value=136, status=active, verified_at=2025-01, →1001 | status 改为 inactive【停用,不再参与解析】 | | user_identity (phone, 188) | 不存在 | id_type=phone , id_value=188, status=active, verified_at=now(), →1001【新增】 | | user_identity (UnionID / OpenID) | id_type=UnionID , id_value=U1, →1001;id_type=OpenID, id_value=o1, →1001 | 不变 | | 资产:课程 X | 外键 →1001 | 外键 →1001(无变化) |

要点:user_id 不变、UnionID/OpenID 不变、资产不变,只动 primary_phone 和 phone 那一行;旧号 136 必须停用且不再参与解析 ,否则将来有人拿被释放的 136 注册会错误落到 1001;因为是本人显式发起、且已登录,"是谁的账号"没有歧义(就是 1001)。唯一需要当心的边界:新号 188 已经是别人的锚------默认直接拦("该号已被使用"),要放行就当一次显式合并且必须用户确认,绝不能因号码撞上就静默合并。

场景 7:两个账号其实是同一个人(账号合并,用 merged_into)

前六个场景里,张三始终只有一个 user_id。但现实中常会冒出"同一个人被拆成两个账号"的情况------这正是 merged_into 唯一的用武之地。

设想:张三早年先在 H5 用手机号 136 注册,落到 user 1001,买了课程 X;后来他用另一只微信登录小程序、当场没绑手机号,这只微信的 UnionID(U9)系统从没见过、又没有手机号可比对,于是又新建了 user 2001,他在这边买了课程 Y。两个账号、一个人,互不相识。直到某天他在小程序里去绑手机号、填了 136------系统一查,136 已经属于 1001,而当前会话的锚是 2001(U9)。两边其实是同一个人,该合并了。

合并要先挑一个保留方(这是个策略选择,通常保留"手机已实名 / 更老 / 资产更多"的一方;这里保留手机锚的 1001),再把被合并方 2001 的身份行和资产全部迁到 1001,最后给 2001 写上 merged_into=1001

| 数据对象 | 变更前 | 变更后 | | --- | --- | --- | | user 表:1001(手机锚账号,保留方) | primary_phone=136 ,资产=课程 X | 不变(作为保留方;课程 Y 并入) | | user 表:2001(微信锚账号,被合并方) | primary_phone=空 ,资产=课程 Y,merged_into=空 | merged_into=1001 【被合并,不再参与解析】 | | user_identity (phone, 136) | id_type=phone , id_value=136, status=active, →1001 | 不变(本次合并的触发依据) | | user_identity (UnionID, U9) | id_type=UnionID , id_value=U9, status=active, →2001 | →1001【改绑到保留方】 | | user_identity (OpenID, o9) | id_type=OpenID , id_value=o9, app_id=wxAAA, status=active, →2001 | →1001【改绑到保留方】 | | 资产:课程 X(原属 1001) | 外键 →1001 | 外键 →1001(不变) | | 资产:课程 Y(原属 2001) | 外键 →2001 | 外键 →1001【资产迁移到保留方】 |

为什么被合并方 2001 不直接删掉、而要留一行写 merged_into?因为系统里可能还有正在进行的会话、外部回调、对账记录、日志、幂等键攥着 user_id=2001;留一个指向 1001 标记。这个标记的旧引用顺着指针就能解析到 1001,而不是查无此人。解析身份时,凡是落到 merged_into 非空的账号,一律顺着指针改用保留方。

实操上还有两点要当心:一是合并要幂等且可审计(记一条合并流水,失败能重放,避免迁到一半卡住),二是迁资产时注意唯一约束------比如两边都买过同一门课,得去重或按业务规则取一条,不能硬塞出重复记录。这也正是场景 6 末尾"显式合并"那句的展开:当新绑的手机号已经是别人的锚、而用户确认"就是要把那个账号并过来"时,走的就是这套 merged_into 流程。

一句话:merged_into 是"同一个人被拆成两个账号"时的归一开关------挑一个保留方,把另一方的身份和资产迁过去,被合并方只留一行 merged_into 指向保留方,从此做重定向、不再参与解析。

场景 8:手机号被回收再出售(用实名核验来兜底)

国内手机号是实名制 的------每个号背后都登记着一个真实身份。运营商会把长期不用的号收回、重新放号,于是 136 这个号,半年后可能登记在另一个人名下了。如果系统还认 phone(136) → 张三,新机主一登录就串号了。

光靠 verified_at 只能告诉你"这条绑定有点旧了",是一种怀疑,不是结论。利用实名制,可以把"怀疑"变成"确定"------在绑手机号时(或在出现回收嫌疑、涉及高价值资产、账号找回争议时),做实名核验,把"号背后是谁"固定下来。两种强度:

三要素核验:手机号 + 姓名 + 身份证号,确认这个号登记在这个人名下。

人脸核验:在三要素基础上再加人脸核验,确认"此刻在操作的就是本人",更防冒用(别人拿到你的姓名身份证也过不了)。

核验结果存成不可逆的 realname_token(姓名+身份证哈希,不存明文),连同 verify_levelverified_at 挂在 phone 行上。号被回收给新机主李四时,李四核验得到的 token 和旧绑定对不上,系统就能确定性地判断"换人了"。

| 数据对象 | 变更前(张三的绑定) | 变更后(新机主李四核验后) | | --- | --- | --- | | user_identity (phone, 136,张三) | id_type=phone , id_value=136, status=active, verify_level=face, realname_token=H(张三), verified_at=2024-06, →1001 | status 改为 inactive【因实名不符而解绑,痕迹保留、不再参与解析】 | | user 表:2002(新机主) | 不存在 | user_id=2002 , primary_phone=136【新建】 | | user_identity (phone, 136,新机主) | 不存在 | id_type=phone , id_value=136, status=active, realname_token=H(李四), verified_at=now(), →2002【新增】 | | user_identity (UnionID / OpenID,张三) | id_type=UnionID , id_value=U1, →1001 等 | 不变(张三仍可经微信登录访问 1001) | | 资产:课程 X(张三) | 外键 →1001 | 外键 →1001(新机主看不到) |

一句话:手机号会被回收,但它背后的实名身份不会跟着换人。 用三要素 / 人脸核验得到的实名标识(哈希存储),把"号被回收了"和"还是同一个人"确定性地区分开,比只看验证时间强得多。


六、回到标题:所以到底为什么要你的手机号?

把八个场景连起来看,结论就清楚了。在微信生态里:

OpenID 绑"微信号 × 应用",换应用就变;

UnionID 绑"微信号 × 开放平台",换主体就变;

连微信号本身都会换人、会被回收。

微信给你的每一个标识符,绑的都是"某个微信在某个应用里",没有一个真正绑"你这个人" 。而你买的课、充的会员、攒的进度,是属于"你这个人"的资产。要让资产在所有这些变动里始终跟着你,就必须有一个最接近"人"、且相对稳定的锚------在能拿到的信息里,手机号就是那个最好的选择;而国内手机号的实名制,又让它背后可以挂一个更硬的实名身份,连号码回收都能扛住。如果你是做会员系统、SaaS、CDP、CRM 的欢迎添加微信公众号Jinkey和我交流。

所以一个正经产品要你手机号,核心目的通常是:

让你换小程序、换主体、换微信登录时,买过的东西都还在(场景 1--4);

让别人拿到你用过的微信号、或拿到你回收掉的旧号时,看不到你的资产(场景 5、7);

给你一个能跨设备、跨微信找回账号的入口。

当然,得诚实地说另一面:确实有产品拿手机号去做营销、甚至卖给第三方打骚扰电话。 而且越往实名核验走(姓名、身份证、人脸),收集的信息越敏感,"最小必要"和"安全存储"就越关键。《个人信息保护法》的最小必要原则要求:收集个人信息要与处理目的直接相关、限于实现目的的最小范围。技术上"需要手机号 / 实名当账号锚",和业务上"拿这些信息去骚扰、去滥用",是两回事。

一个负责任的产品,会做到这几点:

时机:在"购买 / 涉及资产"的关键节点才要手机号;把三要素、人脸这类重核验,留给"回收嫌疑、找回争议、高价值资产"这种真正需要确定身份的时刻,而不是人人、时时都要。

存储 :实名信息只留不可逆的哈希标识用于比对,不留明文。

用途:严格限定在账号与资产本身,要完就安安静静当锚,不拿去推销。

告知:把用途说清楚。

所以回到标题------要手机号,不一定是为了打骚扰电话。在微信这套"标识符全都绑微信号、不绑人"的生态里,它常常是把"你的资产"和"你这个人"绑在一起的、技术上几乎唯一的办法。这把钥匙当然也可能被滥用,那是另一个该由监管和自律去约束的问题------但它本身的存在,确实有正当的技术理由。

相关推荐
葫芦和十三1 小时前
图解 MongoDB 06|模式演进:无 schema 是优势还是债
后端·mongodb·agent
葫芦和十三9 小时前
图解 MongoDB 05|文档模型设计:内嵌 vs 引用,反范式不是免费午餐
后端·mongodb·agent
不能放弃治疗12 小时前
单 Agent 实现模式
后端
IT_陈寒14 小时前
Redis内存爆了,原来我漏掉了这个致命配置
前端·人工智能·后端
fliter15 小时前
最后一块拼图:用 bitvec 构造 IPv4 包,真正做出自己的 Ping
后端
fliter16 小时前
用 Rust 解析并生成 ICMP 包:checksum、nom 与 cookie-factory
后端
蝎子莱莱爱打怪16 小时前
XZLL-IM干货系列 03|消息 ID 设计:一个 UUID 搞不定的事,我用两个 ID 解决了
后端·面试·开源
fliter16 小时前
从 panic 到 Result:用 Rust 重新整理一个 ping 项目的错误处理
后端
森蓝情丶17 小时前
我给 AI 搭了个法庭:一个前端仔的 LangGraph 实战全记录
前端·后端