每次注册一个小程序,或者刚想点开一篇付费文章,又或者在某个知识付费平台下单那一刻,屏幕冷不丁弹出一句:“请先绑定手机号”。
说实话,多数人的第一反应都一样:又来?是不是又要拿我的号去搞贷款、卖保险、或者倒手给装修公司了?
这个警惕一点问题没有——确实有产品这么干,监管这几年也在不断敲打。但今天想聊的,是这件事的另一面,一个在微信生态里做产品越久,体会越深的技术现实:在很多场景下,要手机号真不是为了营销,而是为了在微信这套天生不稳定的身份体系里,给用户的资产上个“锚”。
不信你想想,如果有一天你买过的课、充过的会员、攒了半年的学习进度,忽然因为产品的一次升级就“消失”了,或者更离谱——你登陆后,看到的是别人的订单和隐私信息。这背后十有八九,根子就出在“没有一个稳定可靠的账号体系”上。
这篇文章,就把这件事彻底讲透。我们会聊到微信给开发者提供的几种身份证到底有多不靠谱,一个能扛事儿的账号与资产体系应该如何设计(包括每个表和字段的定义),以及八个真实会发生的数据变更场景,看看到底怎么保障数据安全。
一、先认识微信给你的几个“身份证号”
你在任何一个微信生态产品里的身份,归根结底由下面三个东西描述:
- AppID:应用的身份证。一个公众号是一个 AppID,一个小程序是另一个不同的AppID。
- OpenID:你在“某一个应用(AppID)”里的唯一标识。注意,同一个人,在两个不同的AppID下,他的OpenID是完全不同的。
- UnionID:你在“某一个微信开放平台账号”下、所有绑定到这个平台的各个应用之间的统一标识。换句话说,如果一家公司把自己的公众号、小程序、APP都绑到同一个开放平台账号下,那么同一个用户在这些不同应用里的UnionID,就是同一个。
它们的层级关系是这样的:一个开放平台账号(市场主体)下面挂着多个应用(AppID),每个应用都只能看到这个用户在自己这里的OpenID,而UnionID则能在同主体的多个应用之间共享。
但这里有一个决定一切的关键事实:OpenID和UnionID绑定的底层逻辑,是“微信号 × 应用”和“微信号 × 开放平台”,而不是直接绑“你这个人”。它们更像是你在特定场景下的临时工号,而不是你的社会身份证。
把它们按稳定性排个序:
- OpenID 最不稳:换一个应用(AppID)它就变了。
- UnionID 相对稳一点:只要还在同一个开放平台主体下,换应用它不变;但要是换了开放平台主体(比如公司更名、更换技术服务商),它也跟着变。
- 即使是UnionID,也只能保证在“微信号没换人”的前提下代表同一个人——而现实是,微信号是会转卖、会换绑、甚至被回收的。
记住这几个要点,下面几乎全是它们衍生出来的问题。
二、为什么这是个问题:把资产挂在会移动的东西上
很多产品早期图省事,订单表、购买记录、用户行为数据,直接拿 OpenID 当主键或外键。在“永远只有一个小程序、永远不换主体”的理想世界里,这确实能跑。但现实很骨感。常见的变动包括:
- 业务调整,需要从旧小程序迁到新小程序;
- 公司主体变更,或者要换一套技术服务商,整个开放平台账号都换了;
- 用户自己换了微信号登录;
- 甚至,你以前用过的一个微信号,辗转到了另一个人手里。
每发生一件这样的事,OpenID 或 UnionID 就可能变。如果你的核心资产(课程、余额、积分)是挂在这些会变的标识上的,就会出现两类问题,方向相反但后果都很严重:
- 资产丢失/分裂:标识变了,系统认不出老用户,他买的课就读不到了;或者同一个人被系统当成两个账号,资产散落在两边,无法合并。
- 资产泄漏/串号:一个标识被另一个人继承了(比如微信号转手),新用户登录后,系统解析到了前任的账号,看到了别人的订单和课程。这已经不是用户体验问题,而是数据安全事故。
第一类问题导致用户投诉、要求退款、最后流失;第二类问题则是数据安全的红线,性质更严重。
三、正确的数据模型:先给“人”一个永不变的身份
解决问题的核心只有一句话:永远不要把任何资产直接挂在 OpenID 或 UnionID 上,而是给你系统中的每个“人”,一个由你系统自己生成、并且永远不变的内部 ID。
落到数据库表设计上,就是三层结构:用户主体表 user,身份映射表 user_identity,以及将所有资产表的外键指向 user_id。
字段定义
user —— 用户主体表(一个真实的人 = 一行数据)
这张表的核心就是那句老话:一个用户,一个ID。
| 字段 | 含义 |
|---|---|
user_id | 内部用户主键,系统自己生成(如雪花算法或自增ID),永不变,是所有资产归属的绝对锚点。 |
primary_phone | 当前生效的手机号,这是一个冗余缓存的字段,与 user_identity 表中 active 状态的 phone 行数据保持一致,便于快速读取。 |
merged_into | 这是一个非常关键的字段。如果该用户被合并进了另一个用户,这个字段会指向合并后保留的那个 user_id。被合并的用户只保留这个指针,不再参与身份解析。为空则表示自身就是当前有效账号。 |
created_at | 用户创建时间。 |
user_identity —— 身份映射表(一个用户可以挂多条登录方式,一条登录方式 = 一行数据)
为什么要设计成一张独立的一对多表,而不是在 user 表上硬塞几个字段?因为一个人可能同时拥有多个微信账号、多个手机号,用一对多的行结构,天生就能装下这种复杂情况。
| 字段 | 含义 |
|---|---|
user_id | 这条身份属于哪个用户,外键指向 user.user_id。 |
id_type | 身份类型,取值可以是 phone、UnionID、OpenID、external_userid(企业微信外部联系人)等。 |
id_value | 该身份的具体值,比如手机号、UnionID字符串、OpenID字符串等。 |
app_id | 当 id_type=OpenID 时必填,因为OpenID只在某个具体的应用下唯一。 |
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_level、realname_token 留空即可。
理解这个模型,需要记住一条关键规则:这几行身份不是平权的。
- phone 行是权威锚:它最接近代表“这个人”。
- UnionID / OpenID 是可改绑的登录凭证:它们只代表“当前谁在用这个微信号或这个应用”。
- 解析身份时,优先级是:已验证手机号 > UnionID > OpenID。
- 当一个会话带来的已验证手机号,和某个 UnionID 当前绑定用户的手机号对不上时,信手机号。这说明微信号可能换人了,系统应该把这条 UnionID 改绑过去。
看到这里,其实已经能回答标题的一半了:手机号在这套体系里扮演的角色,就是那个“永不变的人”的锚。它不是用来给你打电话的,而是为了在 OpenID、UnionID 全都靠不住的时候,系统还能认出“这是同一个人,这些是他的资产”。
下面,通过八个真实场景过一遍数据的变更,彻底搞明白这套模型是如何运作的。为直观起见,我们假设用户张三,user_id = 1001 购买了课程 X。
四、八个场景下的数据变更
场景 1:换小程序,OpenID 变(UnionID 不变)
业务从小程序 A 迁到小程序 B,但还在同一个开放平台主体下。张三在 B 中登录,OpenID 变了,但 UnionID 没变。这里的“桥”就是 UnionID。系统拿 UnionID 反查到 user_id 为 1001,然后把新的 OpenID 作为新的一行插入 user_identity 表。
| 数据对象 | 变更前 | 变更后 |
|---|---|---|
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 数据——没绑的话,桥就不存在。
场景 2:换开放平台,UnionID 变(OpenID 不变)
这次是 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 变)
这是最硬核的情况:你重建了一个全新的小程序,挂在一个全新的开放平台下,OpenID 和 UnionID 都变了。微信体系里没有任何标识符能搭桥。这时,唯一的办法就是靠“微信体系之外的锚”——张三在新应用中验证手机号 136,系统反查到 user_id 为 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 挂到他名下。
| 数据对象 | 变更前 | 变更后 |
|---|---|---|
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 没注册过,生成一个新用户 1002;同时发现 U1 和 o1 当前还绑在 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;因为是用户本人显式发起且已登录,这里没有歧义。唯一需要当心的边界是:新号 188 已经是别人的锚点——这种情况默认应该拦截,并提示用户,如果用户坚持绑定,则应该视为一次显式的账号合并,并经过用户确认。
场景 7:两个账号其实是同一个人(账号合并,用 merged_into)
前六个场景里,张三始终只有一个 user_id。但现实中,用户经常被拆成两个账号——这正是 merged_into 唯一的用武之地。设想:张三早年先在 H5 用手机号 136 注册,落到 user 1001,买了课程 X;后来他用另一个微信号登录小程序,但当时没绑手机,系统不认识这个新微信号,于是又新建了 user 2001,他在这边又买了课程 Y。直到某天他去小程序里绑手机号,填了 136,系统才发现撞车了——两边其实是同一个人,需要合并。
合并时,先挑一个保留方(这通常是策略选择,比如保留“手机已实名”或“资产更多”的账号,这里保留 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 的标记,任何对这个旧 ID 的引用,都能通过这个指针解析到正确的用户,而不是报错“查无此人”。
一句话结论: merged_into 是解决“同一个人被拆成两个账号”的归一开关。挑一个保留方,把另一方的身份和资产迁移过去,被合并方只留一个指向保留方的指针,从此做重定向,不再参与解析。
场景 8:手机号被回收再出售(用实名核验来兜底)
国内手机号是实名制的。运营商会把长期不用的号回收,重新放号。所以一个半年后的 136 号,可能登记在另一个人名下。如果系统还无条件地相信 phone(136) → 张三,新机主一登录就会串号。
光靠 verified_at(上次验证时间)只能告诉你“这条绑定有点旧了”,是个怀疑,不是结论。借着实名制,可以把“怀疑”变成“确定”。在绑手机号时,或者在出现回收嫌疑、涉及高价值资产、账号找回争议时,做一次实名核验:
- 三要素核验:手机号 + 姓名 + 身份证号,确认这个号登记在这个人名下。
- 人脸核验:在三要素基础上再加活体检测,确认“此刻操作的就是本人”,能更有效地防止冒用。
核验结果存成不可逆的 realname_token(姓名+身份证的哈希),连同 verify_level、verified_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 绑的是“微信号 × 开放平台”,换主体就变;
- 连微信号本身都会换人,会被回收。
微信给你的每一个标识符,绑定的都是“某个微信在某个应用里”,没有一个真正绑定“你这个人”。而你买的课、充的会员、攒的学习进度,是属于“你这个人”的资产。要让这些资产在所有变动中始终跟着你,就必须有一个最接近“人”、且相对稳定的锚。在当前能拿到的信息里,手机号就是那个最好的选择;而国内手机号的实名制,又让它背后可以挂一个更硬的实名身份,连号码回收都能扛住。
所以,一个负责任的产品要你的手机号,核心目的通常是:
- 让你换小程序、换主体、换微信号登录时,买过的东西都还在(场景 1–4);
- 让别人拿到你用过的微信号、或拿到你回收掉的旧号时,看不到你的资产(场景 5、8);
- 给你一个能跨设备、跨微信找回账号的入口。
当然,得诚实地说:确实有产品拿手机号去做营销,甚至卖给第三方。而且,越往实名核验走(姓名、身份证、人脸),收集的信息越敏感,“最小必要”和“安全存储”就越发重要。《个人信息保护法》的最小必要原则要求,收集个人信息应与处理目的直接相关,限于实现目的的最小范围。技术上需要手机号当账号锚,和业务上拿着这些信息去骚扰,是两回事。
一个负责任的产品,会做到以下几点:
- 时机:只在“购买”或“涉及资产”这类关键节点才要手机号;把三要素、人脸这类重核验,留给“回收嫌疑、找回争议、高价值资产”这些真正需要确定身份的时刻,而不是人人、时时都要。
- 存储:实名信息只留不可逆的哈希标识用于比对,不留明文。
- 用途:严格限定在账号与资产本身,要完就安安静静当锚,不拿去推销。
- 告知:把用途向用户说清楚。
所以,回到标题——要你的手机号,不一定是为了打骚扰电话。在微信这套“标识符全绑微信号”的生态里,它常常是把“你的资产”和“你这个人”绑在一起的,技术上几乎唯一的办法。这把钥匙当然可能被滥用,那是另一个该由监管和自律去约束的问题——但它本身的存在,确实有它正当的、技术上的理由。
