Browse Source

feat(auth): 隔离运营商取号适配层,补齐取号超时/网络预检/缓存管理

把端上取号从 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
doublex001 1 month ago
parent
commit
c16ce63039
  1. 138
      src/__tests__/carrier-sdk.test.ts
  2. 75
      src/api/api.ts
  3. 21
      src/constants/index.ts
  4. 8
      src/hooks/useCarrierPhone.ts
  5. 177
      src/lib/carrier-sdk.ts
  6. 50
      src/types/carrier-auth.ts
  7. 9
      src/types/index.ts
  8. 65
      运营商一键登录接入方案.md

138
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');
});
});

75
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 步:取号)。
*
* 真实实现在**端上**完成,不经过我们的服务器:需接入运营商号码认证 SDK
* (中国移动/联通/电信,或阿里云号码认证服务等聚合方案)。它是原生模块,
* Expo Go 跑不了,必须 dev build / 出包 —— 详见 运营商一键登录接入方案.md。
* 真实实现在**端上**完成,不经过我们的服务器:走 src/lib/carrier-sdk.ts 调运营商号码认证
* SDK(原生模块,Expo Go 跑不了,必须 dev build / 出包 —— 详见 运营商一键登录接入方案.md)。
*
* 这里只做三件属于业务规则的事:套超时、归一化运营商、脱敏。取号能力本身不在本文件。
*
* @method POST
* @url /api/auth/carrier/number
* @returns Promise<CarrierPhoneResult> 本机号码 + 掩码 + 一次性鉴权令牌;取号失败时号码与令牌为空串
*/
export function fetchCarrierPhone(): Promise<CarrierPhoneResult> {
// 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<CarrierPhoneResult>({
phone: MOCK_CARRIER_PHONE,
maskedPhone: maskPhone(MOCK_CARRIER_PHONE),
carrierToken: `mock_carrier_token_${Date.now()}`,
carrier: MOCK_CARRIER_NAME,
export async function fetchCarrierPhone(): Promise<CarrierPhoneResult> {
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',
);
}
}
/**

21
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<CarrierName, string> = {
unknown: '运营商',
};
/**
* 取号失败原因 → 引导文案。
* 文案必须写明**用户下一步能做什么**,只说「失败了」没有意义。
*/
export const CARRIER_FAILURE_LABEL: Record<CarrierFailureReason, string> = {
'sdk-unavailable': '当前客户端不支持一键登录,请改用验证码登录',
'no-cellular': '取号需要移动数据流量,请开启后重新获取',
timeout: '取号超时,请检查网络后重新获取',
'unsupported-carrier': '当前运营商暂不支持一键登录,请改用验证码登录',
unknown: '未能获取本机号码,可重新获取或改用验证码登录',
};
/* ===================== 登录密码规则 ===================== */
/** 登录密码最小长度(含) */

8
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,

177
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<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);
}
}

50
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;
}

9
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,

65
运营商一键登录接入方案.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 自定义客户端,一键登录即可真机走通。

Loading…
Cancel
Save