Browse Source
落实《职来后端接口联调说明》(job.youthome.com.cn 规范)的前端地基: - 新建 src/api/http.ts 统一请求层:Base URL 从 env 读取(默认生产 HTTPS 域名)、 Authorization: Bearer 注入、信封 code/message/data/requestId/timestamp 解析、401 单飞 刷新一次并重放、X-Request-Id 透传、HTTP 状态码语义映射(400/401/403/404/409/429/ 500/503)。 - 新建 src/lib/token-store.ts:access+refresh 双 Token 内存单例 + AsyncStorage 持久化, 沿用 qingsongpin: 前缀(已更正旧记忆的 youzhi: 误记)。 - 新建 src/api/auth.ts 真实鉴权端点:sendSmsCode / loginByPassword / loginBySms / fetchCurrentUser / requestLogout / registerCompany,并加 mapBackendRole (STUDENT→candidate、COMPANY→enterprise、ADMIN→admin)。 - AuthContext 扩 refreshToken 字段、setSession(access,refresh,expiresAt)、logout 清 Token、 启动 hydrateTokens 并注册 401 清会话回调。 - AppRole 扩 admin;删除 api.ts 旧 mock 的 loginByPassword/sendSmsCode(旧端点与规范不符), 归一到 auth.ts。P2PasswordLogin 登录后 setSession 双 Token 并调 /auth/me 权威化角色。 - app.config.js 默认 apiBaseUrl 改为 https://job.youthome.com.cn/api/v1(保留环境变量覆盖)。 业务端点(jobs/profile 等 100+)仍走 mock,下一阶段按 OpenAPI 逐个换 request() 即可。 规范 §1–§10 逐条对照见 后端联调前端实现.md。 tsc 0 错误;jest 398 passed / 28 suites。master
16 changed files with 971 additions and 87 deletions
@ -0,0 +1,252 @@ |
|||||
|
import { |
||||
|
request, |
||||
|
checkHealth, |
||||
|
ApiError, |
||||
|
API_BASE_URL, |
||||
|
setUnauthorizedHandler, |
||||
|
} from '../api/http'; |
||||
|
import { mapBackendRole } from '../api/auth'; |
||||
|
import { setTokens, clearTokens, getAccessToken } from '../lib/token-store'; |
||||
|
|
||||
|
// expo-constants:extra.apiBaseUrl 为空 → 走默认生产域名
|
||||
|
jest.mock('expo-constants', () => ({ |
||||
|
expoConfig: { extra: {} }, |
||||
|
})); |
||||
|
|
||||
|
// token-store:用可控内存双 Token,避免依赖 AsyncStorage
|
||||
|
jest.mock('../lib/token-store', () => { |
||||
|
let mem: { accessToken: string; refreshToken: string; expiresAt?: number } | null = null; |
||||
|
return { |
||||
|
getAccessToken: jest.fn(() => mem?.accessToken ?? null), |
||||
|
getTokens: jest.fn(() => mem), |
||||
|
setTokens: jest.fn((a: string, r: string, e?: number) => { |
||||
|
mem = { accessToken: a, refreshToken: r, expiresAt: e }; |
||||
|
}), |
||||
|
clearTokens: jest.fn(() => { |
||||
|
mem = null; |
||||
|
}), |
||||
|
hydrateTokens: jest.fn(async () => undefined), |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
interface MockResponseSpec { |
||||
|
status: number; |
||||
|
body: unknown; |
||||
|
} |
||||
|
|
||||
|
type Responder = (url: string, init: RequestInit) => MockResponseSpec; |
||||
|
|
||||
|
let responder: Responder | null = null; |
||||
|
let fetchCalls: Array<{ url: string; init: RequestInit }> = []; |
||||
|
|
||||
|
function installFetch(): void { |
||||
|
(global as unknown as { fetch: jest.Mock }).fetch = jest.fn( |
||||
|
async (url: string, init: RequestInit) => { |
||||
|
fetchCalls.push({ url, init }); |
||||
|
const spec = responder |
||||
|
? responder(url, init) |
||||
|
: { status: 200, body: { code: 200, message: 'ok', data: {}, requestId: 'rid', timestamp: 't' } }; |
||||
|
const text = |
||||
|
typeof spec.body === 'string' ? spec.body : JSON.stringify(spec.body); |
||||
|
return { |
||||
|
ok: spec.status >= 200 && spec.status < 300, |
||||
|
status: spec.status, |
||||
|
headers: { get: (k: string) => (k === 'X-Request-Id' ? 'rid' : null) }, |
||||
|
text: async () => text, |
||||
|
}; |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
function ok(data: unknown): MockResponseSpec { |
||||
|
return { status: 200, body: { code: 200, message: 'ok', data, requestId: 'rid', timestamp: 't' } }; |
||||
|
} |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
fetchCalls = []; |
||||
|
responder = null; |
||||
|
installFetch(); |
||||
|
(clearTokens as jest.Mock)(); |
||||
|
setUnauthorizedHandler(() => undefined); |
||||
|
}); |
||||
|
|
||||
|
describe('API_BASE_URL(规范 §1)', () => { |
||||
|
it('默认生产 HTTPS 域名,路径不在此重复拼 /api/v1', () => { |
||||
|
expect(API_BASE_URL).toBe('https://job.youthome.com.cn/api/v1'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('请求信封与鉴权头(规范 §3)', () => { |
||||
|
it('成功时返回 data,并自动带 Bearer(已登录)', async () => { |
||||
|
(setTokens as jest.Mock)('access-1', 'refresh-1'); |
||||
|
responder = () => ok({ id: 'u1', role: 'STUDENT' }); |
||||
|
|
||||
|
const data = await request<{ id: string }>('/auth/me', { method: 'GET' }); |
||||
|
|
||||
|
expect(data.id).toBe('u1'); |
||||
|
const last = fetchCalls[fetchCalls.length - 1]; |
||||
|
expect(last.url).toContain('/auth/me'); |
||||
|
expect(last.init.headers).toMatchObject({ Authorization: 'Bearer access-1' }); |
||||
|
}); |
||||
|
|
||||
|
it('匿名接口(auth:false)不带 Authorization', async () => { |
||||
|
responder = () => ok({ accessToken: 'A', refreshToken: 'R' }); |
||||
|
|
||||
|
await request('/auth/password/login', { method: 'POST', auth: false, body: {} }); |
||||
|
|
||||
|
const last = fetchCalls[fetchCalls.length - 1]; |
||||
|
expect((last.init.headers as Record<string, string>).Authorization).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('每个请求都带 X-Request-Id 请求头', async () => { |
||||
|
responder = () => ok({}); |
||||
|
await request('/health', { method: 'GET', auth: false }); |
||||
|
const last = fetchCalls[fetchCalls.length - 1]; |
||||
|
expect((last.init.headers as Record<string, string>)['X-Request-Id']).toMatch(/^app-/); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('信封 code 校验(规范 §3)', () => { |
||||
|
it('code !== 200 抛 ApiError 且携带 business code 与 requestId', async () => { |
||||
|
responder = () => ({ status: 200, body: { code: 4001, message: '业务失败', data: null, requestId: 'rid', timestamp: 't' } }); |
||||
|
try { |
||||
|
await request('/x', { method: 'GET', auth: false }); |
||||
|
throw new Error('不应成功'); |
||||
|
} catch (e) { |
||||
|
expect(e).toBeInstanceOf(ApiError); |
||||
|
expect((e as ApiError).code).toBe(4001); |
||||
|
expect((e as ApiError).httpStatus).toBe(200); |
||||
|
expect((e as ApiError).requestId).toBe('rid'); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('HTTP 非 2xx 且信封带 message 时,优先用后端 message(更具体)', async () => { |
||||
|
responder = () => ({ status: 404, body: { code: 404, message: '职位不存在', data: null, requestId: 'rid', timestamp: 't' } }); |
||||
|
try { |
||||
|
await request('/missing', { method: 'GET', auth: false }); |
||||
|
throw new Error('不应成功'); |
||||
|
} catch (e) { |
||||
|
expect(e).toBeInstanceOf(ApiError); |
||||
|
expect((e as ApiError).httpStatus).toBe(404); |
||||
|
expect((e as ApiError).message).toBe('职位不存在'); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('HTTP 非 2xx 且信封无 message 时,回退到状态码映射文案(§8)', async () => { |
||||
|
responder = () => ({ status: 404, body: '' }); |
||||
|
try { |
||||
|
await request('/missing', { method: 'GET', auth: false }); |
||||
|
throw new Error('不应成功'); |
||||
|
} catch (e) { |
||||
|
expect(e).toBeInstanceOf(ApiError); |
||||
|
expect((e as ApiError).httpStatus).toBe(404); |
||||
|
expect((e as ApiError).message).toBe('请求的资源不存在'); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('401 单飞刷新 + 重放(规范 §4)', () => { |
||||
|
it('Access Token 过期:一次刷新成功后重放原请求并返回 data', async () => { |
||||
|
(setTokens as jest.Mock)('old-access', 'refresh-1'); |
||||
|
// 前 1 次保护接口返回 401,刷新接口返回新 Token,重放返回 200
|
||||
|
let calls = 0; |
||||
|
responder = (url: string) => { |
||||
|
if (url.includes('/auth/refresh')) { |
||||
|
return ok({ accessToken: 'new-access', refreshToken: 'refresh-2' }); |
||||
|
} |
||||
|
calls += 1; |
||||
|
if (calls === 1) return { status: 401, body: { code: 401, message: 'expired', data: null, requestId: 'rid', timestamp: 't' } }; |
||||
|
return ok({ id: 'u1', role: 'STUDENT' }); |
||||
|
}; |
||||
|
|
||||
|
const data = await request<{ id: string }>('/auth/me', { method: 'GET' }); |
||||
|
expect(data.id).toBe('u1'); |
||||
|
// 只刷新了一次
|
||||
|
const refreshCalls = fetchCalls.filter((c) => c.url.includes('/auth/refresh')); |
||||
|
expect(refreshCalls).toHaveLength(1); |
||||
|
// 刷新后内存 Token 已更新
|
||||
|
expect((getAccessToken as jest.Mock)()).toBe('new-access'); |
||||
|
}); |
||||
|
|
||||
|
it('并发 401 只触发一次刷新(多标签并发等价物)', async () => { |
||||
|
(setTokens as jest.Mock)('old-access', 'refresh-1'); |
||||
|
let protectedCalls = 0; |
||||
|
responder = (url: string) => { |
||||
|
if (url.includes('/auth/refresh')) { |
||||
|
return ok({ accessToken: 'new-access', refreshToken: 'refresh-2' }); |
||||
|
} |
||||
|
protectedCalls += 1; |
||||
|
// 前 3 次(并发)都 401,之后重放成功
|
||||
|
if (protectedCalls <= 3) return { status: 401, body: { code: 401, message: 'expired', data: null, requestId: 'rid', timestamp: 't' } }; |
||||
|
return ok({ ok: true }); |
||||
|
}; |
||||
|
|
||||
|
const results = await Promise.all([ |
||||
|
request('/a', { method: 'GET' }), |
||||
|
request('/b', { method: 'GET' }), |
||||
|
request('/c', { method: 'GET' }), |
||||
|
]); |
||||
|
expect(results).toHaveLength(3); |
||||
|
const refreshCalls = fetchCalls.filter((c) => c.url.includes('/auth/refresh')); |
||||
|
expect(refreshCalls).toHaveLength(1); |
||||
|
}); |
||||
|
|
||||
|
it('刷新也失败:清 Token、调用 unauthorizedHandler、抛 ApiError', async () => { |
||||
|
(setTokens as jest.Mock)('old-access', 'refresh-1'); |
||||
|
let handlerCalled = false; |
||||
|
setUnauthorizedHandler(() => { |
||||
|
handlerCalled = true; |
||||
|
}); |
||||
|
responder = (url: string) => { |
||||
|
if (url.includes('/auth/refresh')) { |
||||
|
return { status: 401, body: { code: 401, message: 'bad refresh', data: null, requestId: 'rid', timestamp: 't' } }; |
||||
|
} |
||||
|
return { status: 401, body: { code: 401, message: 'expired', data: null, requestId: 'rid', timestamp: 't' } }; |
||||
|
}; |
||||
|
|
||||
|
await expect(request('/a', { method: 'GET' })).rejects.toBeInstanceOf(ApiError); |
||||
|
expect(handlerCalled).toBe(true); |
||||
|
expect((getAccessToken as jest.Mock)()).toBeNull(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('网络层异常(规范 §8 / §10 降级)', () => { |
||||
|
it('fetch 抛错时降级为网络异常,不回吐 Token', async () => { |
||||
|
(global as unknown as { fetch: jest.Mock }).fetch = jest.fn(async () => { |
||||
|
throw new Error('network down'); |
||||
|
}); |
||||
|
try { |
||||
|
await request('/a', { method: 'GET', auth: false }); |
||||
|
throw new Error('不应成功'); |
||||
|
} catch (e) { |
||||
|
expect(e).toBeInstanceOf(ApiError); |
||||
|
expect((e as ApiError).httpStatus).toBe(0); |
||||
|
expect((e as ApiError).message).toContain('网络'); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('checkHealth(规范 §1)', () => { |
||||
|
it('UP 返回 true', async () => { |
||||
|
responder = () => ({ status: 200, body: { code: 200, message: 'ok', data: { status: 'UP' }, requestId: 'rid', timestamp: 't' } }); |
||||
|
expect(await checkHealth()).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('非 UP 或异常返回 false', async () => { |
||||
|
responder = () => ({ status: 200, body: { code: 200, message: 'ok', data: { status: 'DOWN' }, requestId: 'rid', timestamp: 't' } }); |
||||
|
expect(await checkHealth()).toBe(false); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('mapBackendRole(规范 §5 角色映射)', () => { |
||||
|
it('后端角色映射到应用内部角色', () => { |
||||
|
expect(mapBackendRole('STUDENT')).toBe('candidate'); |
||||
|
expect(mapBackendRole('COMPANY')).toBe('enterprise'); |
||||
|
expect(mapBackendRole('ADMIN')).toBe('admin'); |
||||
|
}); |
||||
|
|
||||
|
it('认不出来时降级为 candidate,不臆造身份', () => { |
||||
|
expect(mapBackendRole('UNKNOWN')).toBe('candidate'); |
||||
|
expect(mapBackendRole('')).toBe('candidate'); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,135 @@ |
|||||
|
import { request } from './http'; |
||||
|
import type { AppRole } from '../types'; |
||||
|
|
||||
|
/* ===================== 鉴权接口(真实后端) ===================== |
||||
|
* 落实规范第 4、5、6 节。全部走 http 层;匿名接口显式 auth:false。 |
||||
|
* 客户端不提交 role / userId / verified / passwordHash(后端生成)。 |
||||
|
*/ |
||||
|
|
||||
|
/** 后端下发的角色值(规范 §5) */ |
||||
|
export type BackendRole = 'STUDENT' | 'COMPANY' | 'ADMIN'; |
||||
|
|
||||
|
/** |
||||
|
* 后端角色 → 应用内部角色(§5:以 /auth/me 返回为准)。 |
||||
|
* STUDENT→candidate(求职者)、COMPANY→enterprise(企业)、ADMIN→admin(管理员)。 |
||||
|
* 认不出来时降级为 candidate,不臆造身份。 |
||||
|
*/ |
||||
|
export function mapBackendRole(role: string): AppRole { |
||||
|
switch (role) { |
||||
|
case 'STUDENT': |
||||
|
return 'candidate'; |
||||
|
case 'COMPANY': |
||||
|
return 'enterprise'; |
||||
|
case 'ADMIN': |
||||
|
return 'admin'; |
||||
|
default: |
||||
|
return 'candidate'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export interface AuthTokens { |
||||
|
accessToken: string; |
||||
|
refreshToken: string; |
||||
|
/** accessToken 过期时间戳(毫秒),后端可能不返回 */ |
||||
|
expiresAt?: number; |
||||
|
} |
||||
|
|
||||
|
export interface SmsSendPayload { |
||||
|
phone: string; |
||||
|
/** purpose 必须使用后端支持的固定值,如 login / register / company-register */ |
||||
|
purpose: string; |
||||
|
} |
||||
|
|
||||
|
/** 客户端手机号格式预检(与后端保持一致;无效输入直接本地 reject,不发请求) */ |
||||
|
function isValidPhone(phone: string): boolean { |
||||
|
return /^1[3-9]\d{9}$/.test(phone); |
||||
|
} |
||||
|
|
||||
|
/** 发送短信验证码(§4 步骤1 / §6 步骤1) */ |
||||
|
export async function sendSmsCode(payload: SmsSendPayload): Promise<void> { |
||||
|
if (!isValidPhone(payload.phone)) { |
||||
|
throw new Error('手机号格式不正确'); |
||||
|
} |
||||
|
await request('/auth/sms/send', { method: 'POST', auth: false, body: payload }); |
||||
|
} |
||||
|
|
||||
|
export interface PasswordLoginPayload { |
||||
|
phone: string; |
||||
|
password: string; |
||||
|
} |
||||
|
|
||||
|
/** 手机号 + 密码登录(§4 步骤2A):成功后返回双 Token */ |
||||
|
export async function loginByPassword(payload: PasswordLoginPayload): Promise<AuthTokens> { |
||||
|
if (!isValidPhone(payload.phone)) { |
||||
|
throw new Error('手机号格式不正确'); |
||||
|
} |
||||
|
return request<AuthTokens>('/auth/password/login', { |
||||
|
method: 'POST', |
||||
|
auth: false, |
||||
|
body: payload, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
export interface SmsLoginPayload { |
||||
|
phone: string; |
||||
|
code: string; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 短信验证码登录(§4 步骤2B)。 |
||||
|
* 注意:接口文档标注为 Mock 登录,生产前端不要作为正式入口, |
||||
|
* 除非产品和后端明确确认。保留实现以便联调与兜底。 |
||||
|
*/ |
||||
|
export async function loginBySms(payload: SmsLoginPayload): Promise<AuthTokens> { |
||||
|
return request<AuthTokens>('/auth/login', { method: 'POST', auth: false, body: payload }); |
||||
|
} |
||||
|
|
||||
|
export interface CurrentUser { |
||||
|
id: string; |
||||
|
/** 已由后端角色映射为应用内部角色 */ |
||||
|
role: AppRole; |
||||
|
/** 其余身份字段按 OpenAPI 透传 */ |
||||
|
[key: string]: unknown; |
||||
|
} |
||||
|
|
||||
|
/** 拉取当前用户与角色(§4 步骤3):角色以后端为准并映射 */ |
||||
|
export async function fetchCurrentUser(): Promise<CurrentUser> { |
||||
|
const data = await request<{ id: string; role: BackendRole } & Record<string, unknown>>( |
||||
|
'/auth/me', |
||||
|
{ method: 'GET' }, |
||||
|
); |
||||
|
return { ...data, role: mapBackendRole(data.role) }; |
||||
|
} |
||||
|
|
||||
|
/** 退出登录(§4 步骤5):前端清会话由 AuthContext 负责,这里只通知后端 */ |
||||
|
export async function requestLogout(): Promise<void> { |
||||
|
await request('/auth/logout', { method: 'POST' }); |
||||
|
} |
||||
|
|
||||
|
export interface CompanyRegisterPayload { |
||||
|
phone: string; |
||||
|
code: string; |
||||
|
password: string; |
||||
|
company: { |
||||
|
name: string; |
||||
|
industry: string; |
||||
|
description: string; |
||||
|
website: string; |
||||
|
address: string; |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 企业专用注册(§6):成功直接返回 COMPANY 双 Token。 |
||||
|
* 客户端不能提交 userId / role / verified / passwordHash(后端生成)。 |
||||
|
*/ |
||||
|
export async function registerCompany(payload: CompanyRegisterPayload): Promise<AuthTokens> { |
||||
|
if (!isValidPhone(payload.phone)) { |
||||
|
throw new Error('手机号格式不正确'); |
||||
|
} |
||||
|
return request<AuthTokens>('/auth/company/register', { |
||||
|
method: 'POST', |
||||
|
auth: false, |
||||
|
body: payload, |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,248 @@ |
|||||
|
import Constants from 'expo-constants'; |
||||
|
import { |
||||
|
getAccessToken, |
||||
|
getTokens, |
||||
|
setTokens, |
||||
|
clearTokens, |
||||
|
} from '../lib/token-store'; |
||||
|
|
||||
|
/* ===================== 统一请求层 ===================== |
||||
|
* 所有真实后端请求的唯一出口。mock 阶段业务函数仍走各自 mock, |
||||
|
* 但鉴权相关函数(auth.ts)与未来转真实的业务函数都从本文件走。 |
||||
|
* |
||||
|
* 落实的规范条款: |
||||
|
* - §1 业务基地址从 expo-constants.extra.apiBaseUrl 读取,默认生产域名 |
||||
|
* - §3 请求头 Authorization: Bearer <accessToken>;响应外层 code/message/data/requestId/timestamp |
||||
|
* - §4 access + refresh 双 Token;401 单飞刷新一次并重放;刷新失败清会话 |
||||
|
* - §3 X-Request-Id 生成并随响应回传(联调报障用) |
||||
|
* - §8 HTTP 状态码语义映射到 ApiError |
||||
|
* - §10 Token 不进日志 / URL / DOM(本层仅在内部使用,不向调用方回吐原始 Token) |
||||
|
*/ |
||||
|
|
||||
|
/** 业务基地址;接口路径只写相对路径,不在此重复拼 /api/v1 */ |
||||
|
export const API_BASE_URL: string = |
||||
|
Constants.expoConfig?.extra?.apiBaseUrl ?? 'https://job.youthome.com.cn/api/v1'; |
||||
|
|
||||
|
/** 后端响应外层结构(§3) */ |
||||
|
export interface ApiEnvelope<T> { |
||||
|
code: number; |
||||
|
message: string; |
||||
|
data: T; |
||||
|
requestId: string; |
||||
|
timestamp: string; |
||||
|
} |
||||
|
|
||||
|
/** 统一错误:携带 HTTP 状态、业务 code、requestId,便于联调报障 */ |
||||
|
export interface ApiErrorDetail { |
||||
|
httpStatus: number; |
||||
|
code: number; |
||||
|
message: string; |
||||
|
requestId?: string; |
||||
|
} |
||||
|
|
||||
|
export class ApiError extends Error { |
||||
|
readonly httpStatus: number; |
||||
|
readonly code: number; |
||||
|
readonly requestId?: string; |
||||
|
|
||||
|
constructor(detail: ApiErrorDetail) { |
||||
|
super(detail.message || `请求失败(${detail.httpStatus})`); |
||||
|
this.name = 'ApiError'; |
||||
|
this.httpStatus = detail.httpStatus; |
||||
|
this.code = detail.code; |
||||
|
this.requestId = detail.requestId; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; |
||||
|
|
||||
|
export interface RequestOptions { |
||||
|
method?: HttpMethod; |
||||
|
/** 请求体;非 GET 且传入时 JSON 序列化 */ |
||||
|
body?: unknown; |
||||
|
/** 是否携带 Bearer;匿名接口(登录 / 短信 / 刷新自身)显式传 false */ |
||||
|
auth?: boolean; |
||||
|
/** 超时(毫秒),默认 10000 */ |
||||
|
timeoutMs?: number; |
||||
|
} |
||||
|
|
||||
|
/** 401 清会话回调:由 AuthContext 注册,刷新失败 / 无 refreshToken 时触发 */ |
||||
|
let unauthorizedHandler: (() => void) | null = null; |
||||
|
export function setUnauthorizedHandler(handler: () => void): void { |
||||
|
unauthorizedHandler = handler; |
||||
|
} |
||||
|
|
||||
|
/** 单飞刷新闸门:并发 401 只触发一次刷新(§4 多标签并发互斥的移动端等价物) */ |
||||
|
let refreshPromise: Promise<boolean> | null = null; |
||||
|
|
||||
|
function genRequestId(): string { |
||||
|
// 规范示例为 web-20260922-0001;移动端用短随机串,降低可读性暴露风险
|
||||
|
return `app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; |
||||
|
} |
||||
|
|
||||
|
function isEnvelope<T>(value: unknown): value is ApiEnvelope<T> { |
||||
|
return ( |
||||
|
typeof value === 'object' && |
||||
|
value !== null && |
||||
|
'code' in value && |
||||
|
'data' in value |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
interface InternalOptions extends RequestOptions { |
||||
|
/** 标记这是刷新请求本身,避免 401 时递归刷新 */ |
||||
|
_isRefresh?: boolean; |
||||
|
} |
||||
|
|
||||
|
async function doRefresh(): Promise<boolean> { |
||||
|
if (refreshPromise) return refreshPromise; |
||||
|
refreshPromise = (async () => { |
||||
|
try { |
||||
|
const tokens = getTokens(); |
||||
|
if (!tokens?.refreshToken) { |
||||
|
await clearTokens(); |
||||
|
unauthorizedHandler?.(); |
||||
|
return false; |
||||
|
} |
||||
|
const result = await rawRequest<{ |
||||
|
accessToken: string; |
||||
|
refreshToken: string; |
||||
|
expiresAt?: number; |
||||
|
}>('/auth/refresh', { |
||||
|
method: 'POST', |
||||
|
body: { refreshToken: tokens.refreshToken }, |
||||
|
auth: false, |
||||
|
_isRefresh: true, |
||||
|
}); |
||||
|
await setTokens(result.accessToken, result.refreshToken, result.expiresAt); |
||||
|
return true; |
||||
|
} catch { |
||||
|
await clearTokens(); |
||||
|
unauthorizedHandler?.(); |
||||
|
return false; |
||||
|
} finally { |
||||
|
refreshPromise = null; |
||||
|
} |
||||
|
})(); |
||||
|
return refreshPromise; |
||||
|
} |
||||
|
|
||||
|
async function rawRequest<T>(path: string, opts: InternalOptions): Promise<T> { |
||||
|
const url = `${API_BASE_URL}${path}`; |
||||
|
const requestId = genRequestId(); |
||||
|
const headers: Record<string, string> = { |
||||
|
'Content-Type': 'application/json', |
||||
|
'X-Request-Id': requestId, |
||||
|
}; |
||||
|
const needAuth = opts.auth !== false; |
||||
|
if (needAuth) { |
||||
|
const token = getAccessToken(); |
||||
|
if (token) headers['Authorization'] = `Bearer ${token}`; |
||||
|
} |
||||
|
|
||||
|
const controller = new AbortController(); |
||||
|
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 10000); |
||||
|
let response: Response; |
||||
|
try { |
||||
|
response = await fetch(url, { |
||||
|
method: opts.method ?? 'GET', |
||||
|
headers, |
||||
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, |
||||
|
signal: controller.signal, |
||||
|
}); |
||||
|
} catch (err) { |
||||
|
clearTimeout(timer); |
||||
|
// 网络层异常(无网 / 超时 / 证书):对应 §8 的 503 类,提示稍后重试
|
||||
|
const aborted = err instanceof Error && err.name === 'AbortError'; |
||||
|
throw new ApiError({ |
||||
|
httpStatus: 0, |
||||
|
code: 0, |
||||
|
message: aborted ? '请求超时,请稍后重试' : '网络异常,请检查网络连接', |
||||
|
}); |
||||
|
} |
||||
|
clearTimeout(timer); |
||||
|
|
||||
|
const resRequestId = response.headers.get('X-Request-Id') ?? requestId; |
||||
|
const rawText = await response.text(); |
||||
|
const parsed: unknown = rawText ? safeJsonParse(rawText) : null; |
||||
|
|
||||
|
// 401 处理(§4):仅对带鉴权的普通请求触发刷新,刷新自身不递归
|
||||
|
if (response.status === 401 && needAuth && !opts._isRefresh) { |
||||
|
const refreshed = await doRefresh(); |
||||
|
if (refreshed) { |
||||
|
return rawRequest<T>(path, { ...opts, _isRefresh: true }); |
||||
|
} |
||||
|
throw new ApiError({ |
||||
|
httpStatus: 401, |
||||
|
code: isEnvelope<unknown>(parsed) ? parsed.code : 401, |
||||
|
message: '登录已失效,请重新登录', |
||||
|
requestId: resRequestId, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// HTTP 状态码优先(§8)
|
||||
|
if (!response.ok) { |
||||
|
const msg = isEnvelope<unknown>(parsed) |
||||
|
? parsed.message |
||||
|
: pickHttpMessage(response.status); |
||||
|
throw new ApiError({ |
||||
|
httpStatus: response.status, |
||||
|
code: isEnvelope<unknown>(parsed) ? parsed.code : response.status, |
||||
|
message: msg, |
||||
|
requestId: resRequestId, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 信封 code 校验(§3):后端约定 code=200 表示成功
|
||||
|
if (isEnvelope<T>(parsed) && parsed.code !== 200) { |
||||
|
throw new ApiError({ |
||||
|
httpStatus: response.status, |
||||
|
code: parsed.code, |
||||
|
message: parsed.message || '业务处理失败', |
||||
|
requestId: resRequestId, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 返回 data;无信封时回退为空对象(部分接口无 data 字段)
|
||||
|
if (isEnvelope<T>(parsed)) return parsed.data; |
||||
|
return (parsed as T) ?? ({} as T); |
||||
|
} |
||||
|
|
||||
|
function safeJsonParse(text: string): unknown { |
||||
|
try { |
||||
|
return JSON.parse(text); |
||||
|
} catch { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function pickHttpMessage(status: number): string { |
||||
|
switch (status) { |
||||
|
case 400: return '请求参数有误'; |
||||
|
case 403: return '没有权限执行此操作'; |
||||
|
case 404: return '请求的资源不存在'; |
||||
|
case 409: return '操作冲突,请刷新后重试'; |
||||
|
case 429: return '操作过于频繁,请稍后再试'; |
||||
|
case 500: return '服务器异常,请稍后重试'; |
||||
|
case 503: return '服务暂不可用,请稍后重试'; |
||||
|
default: return `请求失败(${status})`; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** 统一请求入口(鉴权相关业务走此;业务端点转真实时也复用) */ |
||||
|
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> { |
||||
|
return rawRequest<T>(path, opts); |
||||
|
} |
||||
|
|
||||
|
/** 健康检查(§1):data.status === 'UP' 表示后端可用 */ |
||||
|
export async function checkHealth(): Promise<boolean> { |
||||
|
try { |
||||
|
const data = await request<{ status: string }>('/health', { |
||||
|
method: 'GET', |
||||
|
auth: false, |
||||
|
}); |
||||
|
return data?.status === 'UP'; |
||||
|
} catch { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,84 @@ |
|||||
|
import { storageGet, storageSet, storageRemove } from './storage'; |
||||
|
|
||||
|
/* ===================== 双 Token 存储(access + refresh) ===================== |
||||
|
* 规范 §4:Access Token 用于业务请求;Refresh Token 只提交给 /auth/refresh。 |
||||
|
* |
||||
|
* 鉴权请求头在 fetch 构造时是「同步」的,但 AsyncStorage 是异步的, |
||||
|
* 因此这里保留一份内存单例,供请求层同步读取 accessToken; |
||||
|
* 持久化仍走 storage(qingsongpin: 前缀),由 hydrateTokens 在启动时回填。 |
||||
|
*/ |
||||
|
|
||||
|
const KEY_ACCESS = 'auth:accessToken'; |
||||
|
const KEY_REFRESH = 'auth:refreshToken'; |
||||
|
const KEY_EXP = 'auth:expiresAt'; |
||||
|
|
||||
|
export interface TokenPair { |
||||
|
accessToken: string; |
||||
|
refreshToken: string; |
||||
|
/** accessToken 过期时间戳(毫秒);后端可能不返回,故可选 */ |
||||
|
expiresAt?: number; |
||||
|
} |
||||
|
|
||||
|
let memory: TokenPair | null = null; |
||||
|
|
||||
|
/** 应用启动时调用一次:从持久化回填内存态;失败静默降级为未登录 */ |
||||
|
export async function hydrateTokens(): Promise<void> { |
||||
|
try { |
||||
|
const [access, refresh, exp] = await Promise.all([ |
||||
|
storageGet(KEY_ACCESS), |
||||
|
storageGet(KEY_REFRESH), |
||||
|
storageGet(KEY_EXP), |
||||
|
]); |
||||
|
if (access && refresh) { |
||||
|
memory = { |
||||
|
accessToken: access, |
||||
|
refreshToken: refresh, |
||||
|
expiresAt: exp ? Number(exp) : undefined, |
||||
|
}; |
||||
|
} |
||||
|
} catch { |
||||
|
/* 降级为未登录 */ |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** 同步读取 accessToken(供请求层构造 Authorization 头) */ |
||||
|
export function getAccessToken(): string | null { |
||||
|
return memory?.accessToken ?? null; |
||||
|
} |
||||
|
|
||||
|
/** 同步读取完整 Token 对(供刷新判断是否有 refreshToken) */ |
||||
|
export function getTokens(): TokenPair | null { |
||||
|
return memory; |
||||
|
} |
||||
|
|
||||
|
/** 写入并持久化;expiresAt 缺省时不覆盖已存值 */ |
||||
|
export async function setTokens( |
||||
|
accessToken: string, |
||||
|
refreshToken: string, |
||||
|
expiresAt?: number, |
||||
|
): Promise<void> { |
||||
|
memory = { accessToken, refreshToken, expiresAt }; |
||||
|
const writes: Promise<void>[] = [ |
||||
|
storageSet(KEY_ACCESS, accessToken), |
||||
|
storageSet(KEY_REFRESH, refreshToken), |
||||
|
]; |
||||
|
if (expiresAt !== undefined) { |
||||
|
writes.push(storageSet(KEY_EXP, String(expiresAt))); |
||||
|
} |
||||
|
await Promise.all(writes); |
||||
|
} |
||||
|
|
||||
|
/** 清空(登出 / 刷新失败 / 401 清会话):内存与持久化一并清 */ |
||||
|
export async function clearTokens(): Promise<void> { |
||||
|
memory = null; |
||||
|
await Promise.all([ |
||||
|
storageRemove(KEY_ACCESS), |
||||
|
storageRemove(KEY_REFRESH), |
||||
|
storageRemove(KEY_EXP), |
||||
|
]); |
||||
|
} |
||||
|
|
||||
|
/** 仅供测试:复位内存态,避免用例间污染 */ |
||||
|
export function __resetTokenMemory(): void { |
||||
|
memory = null; |
||||
|
} |
||||
@ -0,0 +1,45 @@ |
|||||
|
# 后端联调 · 前端实现说明 |
||||
|
|
||||
|
> 适用范围:`有职`(youzhi)App 前端对接《职来后端接口联调说明》(`job.youthome.com.cn` 规范) |
||||
|
> 关联文件:`src/api/http.ts`、`src/api/auth.ts`、`src/lib/token-store.ts`、`src/context/AuthContext.tsx`、`src/types/user.ts` |
||||
|
> 本文只记录**前端已落地的地基与契约映射**,完整接口字段以运行时 OpenAPI 为唯一依据(规范 §7)。 |
||||
|
|
||||
|
## 一、本次已落地(地基,不涉及业务端点) |
||||
|
|
||||
|
| 层 | 文件 | 内容 | |
||||
|
|---|---|---| |
||||
|
| 请求层 | `src/api/http.ts` | 统一出口:`Base URL` + `Bearer` 注入 + 信封解析 + **401 单飞刷新** + `X-Request-Id` + 状态码语义 | |
||||
|
| 令牌 | `src/lib/token-store.ts` | `accessToken` + `refreshToken` 内存单例 + AsyncStorage 持久化 | |
||||
|
| 鉴权 | `src/api/auth.ts` | `sendSmsCode` / `loginByPassword` / `loginBySms` / `fetchCurrentUser` / `requestLogout` / `registerCompany` | |
||||
|
| 会话 | `src/context/AuthContext.tsx` | `setSession(access,refresh,expiresAt)`;启动时 `hydrateTokens`;注册 401 清会话回调 | |
||||
|
| 角色 | `src/types/user.ts` + `auth.ts` | `AppRole` 扩 `admin`;`mapBackendRole(STUDENT→candidate / COMPANY→enterprise / ADMIN→admin)` | |
||||
|
| 环境 | `app.config.js` | 默认 `apiBaseUrl = https://job.youthome.com.cn/api/v1`(保留 `API_BASE_URL` 环境变量覆盖) | |
||||
|
|
||||
|
业务端点(`jobs` / `profile` / `messages` 等 100+ mock 函数)**仍走 mock**,按 AGENTS.md 约定后续逐个替换为真实 `request()` 调用即可,页面与 hook 零改动。 |
||||
|
|
||||
|
## 二、规范条款逐条对照 |
||||
|
|
||||
|
| 规范 | 前端现状 | |
||||
|
|---|---| |
||||
|
| §1 基地址 / 健康检查 / OpenAPI | 基地址已接 `API_BASE_URL`;`checkHealth()` 已实现对 `/health`(UP 判定);OpenAPI 为后端准绳,前端不手抄 | |
||||
|
| §3 信封 `code/message/data/requestId/timestamp` | `request()` 解析 envelope;`code!==200` 抛 `ApiError`;后端 `message` 优先(更具体),无 message 时回退状态码文案 | |
||||
|
| §3 `Authorization: Bearer` | 自动注入;匿名接口(登录/短信/刷新)显式 `auth:false` 不带头 | |
||||
|
| §4 双 Token + `/auth/refresh` | `token-store` 持久化 `access+refresh`;401 触发刷新 | |
||||
|
| §4 一次串行刷新 + 重放 | 模块级 `refreshPromise` 单飞闸门:并发 401 只刷一次,成功后重放原请求 | |
||||
|
| §4 刷新失败清会话 | 清 Token + `unauthorizedHandler`(AuthContext 注册为清登录态) | |
||||
|
| §4 Token 不打印/上报/进 URL | 令牌仅在 `http.ts` 内层使用,错误对象不回吐原始 Token | |
||||
|
| §5 角色以后端 `/auth/me` 为准 | `fetchCurrentUser()` 返回映射后角色;密码登录页(`P2PasswordLogin`)登录后调 `/auth/me` 并 `setRole`,与本地选择不一致时纠正路由 | |
||||
|
| §6 企业注册 | `registerCompany()` 已实现对 `/auth/company/register`;客户端不提交 `userId/role/verified/passwordHash` | |
||||
|
| §8 状态码语义 | `pickHttpMessage` 覆盖 400/401/403/404/409/429/500/503;网络异常降级为 `httpStatus=0` | |
||||
|
| §10 安全 | 不写 Token/验证码/密码到日志或前端 DOM;`X-Request-Id` 用于联调报障 | |
||||
|
|
||||
|
## 三、明确未做(下一阶段) |
||||
|
|
||||
|
- **业务端点转真实**:`jobs`、`profile/me`、`companies/me/onboarding` 等仍 mock,需按 OpenAPI 逐个换 `request()`。 |
||||
|
- **WebSocket**:`wss://job.youthome.com.cn/api/v1/ws` 未接入(无对应实时功能)。 |
||||
|
- **企业入驻状态机**:`/companies/me/onboarding` 的 `missingFields` / `allowedActions` 驱动 UI 尚未接。 |
||||
|
- **其余入口页角色纠正**:仅密码登录页已接 `/auth/me`;微信登录、验证码登录等入口需在登录后补齐角色权威化。 |
||||
|
|
||||
|
## 四、命名待确认(非阻塞) |
||||
|
|
||||
|
- 域名 `youthome.com.cn`(青年家)与 App 品牌「有职 / youzhi」不一致;Storage 前缀仍为历史遗留 `qingsongpin:`,**勿擅自改名**(会导致老用户本地态失效)。 |
||||
Loading…
Reference in new issue