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