10 KiB
运营商一键登录接入方案(P2Q)
状态:方案文档 · 不涉及代码改写 · 真实取号 SDK 就绪后按路线图替换 mock 即可 适用范围:轻松聘 App 运营商一键登录(本机号码认证) 关联文件:
src/types/carrier-auth.ts、src/hooks/useCarrierPhone.ts、src/hooks/useLogin.ts、src/api/api.ts、components/onboarding/screens/P2QuickLogin.tsx、src/__tests__/carrier-login.test.ts本文回答的问题:「一键登录时应当获取到手机号,为什么获取不到」—— 见第 1 节。
0. 一句话结论
在本机(Expo Go / 模拟器)跑,取号必然是假的:本机号码只能由运营商的号码认证 SDK 在端上取回,它是原生模块,Expo Go 内不存在,模拟器也没有 SIM 卡。当前 fetchCarrierPhone() 返回的是写死的号码 15000009367(掩码 150****9367),用于把页面链路跑通。
接入真实取号 = 端上换 1 处 SDK 调用(fetchCarrierPhone 函数体) + 服务器换 1 个接口(/api/auth/login-by-carrier) + 用 EAS Build 生成自定义客户端。 页面、hook、类型零改动。
1. 原先「取不到手机号」的两个真实原因(已修)
排查结论:不是取号 SDK 的问题,是页面根本没读取号结果。修复前 P2QuickLogin.tsx 有两个独立缺陷,叠加起来表现为「号码永远显示占位」:
| # | 缺陷 | 表现 | 现状态 |
|---|---|---|---|
| 1 | 页面读的是 data.phone —— 该字段只有短信登录通道(P2)才会写入 |
走一键登录进来时 data.phone 恒为空 → 号码位永远渲染占位文案「本机号码」,用户以为没取到号 |
✅ 已改为读 useCarrierPhone() 的 maskedPhone |
| 2 | loginByCarrier() 返回的 phone 被直接丢弃 |
即使后端换了真号码,页面与后续步骤(P2P/P2R 预填、B3 联系人电话)也拿不到 | ✅ 成功登录后 setData({ phone: result.phone }) 写回引导资料 |
顺带补上的失败分支(此前不存在,取号失败也照样显示号码、按钮照样可点):
carrierToken为空 = 没取到号 → 「一键登录」置灰(空令牌调登录接口必然失败,不如不让点);- 号码位改为「正在获取本机号码…」/「未能获取本机号码」,并给出重新获取与改用验证码登录两个出口;
- 取号是进页面即预取(
useCarrierPhone→useAsyncData,带自动重试与请求去重),不是等用户点按钮才去取。
没修的(有意保留):
P2QuickLogin的《xx认证服务条款》不冒充能打开——该文档由运营商在认证页内提供,本仓库没有,故只弹一段如实说明;页脚服务热线为空时整块不渲染(SUPPORT_HOTLINE为空)。
2. 现状盘点(已实现 vs 待接入)
| 层 | 文件 | 现状 | 接入动作 |
|---|---|---|---|
| 类型 | src/types/carrier-auth.ts |
✅ CarrierPhoneResult / CarrierName 已定义 |
无需改动 |
| 取号 hook | src/hooks/useCarrierPhone.ts |
✅ loading / error / refresh 已就绪 | 无需改动 |
| 页面流转 | P2QuickLogin.tsx |
✅ 取号 → 换登录态 → 写登录态 → 写回号码 → P3 |
无需改动 |
| 登录态 | src/context/AuthContext.tsx |
✅ login(token, expiresAt) 已就绪 |
无需改动 |
| 端上取号 | src/api/api.ts · fetchCarrierPhone() |
⚠️ mock:写死 15000009367 |
换运营商 SDK 调用(端上) |
| 换登录态 | src/api/api.ts · loginByCarrier() |
⚠️ mock:直接下发 mock_token_carrier_* |
换真实接口 |
| 运营商/聚合服务 | —— | ❌ 未开通 | 开通号码认证服务 |
| 构建产物 | EAS | ❌ 无自定义客户端 | eas build --profile development |
已验证的关键事实(直接取自源码,非推测):
loginByCarrier('')直接 reject(本机号码未取到,请重新获取或改用验证码登录),页面因此不能靠空令牌换到登录态。- 登录成功后
writeAuth(result.token)确实写入登录态(此前该页只navigate('P3')不写 token,是本项目曾存在的严重缺口,已于 2026-09-15 一并修复)。 - 号码的唯一真相源是后端换取结果,不是端上预取的号码:
loginByCarrier返回的phone由接口层给出,页面不自己拼预取到的号码写资料。 - 取号令牌一次性且 5 分钟内有效(
CARRIER_TOKEN_TTL_MS),过期需重新取号。
3. 两个硬约束(设计已遵守,接后端不可破坏)
- 端上取号 ≠ 可信号码:运营商 SDK 在端上取回的号码可以被改机工具伪造,因此可信号码必须由后端拿令牌向运营商换取(
/api/auth/login-by-carrier的返回为准)。前端只把取到的号码拿去展示。 - 令牌一次性 + 短 TTL:
carrierToken用后即废、且 5 分钟过期。所以「取号」与「换登录态」必须紧挨着(页面级),不能缓存起来留到下个页面用;过期后唯一的正确动作是重新取号。
4. 接入路线图(按执行顺序)
Step 1 · 开通运营商号码认证服务(前置,阻塞项)
- 任选其一:中国移动 / 联通 / 电信官方号码认证能力,或聚合方案(阿里云号码认证服务、极光、闪验等,一次接多家运营商)。
- 需要准备:企业资质、
android.package(com.qingsongpin.app,见app.config.js)与签名 SHA256、iOSbundleIdentifier。 - 拿到的 AppKey / AppSecret 需同时配置在**端上(SDK 初始化)与服务器(换取号码)**两侧。
Step 2 · 安装取号 SDK(Expo 插件优先)
npx expo install <所选方案的 RN 插件>
- 原生模块需在
ios/android/原生壳中注册;有 config plugin 的方案由预构建自动注入,无需手改原生代码。 - Expo Go 一定跑不了取号(
requireNativeModule在 import 期执行,缺模块会直接崩),所以本地开发期保留 mock 分支,用__DEV__或环境变量区分。
Step 3 · 生成 EAS 自定义开发客户端
eas build --profile development --platform android
eas build --profile development --platform ios
- 与微信登录同一前提(见
微信登录接入方案.md的 Step 3):后续所有真机验证都在此客户端中进行。 - iOS 需要在
app.config.js补运营商要求的隐私描述文案(expo-build-properties/infoPlist),Android 需确认运营商要求的权限未被裁剪。
Step 4 · 替换端上取号(唯一改动点 · 前端)
文件:src/api/api.ts 的 fetchCarrierPhone(),把 mock 返回换成 SDK 调用(注释里已写好变形写法):
const r = await NumberAuthSDK.getMobileNumber(); // 5 分钟内有效,用后即废
if (!r?.token) return { phone: '', maskedPhone: '', carrierToken: '', carrier: 'unknown', expiresAt: 0 };
return {
phone: r.phone,
maskedPhone: maskPhone(r.phone),
carrierToken: r.token,
carrier: carrierOf(r.operator),
expiresAt: Date.now() + CARRIER_TOKEN_TTL_MS,
};
- 失败一律返回空串而不是假号码 —— 页面正是靠
carrierToken === ''判定失败并置灰按钮,返回假号码会让失败分支永远进不去。 maskPhone/carrierOf已在接口层就位,页面不做脱敏。- 此函数签名不变,页面与 hook 不动。
Step 5 · 实现后端换号接口
| 接口 | 方法 | 职责 | 关键返回 |
|---|---|---|---|
/api/auth/carrier/number |
POST | 端上取号的服务端旁路(可选:部分方案把 token 的校验放在这里) | CarrierPhoneResult |
/api/auth/login-by-carrier |
POST | 用一次性 carrierToken 向运营商换取真实号码 → 查库 → 建号/登录 |
LoginResult:token + 已核验的 phone + expiresAt |
- 后端须保证:
carrierToken一次性、来源可校验、过期拒绝;号码以后端换取结果为准(前端传来的号码一律不信任)。 - 该接口同时也是「登录即注册」入口:号码在库中不存在时应自动建档(与验证码通道一致)。
Step 6 · 清除 mock 残留
- 删除
MOCK_CARRIER_PHONE/MOCK_CARRIER_NAME与fetchCarrierPhone的 mock 分支。 src/__tests__/carrier-login.test.ts中断言「mock 号码」的用例改为断言失败分支(空令牌 reject、空号码不置ready),这部分在真实取号后依然是有效契约。
5. 手动验收路径(可复现)
- 在自定义客户端(非 Expo Go)上安装,插入有效 SIM 卡并开启数据流量(取号需要蜂窝网络鉴权,纯 Wi-Fi 下多数方案会失败)。
- 进入 P2Q → 期望不输入任何号码即展示掩码本机号码(与 SIM 卡一致),页脚显示「认证服务由 xx 提供」。
- 点「一键登录」→ 期望直接进 P3,且后续注册流程(P2P/P2R 预填手机号、B3 联系人电话)都用这个真号码。
- 关流量 / 开飞行模式 / 拔卡 → 期望号码位显示「未能获取本机号码」、按钮置灰、两个出口(重新获取 / 改用验证码登录)可用。
- 令牌过期后(>5 分钟再点)→ 期望提示重新取号,不出现「点了没反应」。
- 回归
npx tsc --noEmit与npx eslint .均 0 错误。
6. 风险与待确认项
| 项 | 风险/阻塞 | 处置 |
|---|---|---|
| 运营商服务开通 | 需企业资质与签名指纹,非代码可解 | Step 1 前置,未开通前无法真机验证 |
| 蜂窝网络依赖 | 无 SIM / 无流量 / 双卡选错卡都会取号失败 | 失败分支已就位;验收时明确用流量 |
| 端上号码不可信 | 改机工具可伪造取号结果 | 号码一律以后端换取结果为准(Step 5) |
| Expo Go 无法验证 | 原生模块不存在,import 期即崩 | 开发期保留 mock 分支 + dev build 出包 |
| SDK 选型 | 各家插件维护状态不一 | 接入时以官方 docs 为准(AGENTS.md 要求先读 docs.expo.dev/versions/v57.0.0/) |
7. 一句话给执行者
别动页面、hook、类型。 只动两处:fetchCarrierPhone()(换端上 SDK 调用,失败返回空串)和 loginByCarrier()(换真接口,号码以后端为准),配齐运营商号码认证服务 + EAS 自定义客户端,一键登录即可真机走通。