# 运营商一键登录接入方案(P2Q) > 状态:方案文档 · 真实取号 SDK 就绪后按路线图替换 mock 即可 > 适用范围:轻松聘 App 运营商一键登录(本机号码认证) > 关联文件:`src/types/carrier-auth.ts`、`src/lib/carrier-sdk.ts`(取号适配层)、`src/hooks/useCarrierPhone.ts`、`src/hooks/useLogin.ts`、`src/api/api.ts`、`components/onboarding/screens/P2QuickLogin.tsx`、`src/__tests__/carrier-login.test.ts`、`src/__tests__/carrier-sdk.test.ts` > 参考:https://www.cnblogs.com/Frankada/p/15689036.html (本机号码一键登录原理:三大运营商能力边界 + 取号/校验方法) > 本文回答的问题:「一键登录时应当获取到手机号,为什么获取不到」—— 见第 1 节。 --- ## 0. 一句话结论 **在本机(Expo Go / 模拟器)跑,取号必然是假的**:本机号码只能由运营商的号码认证 SDK 在端上取回,它是原生模块,Expo Go 内不存在,模拟器也没有 SIM 卡。当前 `fetchCarrierPhone()` 走的是 `src/lib/carrier-sdk.ts` 的 mock 分支,返回写死的号码 `15000009367`(掩码 `150****9367`),用于把**页面链路**跑通。 **接入真实取号 = 端上换 1 个适配层文件(`src/lib/carrier-sdk.ts` 的 5 个函数体) + 服务器换 1 个接口(`/api/auth/login-by-carrier`) + 用 EAS Build 生成自定义客户端。** 页面、hook、类型、`api.ts` **零改动**。 --- ## 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` / `CarrierFailureReason` / `CarrierNetworkInfo` 已定义 | 无需改动 | | 取号 hook | `src/hooks/useCarrierPhone.ts` | ✅ loading / error / refresh / failureReason 已就绪 | 无需改动 | | 页面流转 | `P2QuickLogin.tsx` | ✅ 取号 → 换登录态 → 写登录态 → 写回号码 → `P3` | 无需改动 | | 登录态 | `src/context/AuthContext.tsx` | ✅ `login(token, expiresAt)` 已就绪 | 无需改动 | | **端上取号适配层** | `src/lib/carrier-sdk.ts` | ✅ 厂商无关适配层已就位(对齐 `wechat-sdk.ts`),现为 mock | **换 SDK 调用(端上)** | | **取号接口** | `src/api/api.ts` · `fetchCarrierPhone()` | ✅ 已改为调适配层,只做超时/归一化/脱敏 | 无需改动(改适配层即可) | | **换登录态** | `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. 两个硬约束(设计已遵守,接后端不可破坏) 1. **端上取号 ≠ 可信号码**:运营商 SDK 在端上取回的号码可以被改机工具伪造,因此**可信号码必须由后端拿令牌向运营商换取**(`/api/auth/login-by-carrier` 的返回为准)。前端只把取到的号码拿去展示。 2. **令牌一次性 + 短 TTL**:`carrierToken` 用后即废、且 5 分钟过期。所以「取号」与「换登录态」必须紧挨着(页面级),不能缓存起来留到下个页面用;过期后唯一的正确动作是**重新取号**。 --- ## 4. 接入路线图(按执行顺序) ### Step 1 · 开通运营商号码认证服务(前置,阻塞项) - 任选其一:中国移动 / 联通 / 电信官方号码认证能力,或**聚合方案**(阿里云号码认证服务、极光、闪验等,一次接多家运营商)。 - 需要准备:企业资质、`android.package`(`com.qingsongpin.app`,见 `app.config.js`)与**签名 SHA256**、iOS `bundleIdentifier`。 - 拿到的 **AppKey / AppSecret** 需同时配置在**端上(SDK 初始化)**与**服务器(换取号码)**两侧。 ### Step 2 · 安装取号 SDK(Expo 插件优先) ```bash npx expo install <所选方案的 RN 插件> ``` - 原生模块需在 `ios/` `android/` 原生壳中注册;有 config plugin 的方案由预构建自动注入,无需手改原生代码。 - **Expo Go 一定跑不了取号**(`requireNativeModule` 在 import 期执行,缺模块会直接崩),所以本地开发期保留 mock 分支,用 `__DEV__` 或环境变量区分。 ### Step 3 · 生成 EAS 自定义开发客户端 ```bash 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/lib/carrier-sdk.ts`**(不是 `api.ts` —— 取号能力已隔离到适配层,与微信登录的 `src/lib/wechat-sdk.ts` 对称)。把下面 5 个 mock 函数体换成 SDK 调用,**对外签名不变**: | 适配层函数 | 对应原生 API(Android / iOS) | |---|---| | `isCarrierAuthAvailable()` | SDK 初始化结果 | | `getCarrierNetworkInfo()` | `getNetworkType` / `networkInfo` | | `prefetchNumber(timeoutMs)` | `getPhoneInfo` / `getPhoneNumberCompletion`(只建缓存,不返号码) | | `getCarrierNumber(timeoutMs)` | 同上取号 → `{ phone, token, operator }` | | `clearNumberCache()` | `delScrip` / `delectScrip` | `api.ts` 的 `fetchCarrierPhone()` 只负责三件业务规则:套超时(`CARRIER_SDK_TIMEOUT_MS` = 8000ms)、 用 `carrierOf()` 归一化运营商、调 `maskPhone()` 脱敏,**它不碰 SDK,接真实 SDK 时不用改它**。 ```ts // src/lib/carrier-sdk.ts · getCarrierNumber 真实实现示例 const r = await NumberAuthSDK.getMobileNumber(); // 5 分钟内有效,用后即废 if (!r?.token) throw new CarrierAuthError('unknown'); return { phone: r.phone, token: r.token, operator: r.operator }; ``` - **失败一律抛 `CarrierAuthError(reason)` 或返回空串,绝不返回假号码** —— 页面靠 `carrierToken === ''` 判定失败并置灰按钮,返回假号码会让失败分支永远进不去。 - 运营商标识各家形状不同(中文名 / 大写缩写 / 数字码),`carrierOf()` 已做归一化,认不出即 `unknown`。 ### Step 4.5 · 四个不能漏的工程细节(来自运营商文档,非我们的设计选择) | 细节 | 说明 | 落点 | |---|---|---| | **超时** | SDK 默认 8s;不包装则弱信号下取号无限挂起,页面停在「正在获取本机号码…」没有出口 | `CARRIER_SDK_TIMEOUT_MS` + `guard()` 超时包装 | | **蜂窝网络预检** | 取号走数据网关,纯 Wi-Fi 取不到(SDK 会瞬切蜂窝再切回,但用户关流量时必然失败) | `getCarrierNetworkInfo()`,失败原因 `no-cellular` | | **取号缓存 scrip** | 缓存提高成功率、降时延,且允许未开蜂窝时取号;换卡时 SDK 自动失效 | `prefetchNumber()` 建缓存 / `clearNumberCache()` 主动清 | | **敏感动作清缓存** | 注销、换绑手机号、退出登录后应主动 `delScrip`,避免下次取号命中旧缓存 | `clearNumberCache()`(**尚未接到注销/换绑流程,待补**) | > 缓存清理目前只有函数、没有调用方 —— 等账号注销 / 换绑手机号功能落地时再接,不提前铺。 ### Step 5 · 实现后端换号接口 | 接口 | 方法 | 职责 | 关键返回 | |---|---|---|---| | `/api/auth/carrier/number` | POST | 端上取号的**服务端旁路**(可选:部分方案把 token 的校验放在这里) | `CarrierPhoneResult` | | `/api/auth/login-by-carrier` | POST | 用一次性 `carrierToken` 向运营商换取真实号码 → 查库 → 建号/登录 | `LoginResult`:`token` + **已核验的 `phone`** + `expiresAt` | - 后端须保证:`carrierToken` 一次性、来源可校验、过期拒绝;**号码以后端换取结果为准**(前端传来的号码一律不信任)。 - 该接口同时也是「登录即注册」入口:号码在库中不存在时应自动建档(与验证码通道一致)。 ### Step 6 · 清除 mock 残留 - 删除 `src/lib/carrier-sdk.ts` 的 mock 块:`MOCK_CARRIER_PHONE`、`mockState`、`setMockCarrierFailure`、`setMockCarrierHang`(`guard()` 的超时包装要留,真实取号同样需要)。 - `api.ts` 中 `loginByCarrier()` 的 mock 返回与 `MOCK_CARRIER_PHONE` 引用一并替换为真接口。 - `src/__tests__/carrier-sdk.test.ts` 中断言 mock 行为的用例改为断言**适配层契约**(失败不返假号、超时必返 timeout、归一化认不出即 unknown),这部分在真实取号后依然是有效契约。 --- ## 5. 手动验收路径(可复现) 1. 在自定义客户端(非 Expo Go)上安装,**插入有效 SIM 卡并开启数据流量**(取号需要蜂窝网络鉴权,纯 Wi-Fi 下多数方案会失败)。 2. 进入 P2Q → 期望**不输入任何号码**即展示掩码本机号码(与 SIM 卡一致),页脚显示「认证服务由 xx 提供」。 3. 点「一键登录」→ 期望直接进 P3,且后续注册流程(P2P/P2R 预填手机号、B3 联系人电话)**都用这个真号码**。 4. 关流量 / 开飞行模式 / 拔卡 → 期望号码位显示「未能获取本机号码」、按钮置灰、两个出口(重新获取 / 改用验证码登录)可用。 5. 令牌过期后(>5 分钟再点)→ 期望提示重新取号,不出现「点了没反应」。 6. 回归 `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、类型、`api.ts`。** 只动两处:`src/lib/carrier-sdk.ts` 的 5 个函数体(换端上 SDK 调用,失败抛 `CarrierAuthError` 或返回空串)和 `loginByCarrier()`(换真接口,号码以后端为准),配齐运营商号码认证服务 + EAS 自定义客户端,一键登录即可真机走通。