Browse Source
把端上取号从 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。master
8 changed files with 499 additions and 46 deletions
@ -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'); |
||||
|
}); |
||||
|
}); |
||||
@ -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<CarrierNetworkInfo> { |
||||
|
// 真实实现见文件头注释;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<void> { |
||||
|
await guard(timeoutMs, async () => { |
||||
|
if (mockState.hang) { |
||||
|
return new Promise<void>(() => undefined); |
||||
|
} |
||||
|
mockState.cacheReady = true; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 取号:返回本机号码与一次性鉴权令牌。 |
||||
|
* |
||||
|
* 令牌一次性且短时效(业界普遍 5 分钟),用后即废;换卡时运营商会让本地缓存提前失效。 |
||||
|
* |
||||
|
* @param timeoutMs 超时时间,传 CARRIER_SDK_TIMEOUT_MS |
||||
|
* @throws {CarrierAuthError} 取号失败,reason 供上层映射文案 |
||||
|
*/ |
||||
|
export async function getCarrierNumber(timeoutMs: number): Promise<CarrierSdkNumber> { |
||||
|
return guard(timeoutMs, async () => { |
||||
|
if (mockState.hang) { |
||||
|
return new Promise<CarrierSdkNumber>(() => 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<void> { |
||||
|
mockState.cacheReady = false; |
||||
|
} |
||||
|
|
||||
|
/* ===================== 内部:超时包装 ===================== */ |
||||
|
|
||||
|
/** |
||||
|
* 给取号调用套超时。 |
||||
|
* 不做这层包装时,弱信号下取号会无限挂起,页面停在「正在获取本机号码…」没有出口。 |
||||
|
*/ |
||||
|
async function guard<T>(timeoutMs: number, work: () => Promise<T>): Promise<T> { |
||||
|
let timer: ReturnType<typeof setTimeout> | undefined; |
||||
|
const timeout = new Promise<never>((_resolve, reject) => { |
||||
|
timer = setTimeout(() => reject(new CarrierAuthError('timeout')), timeoutMs); |
||||
|
}); |
||||
|
try { |
||||
|
return await Promise.race([work(), timeout]); |
||||
|
} finally { |
||||
|
if (timer !== undefined) clearTimeout(timer); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue