From c16ce630396a053398a21b2b2c84f1d91fa70ff5 Mon Sep 17 00:00:00 2001 From: doublex001 <16157008+doublex001@user.noreply.gitee.com> Date: Wed, 23 Sep 2026 11:07:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E9=9A=94=E7=A6=BB=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E5=95=86=E5=8F=96=E5=8F=B7=E9=80=82=E9=85=8D=E5=B1=82?= =?UTF-8?q?=EF=BC=8C=E8=A1=A5=E9=BD=90=E5=8F=96=E5=8F=B7=E8=B6=85=E6=97=B6?= =?UTF-8?q?/=E7=BD=91=E7=BB=9C=E9=A2=84=E6=A3=80/=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把端上取号从 api.ts 抽到 src/lib/carrier-sdk.ts,与微信登录的 wechat-sdk.ts 对称:两条登录通道的接入方式此前不对称,运营商取号 直接写死在接口层,换 SDK 时要动业务代码。 厂商无关:适配层只暴露 5 个能力(可用性/网络预检/预取号/取号/清缓存), 对外统一形状 CarrierSdkNumber,接入移动/联通/电信/阿里云/极光任一家 都只改这一个文件,页面、hook、类型、api.ts 零改动。 补齐运营商文档强调但项目缺失的 4 个工程细节: - 超时:SDK 默认 8s,不包装则弱信号下无限挂起,页面没有出口 - 蜂窝网络预检:取号走数据网关,纯 Wi-Fi 取不到,可提前给出引导 - 取号缓存 scrip:预取号建缓存,提高成功率并允许未开蜂窝时取号 - 敏感动作清缓存(delScrip/delectScrip):注销/换绑时主动失效旧缓存 取号失败原因显式建模为联合类型(no-cellular/timeout/unsupported-carrier/ sdk-unavailable/unknown),不再混成一句"未能获取本机号码"——三者的 下一步引导动作不同。失败一律返回空串,绝不返回假号码。 收敛 mock 号码到单一定义:此前 fetchCarrierPhone 与 loginByCarrier 各写一份,迟早漂移成两个号。 tsc 0 错误;jest 383 passed / 27 suites。 --- src/__tests__/carrier-sdk.test.ts | 138 +++++++++++++++++++++ src/api/api.ts | 77 ++++++++---- src/constants/index.ts | 21 ++++ src/hooks/useCarrierPhone.ts | 8 +- src/lib/carrier-sdk.ts | 177 +++++++++++++++++++++++++++ src/types/carrier-auth.ts | 50 ++++++++ src/types/index.ts | 9 +- 运营商一键登录接入方案.md | 65 ++++++---- 8 files changed, 499 insertions(+), 46 deletions(-) create mode 100644 src/__tests__/carrier-sdk.test.ts create mode 100644 src/lib/carrier-sdk.ts diff --git a/src/__tests__/carrier-sdk.test.ts b/src/__tests__/carrier-sdk.test.ts new file mode 100644 index 0000000..b1c3bdc --- /dev/null +++ b/src/__tests__/carrier-sdk.test.ts @@ -0,0 +1,138 @@ +import { fetchCarrierPhone, carrierOf } from '../api/api'; +import { + clearNumberCache, + getCarrierNetworkInfo, + getCarrierNumber, + isCarrierAuthAvailable, + prefetchNumber, + setMockCarrierFailure, + setMockCarrierHang, + CarrierAuthError, +} from '../lib/carrier-sdk'; +import { CARRIER_FAILURE_LABEL, CARRIER_SDK_TIMEOUT_MS } from '../constants'; +import type { CarrierFailureReason, CarrierName } from '../types'; + +// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock(与 carrier-login.test.ts 同) +jest.mock('../lib/storage', () => ({ + storageGet: jest.fn(async () => null), + storageSet: jest.fn(async () => undefined), + storageRemove: jest.fn(async () => undefined), +})); + +const ALL_REASONS: CarrierFailureReason[] = [ + 'sdk-unavailable', + 'no-cellular', + 'timeout', + 'unsupported-carrier', + 'unknown', +]; + +const ALL_CARRIERS: CarrierName[] = ['cmcc', 'cucc', 'ctcc', 'unknown']; + +/** mock 状态是模块级单例,每个用例后必须复位,否则会污染后续用例 */ +afterEach(() => { + setMockCarrierFailure(null); + setMockCarrierHang(false); +}); + +describe('取号超时(运营商 SDK 默认 8s,必须显式包装)', () => { + it('超时常量就是运营商 SDK 的默认值 8000ms', () => { + expect(CARRIER_SDK_TIMEOUT_MS).toBe(8000); + }); + + it('取号挂起时按超时失败,而不是一直挂着不给结果', async () => { + setMockCarrierHang(true); + await expect(getCarrierNumber(50)).rejects.toThrow(CarrierAuthError); + }); + + it('超时返回的失败原因是 timeout,可据此引导用户重试', async () => { + setMockCarrierHang(true); + try { + await getCarrierNumber(50); + throw new Error('不应走到成功分支'); + } catch (error) { + expect(error).toBeInstanceOf(CarrierAuthError); + expect((error as CarrierAuthError).reason).toBe('timeout'); + } + }); +}); + +describe('取号失败:一律返回空串,绝不返回假号码', () => { + it.each(ALL_REASONS)('%s 时号码与令牌都是空串', async (reason) => { + setMockCarrierFailure(reason); + const result = await fetchCarrierPhone(); + + expect(result.phone).toBe(''); + expect(result.maskedPhone).toBe(''); + expect(result.carrierToken).toBe(''); + expect(result.failureReason).toBe(reason); + }); + + it('没开蜂窝流量时,网络预检就能提前发现,不必白等一次超时', async () => { + setMockCarrierFailure('no-cellular'); + const info = await getCarrierNetworkInfo(); + + expect(info.networkType).not.toBe('cellular'); + }); + + it('成功时 failureReason 为 null', async () => { + const result = await fetchCarrierPhone(); + expect(result.failureReason).toBeNull(); + expect(result.carrierToken).not.toBe(''); + }); +}); + +describe('carrierOf(厂商运营商标识归一化)', () => { + it('中文名 / 大写缩写 / 数字码形状都能归一到本项目认的四个值', () => { + expect(carrierOf('CMCC')).toBe('cmcc'); + expect(carrierOf('中国移动')).toBe('cmcc'); + expect(carrierOf('CUCC')).toBe('cucc'); + expect(carrierOf('中国联通')).toBe('cucc'); + expect(carrierOf('CTCC')).toBe('ctcc'); + expect(carrierOf('中国电信')).toBe('ctcc'); + }); + + it('认不出来一律 unknown,不猜', () => { + expect(carrierOf('')).toBe('unknown'); + expect(carrierOf('某不知名运营商')).toBe('unknown'); + ALL_CARRIERS.forEach((carrier) => { + expect(ALL_CARRIERS).toContain(carrier); + }); + }); +}); + +describe('预取号与缓存(运营商称 scrip)', () => { + it('预取号成功建立缓存,不抛错', async () => { + await expect(prefetchNumber(CARRIER_SDK_TIMEOUT_MS)).resolves.toBeUndefined(); + }); + + it('清缓存幂等:注销、换绑等敏感动作后应主动清一次', async () => { + await expect(clearNumberCache()).resolves.toBeUndefined(); + await expect(clearNumberCache()).resolves.toBeUndefined(); + }); + + it('预取号挂起同样按超时失败', async () => { + setMockCarrierHang(true); + await expect(prefetchNumber(50)).rejects.toThrow(CarrierAuthError); + }); +}); + +describe('CARRIER_FAILURE_LABEL', () => { + it('每种失败原因都有引导文案(新增原因漏配时失败)', () => { + ALL_REASONS.forEach((reason) => { + expect(CARRIER_FAILURE_LABEL[reason]).toBeTruthy(); + }); + expect(Object.keys(CARRIER_FAILURE_LABEL)).toHaveLength(ALL_REASONS.length); + }); + + it('文案要写明下一步能做什么,不能只说失败了', () => { + expect(CARRIER_FAILURE_LABEL['no-cellular']).toContain('流量'); + expect(CARRIER_FAILURE_LABEL['unsupported-carrier']).toContain('验证码'); + }); +}); + +describe('isCarrierAuthAvailable', () => { + it('不可用时应直接改走验证码登录', () => { + expect(typeof isCarrierAuthAvailable()).toBe('boolean'); + }); +}); diff --git a/src/api/api.ts b/src/api/api.ts index 43ebd78..cba3d2c 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -163,11 +163,13 @@ import type { ChangePasswordPayload, PasswordResetPayload, PasswordRuleError, + CarrierFailureReason, CarrierName, CarrierPhoneResult, } from '../types'; import { ALL_CITY_LABEL, + CARRIER_SDK_TIMEOUT_MS, CARRIER_TOKEN_TTL_MS, DEFAULT_SOCIAL_PROFILE, JOB_MATCH_CITY_BONUS, @@ -194,6 +196,7 @@ import { colors } from '../theme'; import { storageGet, storageSet, storageRemove } from '../lib/storage'; import { exportResumePdf } from '../lib/resumeExport'; import { clearCacheDir, readCacheBytes } from '../lib/cache'; +import { getCarrierNumber, CarrierAuthError, MOCK_CARRIER_PHONE } from '../lib/carrier-sdk'; /** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */ export const API_BASE_URL: string = @@ -8033,38 +8036,68 @@ export function maskPhone(phone: string): string { } /** - * 运营商 SDK 在 mock 阶段取回的本机号码。 - * 真实接入后由端上 SDK 取号,这个常量连同 mock 分支一并删掉。 + * 厂商返回的运营商原始标识 → CarrierName。 * - * 注意它是**接口层的模拟数据**(与 mockJobData 同性质),不是页面里写死的展示值: - * 页面只能拿 `fetchCarrierPhone()` 的返回,取不到号就必须走失败分支。 + * 归一化放在接口层而不是适配层:各家厂商给的形状不一样(中文名 / 大写缩写 / 数字码), + * 而「本项目只认这四个值」是业务规则,不该污染只负责搬运的适配层。 + * 认不出来一律 unknown —— 宁可显示「运营商」也不猜错。 */ -const MOCK_CARRIER_PHONE = '15000009367'; -const MOCK_CARRIER_NAME: CarrierName = 'cmcc'; +export function carrierOf(operator: string): CarrierName { + const s = operator.toUpperCase(); + if (s.includes('CMCC') || s.includes('移动') || s.includes('CHINA_MOBILE')) return 'cmcc'; + if (s.includes('CUCC') || s.includes('联通') || s.includes('CHINA_UNICOM')) return 'cucc'; + if (s.includes('CTCC') || s.includes('电信') || s.includes('CHINA_TELECOM')) return 'ctcc'; + return 'unknown'; +} + +/** + * 取号失败的统一返回:号码与令牌一律空串,只带失败原因。 + * 页面靠 `carrierToken === ''` 判定失败并置灰按钮 —— 这里绝不能返回假号码, + * 否则失败分支永远进不去,用户会对着一个必然失败的按钮反复点。 + */ +function emptyCarrierResult(reason: CarrierFailureReason): CarrierPhoneResult { + return { + phone: '', + maskedPhone: '', + carrierToken: '', + carrier: 'unknown', + expiresAt: 0, + failureReason: reason, + }; +} /** - * 取本机号码(运营商一键登录第 1 步:预取号)。 + * 取本机号码(运营商一键登录第 1 步:取号)。 + * + * 真实实现在**端上**完成,不经过我们的服务器:走 src/lib/carrier-sdk.ts 调运营商号码认证 + * SDK(原生模块,Expo Go 跑不了,必须 dev build / 出包 —— 详见 运营商一键登录接入方案.md)。 * - * 真实实现在**端上**完成,不经过我们的服务器:需接入运营商号码认证 SDK - * (中国移动/联通/电信,或阿里云号码认证服务等聚合方案)。它是原生模块, - * Expo Go 跑不了,必须 dev build / 出包 —— 详见 运营商一键登录接入方案.md。 + * 这里只做三件属于业务规则的事:套超时、归一化运营商、脱敏。取号能力本身不在本文件。 * * @method POST * @url /api/auth/carrier/number * @returns Promise 本机号码 + 掩码 + 一次性鉴权令牌;取号失败时号码与令牌为空串 */ -export function fetchCarrierPhone(): Promise { - // TODO: 端上取号(原生 SDK),去掉下面的 mock 返回: - // 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 }; - return mockRequest({ - phone: MOCK_CARRIER_PHONE, - maskedPhone: maskPhone(MOCK_CARRIER_PHONE), - carrierToken: `mock_carrier_token_${Date.now()}`, - carrier: MOCK_CARRIER_NAME, - expiresAt: Date.now() + CARRIER_TOKEN_TTL_MS, - }); +export async function fetchCarrierPhone(): Promise { + try { + const r = await getCarrierNumber(CARRIER_SDK_TIMEOUT_MS); + if (!r.phone || !r.token) { + return emptyCarrierResult('unknown'); + } + return { + phone: r.phone, + maskedPhone: maskPhone(r.phone), + carrierToken: r.token, + carrier: carrierOf(r.operator), + expiresAt: Date.now() + CARRIER_TOKEN_TTL_MS, + failureReason: null, + }; + } catch (error) { + // 取号失败不向上抛:页面要的是「没取到号」这个状态,不是异常。 + return emptyCarrierResult( + error instanceof CarrierAuthError ? error.reason : 'unknown', + ); + } } /** diff --git a/src/constants/index.ts b/src/constants/index.ts index 7a960bc..4e3c70c 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -2,6 +2,7 @@ * 常量统一导出(枚举、配置项等) */ import type { + CarrierFailureReason, CarrierName, CitySocialProfile, JobEducationRequirement, @@ -117,6 +118,14 @@ export const TOKEN_TTL_MS = 30 * ONE_DAY_MS; */ export const CARRIER_TOKEN_TTL_MS = 5 * 60 * 1000; +/** + * 取号超时时间(毫秒)。 + * 运营商取号走数据网关,SDK 默认超时就是 8s(Android setOverTime / iOS setTimeoutInterval)。 + * 这里显式写出来的原因:不做超时包装时,弱信号下取号会一直挂着,页面既不给结果也不给 + * 重试入口,用户只看到「正在获取本机号码…」卡死。 + */ +export const CARRIER_SDK_TIMEOUT_MS = 8000; + /** * 认证服务提供方 → 展示名。 * 取号接口返回哪家就用哪家,页面不写死「中国移动」。 @@ -128,6 +137,18 @@ export const CARRIER_LABEL: Record = { unknown: '运营商', }; +/** + * 取号失败原因 → 引导文案。 + * 文案必须写明**用户下一步能做什么**,只说「失败了」没有意义。 + */ +export const CARRIER_FAILURE_LABEL: Record = { + 'sdk-unavailable': '当前客户端不支持一键登录,请改用验证码登录', + 'no-cellular': '取号需要移动数据流量,请开启后重新获取', + timeout: '取号超时,请检查网络后重新获取', + 'unsupported-carrier': '当前运营商暂不支持一键登录,请改用验证码登录', + unknown: '未能获取本机号码,可重新获取或改用验证码登录', +}; + /* ===================== 登录密码规则 ===================== */ /** 登录密码最小长度(含) */ diff --git a/src/hooks/useCarrierPhone.ts b/src/hooks/useCarrierPhone.ts index c29a115..f20e348 100644 --- a/src/hooks/useCarrierPhone.ts +++ b/src/hooks/useCarrierPhone.ts @@ -6,7 +6,7 @@ */ import { useCallback } from 'react'; import { fetchCarrierPhone } from '../api'; -import type { CarrierName } from '../types'; +import type { CarrierFailureReason, CarrierName } from '../types'; import { useAsyncData } from './useAsyncData'; export interface UseCarrierPhoneReturn { @@ -16,6 +16,11 @@ export interface UseCarrierPhoneReturn { carrierToken: string; /** 认证服务提供方(页脚「认证服务由 xx 提供」用) */ carrier: CarrierName; + /** + * 取号失败原因;成功时为 null。 + * 没开流量、超时、运营商不支持三者的引导动作不同,故显式传出而不是混成一句「失败了」。 + */ + failureReason: CarrierFailureReason | null; /** 号码是否已就绪 —— 页面据此决定「一键登录」可不可点 */ ready: boolean; /** 取号中 */ @@ -36,6 +41,7 @@ export function useCarrierPhone(): UseCarrierPhoneReturn { maskedPhone: data?.maskedPhone ?? '', carrierToken, carrier: data?.carrier ?? 'unknown', + failureReason: data?.failureReason ?? null, ready: carrierToken !== '', loading, error, diff --git a/src/lib/carrier-sdk.ts b/src/lib/carrier-sdk.ts new file mode 100644 index 0000000..2a2d8a7 --- /dev/null +++ b/src/lib/carrier-sdk.ts @@ -0,0 +1,177 @@ +import { Platform } from 'react-native'; +import type { CarrierFailureReason, CarrierNetworkInfo, CarrierSdkNumber } from '../types'; + +/* ===================== 运营商取号适配层(厂商无关) ===================== + * 把「从运营商网关取回本机号码」这一平台能力单独隔离,与 src/lib/wechat-sdk.ts 同构: + * 两条登录通道(微信 / 一键登录)的接入方式必须对称,否则后来者会直接在 api.ts 里写死。 + * + * 隔离的三个理由: + * 1. 取号是**原生模块**能力(运营商 SDK),Expo Go 内不存在,模拟器也没有 SIM 卡, + * mock 与真实的切换必须收在一处,不能散落到页面或接口层; + * 2. iOS / Android 两端 API 不同(Android getPhoneInfo / iOS getPhoneNumberCompletion), + * 差异应收敛在此文件; + * 3. 厂商未定。移动 / 联通 / 电信 / 阿里云 / 极光的返回值形状各不相同, + * 本文件对外只暴露统一形状(CarrierSdkNumber),**接入任何一家都只改这里**。 + * + * --------------------------------------------------------------------------- + * 真实接入步骤(开通服务后才做,现阶段不要动本文件的对外签名): + * 1. 开通号码认证服务,拿到 AppKey / AppSecret + 登记包名与签名 SHA256(Android)、 + * bundleId(iOS)。注意电信官方只覆盖电信号,要覆盖三网须选移动 / 联通 / 聚合方案。 + * 2. npx expo install <对应 RN 插件>,并用 EAS Build 出自定义客户端(Expo Go 跑不了)。 + * 3. 把下面 5 个 mock 函数体换成 SDK 调用,返回值映射到 CarrierSdkNumber: + * - isCarrierAuthAvailable → SDK 初始化结果 + * - getCarrierNetworkInfo → Android getNetworkType / iOS networkInfo + * - prefetchNumber → Android getPhoneInfo / iOS getPhoneNumberCompletion(只建缓存) + * - getCarrierNumber → 同上取号,取回 phone + 一次性 token + * - clearNumberCache → Android delScrip / iOS delectScrip + * 4. 上层(api.ts / hooks / 页面)零改动。 + * + * --------------------------------------------------------------------------- + * 取号的两个物理约束(来自运营商文档,不是我们的设计选择): + * - 必须蜂窝数据在线:取号走运营商数据网关,纯 Wi-Fi 取不到(SDK 会尝试瞬切蜂窝再切回, + * 但用户关闭流量时必然失败)→ 失败原因 'no-cellular'; + * - 有时延:SDK 默认超时 8s,弱信号下更长 → 必须做超时包装,否则页面会一直转圈。 + */ + +/** 取号失败(带机器可读的原因,供上层映射引导文案) */ +export class CarrierAuthError extends Error { + readonly reason: CarrierFailureReason; + + constructor(reason: CarrierFailureReason, message?: string) { + super(message ?? reason); + this.name = 'CarrierAuthError'; + this.reason = reason; + } +} + +/* ===================== mock 开关(仅开发期) ===================== + * 与 setMockWechatAccountStatus 同套路:默认给成功结果以打通页面链路, + * 需要验证失败分支时显式切换。**真实接入后整块删除。** + */ + +/** + * mock 阶段使用的本机号码,**整个仓库只有这一处定义**。 + * api.ts 的 loginByCarrier(模拟后端核验结果)也引用它,保证「取号展示的号」与 + * 「登录后写回资料的号」始终一致 —— 两处各写一份迟早会漂移成两个号。 + */ +export const MOCK_CARRIER_PHONE = '15000009367'; + +interface MockCarrierState { + /** 指定失败原因;null 表示走成功分支 */ + failure: CarrierFailureReason | null; + /** 挂起不返回,用于验证超时分支 */ + hang: boolean; + /** 取号序号:保证每次下发的令牌都不同 —— 真实令牌一次性,同毫秒撞号会让「不可复用」这条契约失效 */ + seq: number; + /** 缓存是否已建立(对应运营商的 scrip 缓存) */ + cacheReady: boolean; +} + +const mockState: MockCarrierState = { failure: null, hang: false, seq: 0, cacheReady: false }; + +/** 切换 mock 失败原因;传 null 恢复成功分支 */ +export function setMockCarrierFailure(reason: CarrierFailureReason | null): void { + mockState.failure = reason; +} + +/** 让 mock 取号永不返回,用于验证超时 */ +export function setMockCarrierHang(hang: boolean): void { + mockState.hang = hang; +} + +/* ===================== 5 个能力接口 ===================== */ + +/** + * 取号 SDK 是否可用。 + * 不可用时应直接改走验证码登录,不要让用户对着一个必然失败的按钮重试。 + */ +export function isCarrierAuthAvailable(): boolean { + // 真实 SDK 接入后改为返回初始化结果(如 NumberAuthSDK.isAvailable()) + return Platform.OS !== 'web'; +} + +/** + * 查询当前网络与流量卡运营商(对应 Android getNetworkType / iOS networkInfo)。 + * + * 用途是**取号前预检**:已经知道没开蜂窝网络,就没必要让用户干等 8 秒超时, + * 可以直接给出「请开启移动数据流量」的引导。 + */ +export function getCarrierNetworkInfo(): Promise { + // 真实实现见文件头注释;mock 阶段按当前 mock 状态给出一个可用的网络态 + if (mockState.failure === 'no-cellular') { + return Promise.resolve({ networkType: 'wifi', carrier: 'unknown' }); + } + return Promise.resolve({ networkType: 'cellular', carrier: 'cmcc' }); +} + +/** + * 预取号:只建立取号缓存(运营商称 scrip),不返回号码。 + * + * 缓存的价值(运营商文档明确):提高后续取号成功率、降低时延,且**允许用户在未开启 + * 蜂窝网络时成功取号**。所以理想时机是App 启动或进入登录页之前,而不是等用户点按钮。 + */ +export async function prefetchNumber(timeoutMs: number): Promise { + await guard(timeoutMs, async () => { + if (mockState.hang) { + return new Promise(() => undefined); + } + mockState.cacheReady = true; + }); +} + +/** + * 取号:返回本机号码与一次性鉴权令牌。 + * + * 令牌一次性且短时效(业界普遍 5 分钟),用后即废;换卡时运营商会让本地缓存提前失效。 + * + * @param timeoutMs 超时时间,传 CARRIER_SDK_TIMEOUT_MS + * @throws {CarrierAuthError} 取号失败,reason 供上层映射文案 + */ +export async function getCarrierNumber(timeoutMs: number): Promise { + return guard(timeoutMs, async () => { + if (mockState.hang) { + return new Promise(() => undefined); + } + if (mockState.failure) { + throw new CarrierAuthError(mockState.failure); + } + mockState.cacheReady = true; + // mock 号码:仅用于打通页面链路,真实接入后由 SDK 返回。 + // 注意**绝不能**在失败时返回这个号 —— 上层靠 phone/token 是否为空判定成败, + // 返回假号码会让失败分支永远进不去。 + mockState.seq += 1; + return { + phone: MOCK_CARRIER_PHONE, + token: `mock_carrier_token_${Date.now()}_${mockState.seq}`, + operator: 'CMCC', + }; + }); +} + +/** + * 清除取号缓存(对应 Android delScrip / iOS delectScrip)。 + * + * 运营商 SDK 本身会在换卡等场景让缓存自动失效,但对安全性要求高的动作 + * (注销账号、换绑手机号、退出登录)应主动清一次,避免下一次取号直接命中旧缓存。 + */ +export async function clearNumberCache(): Promise { + mockState.cacheReady = false; +} + +/* ===================== 内部:超时包装 ===================== */ + +/** + * 给取号调用套超时。 + * 不做这层包装时,弱信号下取号会无限挂起,页面停在「正在获取本机号码…」没有出口。 + */ +async function guard(timeoutMs: number, work: () => Promise): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new CarrierAuthError('timeout')), timeoutMs); + }); + try { + return await Promise.race([work(), timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} diff --git a/src/types/carrier-auth.ts b/src/types/carrier-auth.ts index eba2a6b..b6a7ebe 100644 --- a/src/types/carrier-auth.ts +++ b/src/types/carrier-auth.ts @@ -16,6 +16,51 @@ */ export type CarrierName = 'cmcc' | 'cucc' | 'ctcc' | 'unknown'; +/** + * 取号失败原因。 + * + * 之所以要显式建模而不是让页面统一显示「未能获取本机号码」:取号的失败原因直接决定 + * 用户下一步该干什么 —— 没开流量要他去开流量,运营商不支持就该直接换验证码登录, + * 这两件事的引导动作完全不同,混成一句话等于让用户自己猜。 + */ +export type CarrierFailureReason = + /** 客户端不含取号 SDK(如 Expo Go)或未完成初始化 */ + | 'sdk-unavailable' + /** 未开启蜂窝数据流量:取号走运营商数据网关,纯 Wi-Fi 下取不到 */ + | 'no-cellular' + /** 取号超时(SDK 默认 8s,弱信号/干扰时容易超) */ + | 'timeout' + /** 当前流量卡运营商不在已开通范围内 */ + | 'unsupported-carrier' + /** 其余未知失败 */ + | 'unknown'; + +/** 当前网络类型:取号要求蜂窝数据在线 */ +export type CarrierNetworkType = 'cellular' | 'wifi' | 'none' | 'unknown'; + +/** + * 网络与流量卡运营商信息(对应运营商 SDK 的 getNetworkType / networkInfo)。 + * 用于取号前预检:能提前知道「没开流量」就不必白等一次 8 秒超时。 + */ +export interface CarrierNetworkInfo { + networkType: CarrierNetworkType; + /** 流量卡所属运营商;取不到为 unknown */ + carrier: CarrierName; +} + +/** + * SDK 原始取号结果(适配层返回值,尚未脱敏、未归一化运营商)。 + * 归一化与脱敏属于业务规则,放在接口层,适配层只负责把厂商返回值原样搬出来。 + */ +export interface CarrierSdkNumber { + /** 本机号码(11 位);取号失败为 '' */ + phone: string; + /** 运营商下发的一次性鉴权令牌;取号失败为 '' */ + token: string; + /** 厂商返回的运营商原始标识(可能是中文名、英文缩写或数字码) */ + operator: string; +} + /** * 取号结果(运营商 SDK 的取号回调,对应 `GET/POST /api/auth/carrier`)。 * @@ -33,4 +78,9 @@ export interface CarrierPhoneResult { carrier: CarrierName; /** 令牌过期时间戳(毫秒);过期后需重新取号 */ expiresAt: number; + /** + * 取号失败原因;成功时为 null。 + * 成功/失败一律由 `carrierToken` 是否为空判定,该字段只用于给出针对性的重试引导。 + */ + failureReason: CarrierFailureReason | null; } diff --git a/src/types/index.ts b/src/types/index.ts index 627ac6b..d89c2ac 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -274,7 +274,14 @@ export type { WechatSession, WechatUserProfile, } from './wechat-auth'; -export type { CarrierName, CarrierPhoneResult } from './carrier-auth'; +export type { + CarrierName, + CarrierPhoneResult, + CarrierFailureReason, + CarrierNetworkType, + CarrierNetworkInfo, + CarrierSdkNumber, +} from './carrier-auth'; export type { OnboardingData, OnboardingProfile, diff --git a/运营商一键登录接入方案.md b/运营商一键登录接入方案.md index b066327..2dc9b88 100644 --- a/运营商一键登录接入方案.md +++ b/运营商一键登录接入方案.md @@ -1,16 +1,17 @@ # 运营商一键登录接入方案(P2Q) -> 状态:方案文档 · 不涉及代码改写 · 真实取号 SDK 就绪后按路线图替换 mock 即可 +> 状态:方案文档 · 真实取号 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` +> 关联文件:`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()` 返回的是写死的号码 `15000009367`(掩码 `150****9367`),用于把**页面链路**跑通。 -**接入真实取号 = 端上换 1 处 SDK 调用(`fetchCarrierPhone` 函数体) + 服务器换 1 个接口(`/api/auth/login-by-carrier`) + 用 EAS Build 生成自定义客户端。** 页面、hook、类型**零改动**。 +**在本机(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` **零改动**。 --- @@ -37,11 +38,12 @@ | 层 | 文件 | 现状 | 接入动作 | |---|---|---|---| -| 类型 | `src/types/carrier-auth.ts` | ✅ `CarrierPhoneResult` / `CarrierName` 已定义 | 无需改动 | -| 取号 hook | `src/hooks/useCarrierPhone.ts` | ✅ loading / error / refresh 已就绪 | 无需改动 | +| 类型 | `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/api/api.ts` · `fetchCarrierPhone()` | ⚠️ **mock**:写死 `15000009367` | **换运营商 SDK 调用(端上)** | +| **端上取号适配层** | `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` | @@ -85,23 +87,41 @@ eas build --profile development --platform ios - iOS 需要在 `app.config.js` 补运营商要求的隐私描述文案(`expo-build-properties` / `infoPlist`),Android 需确认运营商要求的权限未被裁剪。 ### Step 4 · 替换端上取号(唯一改动点 · 前端) -文件:`src/api/api.ts` 的 `fetchCarrierPhone()`,把 mock 返回换成 SDK 调用(注释里已写好变形写法): +文件:**`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) 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, -}; +if (!r?.token) throw new CarrierAuthError('unknown'); +return { phone: r.phone, token: r.token, operator: r.operator }; ``` -- **失败一律返回空串而不是假号码** —— 页面正是靠 `carrierToken === ''` 判定失败并置灰按钮,返回假号码会让失败分支永远进不去。 -- `maskPhone` / `carrierOf` 已在接口层就位,页面不做脱敏。 -- **此函数签名不变,页面与 hook 不动。** +- **失败一律抛 `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 · 实现后端换号接口 | 接口 | 方法 | 职责 | 关键返回 | @@ -113,8 +133,9 @@ return { - 该接口同时也是「登录即注册」入口:号码在库中不存在时应自动建档(与验证码通道一致)。 ### Step 6 · 清除 mock 残留 -- 删除 `MOCK_CARRIER_PHONE` / `MOCK_CARRIER_NAME` 与 `fetchCarrierPhone` 的 mock 分支。 -- `src/__tests__/carrier-login.test.ts` 中断言「mock 号码」的用例改为断言**失败分支**(空令牌 reject、空号码不置 `ready`),这部分在真实取号后依然是有效契约。 +- 删除 `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),这部分在真实取号后依然是有效契约。 --- @@ -143,4 +164,4 @@ return { ## 7. 一句话给执行者 -**别动页面、hook、类型。** 只动两处:`fetchCarrierPhone()`(换端上 SDK 调用,失败返回空串)和 `loginByCarrier()`(换真接口,号码以后端为准),配齐运营商号码认证服务 + EAS 自定义客户端,一键登录即可真机走通。 +**别动页面、hook、类型、`api.ts`。** 只动两处:`src/lib/carrier-sdk.ts` 的 5 个函数体(换端上 SDK 调用,失败抛 `CarrierAuthError` 或返回空串)和 `loginByCarrier()`(换真接口,号码以后端为准),配齐运营商号码认证服务 + EAS 自定义客户端,一键登录即可真机走通。