diff --git a/app.config.js b/app.config.js index 86697c1..8006522 100644 --- a/app.config.js +++ b/app.config.js @@ -75,7 +75,8 @@ export default { ], ], extra: { - apiBaseUrl: process.env.API_BASE_URL || 'https://dev.api.com', + // 业务接口基地址(规范 §1)。默认生产 HTTPS 域名;联调/本地可用环境变量 API_BASE_URL 覆盖。 + apiBaseUrl: process.env.API_BASE_URL || 'https://job.youthome.com.cn/api/v1', // EAS 项目 ID:`eas init` 后会自动注入此处(与 updates.url 中的 PROJECT_ID 对应)。 eas: { projectId: 'REPLACE_WITH_PROJECT_ID', diff --git a/components/onboarding/screens/P2PasswordLogin.tsx b/components/onboarding/screens/P2PasswordLogin.tsx index 8f3bda5..79c4ef5 100644 --- a/components/onboarding/screens/P2PasswordLogin.tsx +++ b/components/onboarding/screens/P2PasswordLogin.tsx @@ -7,6 +7,7 @@ import { useAgreementGate, useOnboarding, usePasswordLogin } from '../../../src/ import { isValidPhone } from '../../../src/utils'; import { useAuth } from '../../../src/context/AuthContext'; import { useRole } from '../../../src/context/RoleContext'; +import { fetchCurrentUser } from '../../../src/api'; import { PasswordField } from '../../../src/components/PasswordField'; import { ScreenShell } from '../ui/ScreenShell'; import { AgreementCheckbox } from '../ui/AgreementCheckbox'; @@ -23,9 +24,9 @@ export function P2PasswordLogin() { const { data, setData, navigate, back } = useOnboarding(); const { gate, modalProps } = useAgreementGate(); const { login, loading, error } = usePasswordLogin(); - /** 登录态写入:换取令牌后必须落库,否则冷启动会被打回登录页 */ - const { login: writeAuth } = useAuth(); - const { role } = useRole(); + /** 双 Token 写入:换取令牌后必须落库,否则冷启动会被打回登录页 */ + const { setSession } = useAuth(); + const { role, setRole } = useRole(); const router = useRouter(); const [password, setPassword] = useState(''); @@ -39,15 +40,26 @@ export function P2PasswordLogin() { if (!canSubmit || loading) return; try { const result = await login(phone, password); - writeAuth(result.token); + // 双 Token 落库(access + refresh),后续请求自动带 Bearer + await setSession(result.accessToken, result.refreshToken, result.expiresAt); } catch { // 失败原因由 usePasswordLogin 记录,页面下方展示 return; } + // 角色以 /auth/me 后端返回为准(规范 §5),不一致时纠正路由 + let landingRole = role; + try { + const me = await fetchCurrentUser(); + setRole(me.role); + landingRole = me.role; + } catch { + // /auth/me 失败时退回到本地已选身份,不阻断登录 + } + // 已有身份的老用户直接进对应主界面,新用户才去选身份 - if (role) { - router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); + if (landingRole) { + router.replace(landingRole === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); return; } navigate('P3'); diff --git a/components/onboarding/screens/P2PasswordReset.tsx b/components/onboarding/screens/P2PasswordReset.tsx index 98dfa6a..f5a880e 100644 --- a/components/onboarding/screens/P2PasswordReset.tsx +++ b/components/onboarding/screens/P2PasswordReset.tsx @@ -55,7 +55,7 @@ export function P2PasswordReset() { const sendCodeNow = async () => { if (!phoneValid) return; try { - await sendCode(phone); + await sendCode(phone, 'reset-password'); } catch { // 失败原因由 useSmsCode 记录,页面下方展示 } diff --git a/components/onboarding/screens/P2PhoneCode.tsx b/components/onboarding/screens/P2PhoneCode.tsx index 21bbc4f..a948117 100644 --- a/components/onboarding/screens/P2PhoneCode.tsx +++ b/components/onboarding/screens/P2PhoneCode.tsx @@ -39,7 +39,7 @@ export function P2PhoneCode() { const requestCode = async () => { if (!phoneValid) return; try { - await sendCode(phone); + await sendCode(phone, 'login'); setStage('code'); } catch { // 失败原因由 useSmsCode 记录,页面下方统一展示 @@ -48,7 +48,7 @@ export function P2PhoneCode() { /** 阶段2 重新获取(不改变阶段) */ const resendCode = () => { - void sendCode(phone).catch(() => { + void sendCode(phone, 'login').catch(() => { // 同上,错误已记录 }); }; diff --git a/src/__tests__/http.test.ts b/src/__tests__/http.test.ts new file mode 100644 index 0000000..d023902 --- /dev/null +++ b/src/__tests__/http.test.ts @@ -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).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)['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'); + }); +}); diff --git a/src/__tests__/password.test.ts b/src/__tests__/password.test.ts index 247d0f1..8c61808 100644 --- a/src/__tests__/password.test.ts +++ b/src/__tests__/password.test.ts @@ -1,12 +1,11 @@ import { changePassword, getPasswordRuleMessage, - loginByPassword, resetPassword, - sendSmsCode, validatePassword, validatePasswordConfirm, } from '../api/api'; +import { loginByPassword, sendSmsCode } from '../api'; import { PASSWORD_RULE_LABEL } from '../constants'; import type { PasswordRuleError } from '../types'; import { isValidPhone, isValidSmsCode } from '../utils'; @@ -28,6 +27,16 @@ const ALL_RULE_ERRORS: PasswordRuleError[] = [ 'same-as-old', ]; +/** 构造 fetch 的响应桩:支持 ok / status / headers.get / text() */ +function makeResponse(status: number, body: unknown) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (k: string) => (k === 'X-Request-Id' ? 'rid' : null) }, + text: async () => JSON.stringify(body), + }; +} + describe('validatePassword(密码强度业务规则)', () => { it('空密码返回 empty', () => { expect(validatePassword('')).toBe('empty'); @@ -100,29 +109,88 @@ describe('validators(手机号 / 验证码格式)', () => { }); }); -describe('loginByPassword(手机号 + 密码登录)', () => { - it('手机号非法时拒绝', async () => { - await expect(loginByPassword('138', 'abc12345')).rejects.toThrow('手机号格式不正确'); - }); - - it('密码不合规时统一提示账号或密码错误(不泄露账号是否存在)', async () => { - await expect(loginByPassword('13800138000', 'abc')).rejects.toThrow('手机号或密码错误'); +describe('loginByPassword(手机号 + 密码登录 · 真实后端)', () => { + beforeEach(() => { + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(); }); - it('参数合法时返回令牌', async () => { - const result = await loginByPassword('13800138000', 'abc12345'); - expect(result.phone).toBe('13800138000'); - expect(result.token).toContain('13800138000'); + it('手机号非法时本地拒绝,不发请求', async () => { + await expect( + loginByPassword({ phone: '138', password: 'abc12345' }), + ).rejects.toThrow('手机号格式不正确'); + expect((global as unknown as { fetch: jest.Mock }).fetch).not.toHaveBeenCalled(); + }); + + it('参数合法时 POST /auth/password/login 且返回双 Token(匿名接口不带 Bearer)', async () => { + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( + async () => + makeResponse(200, { + code: 200, + message: 'ok', + data: { accessToken: 'A', refreshToken: 'R' }, + requestId: 'r1', + timestamp: 't', + }), + ); + const result = await loginByPassword({ phone: '13800138000', password: 'abc12345' }); + expect(result.accessToken).toBe('A'); + expect(result.refreshToken).toBe('R'); + const [url, init] = ( + (global as unknown as { fetch: jest.Mock }).fetch as jest.Mock + ).mock.calls[0]; + expect(url).toContain('/auth/password/login'); + expect(init.method).toBe('POST'); + expect(init.headers['Authorization']).toBeUndefined(); + expect(JSON.parse(init.body)).toEqual({ phone: '13800138000', password: 'abc12345' }); + }); + + it('HTTP 401 时抛出错误(无 refreshToken 不自动刷新)', async () => { + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( + async () => + makeResponse(401, { + code: 401, + message: '凭证无效', + data: null, + requestId: 'r2', + timestamp: 't', + }), + ); + await expect( + loginByPassword({ phone: '13800138000', password: 'abc12345' }), + ).rejects.toThrow(); }); }); -describe('sendSmsCode(发送验证码)', () => { - it('手机号非法时拒绝', async () => { - await expect(sendSmsCode('138')).rejects.toThrow('手机号格式不正确'); - }); - - it('手机号合法时成功', async () => { - await expect(sendSmsCode('13800138000')).resolves.toBeUndefined(); +describe('sendSmsCode(发送验证码 · 真实后端)', () => { + beforeEach(() => { + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(); + }); + + it('手机号非法时本地拒绝,不发请求', async () => { + await expect(sendSmsCode({ phone: '138', purpose: 'login' })).rejects.toThrow( + '手机号格式不正确', + ); + expect((global as unknown as { fetch: jest.Mock }).fetch).not.toHaveBeenCalled(); + }); + + it('手机号合法时 POST /auth/sms/send(带 purpose)', async () => { + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn( + async () => + makeResponse(200, { + code: 200, + message: 'ok', + data: null, + requestId: 'r3', + timestamp: 't', + }), + ); + await sendSmsCode({ phone: '13800138000', purpose: 'login' }); + const [url, init] = ( + (global as unknown as { fetch: jest.Mock }).fetch as jest.Mock + ).mock.calls[0]; + expect(url).toContain('/auth/sms/send'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ phone: '13800138000', purpose: 'login' }); }); }); diff --git a/src/api/api.ts b/src/api/api.ts index cba3d2c..72d9707 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -8160,47 +8160,7 @@ export function getPasswordRuleMessage(error: PasswordRuleError): string { return PASSWORD_RULE_LABEL[error]; } -/* ===================== 账号:手机号 + 密码登录 ===================== */ - -/** - * 手机号 + 密码登录。 - * - * mock 阶段判定:密码本身不合规即视为「密码错误」,用于在 Expo Go 里验证失败分支。 - * - * @method POST - * @url /api/auth/login-by-password - * @param phone 手机号(11 位) - * @param password 登录密码 - * @returns 登录结果(含 token) - */ -export function loginByPassword(phone: string, password: string): Promise { - if (!isValidPhone(phone)) { - return Promise.reject(new Error('手机号格式不正确')); - } - const ruleError = validatePassword(password); - if (ruleError) { - // 真实后端应统一返回「手机号或密码错误」,此处不区分,避免账号枚举 - return Promise.reject(new Error('手机号或密码错误')); - } - return mockRequest({ token: `mock_token_${phone}`, phone }); -} - -/** - * 发送短信验证码。 - * - * 此前页面「获取验证码」只做前端倒计时、无任何接口调用,本函数补齐链路。 - * - * @method POST - * @url /api/auth/sms-code - * @param phone 手机号(11 位) - * @returns 无返回体;失败时 reject - */ -export function sendSmsCode(phone: string): Promise { - if (!isValidPhone(phone)) { - return Promise.reject(new Error('手机号格式不正确')); - } - return mockRequest(undefined); -} +/* ===================== 账号:手机号 + 密码登录(真实实现见 src/api/auth.ts) ===================== */ /** * 重置 / 首次设置登录密码(忘记密码与首次设密码共用同一入口)。 diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..7ed63fd --- /dev/null +++ b/src/api/auth.ts @@ -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 { + 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 { + if (!isValidPhone(payload.phone)) { + throw new Error('手机号格式不正确'); + } + return request('/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 { + return request('/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 { + const data = await request<{ id: string; role: BackendRole } & Record>( + '/auth/me', + { method: 'GET' }, + ); + return { ...data, role: mapBackendRole(data.role) }; +} + +/** 退出登录(§4 步骤5):前端清会话由 AuthContext 负责,这里只通知后端 */ +export async function requestLogout(): Promise { + 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 { + if (!isValidPhone(payload.phone)) { + throw new Error('手机号格式不正确'); + } + return request('/auth/company/register', { + method: 'POST', + auth: false, + body: payload, + }); +} diff --git a/src/api/http.ts b/src/api/http.ts new file mode 100644 index 0000000..12c252a --- /dev/null +++ b/src/api/http.ts @@ -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 ;响应外层 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 { + 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 | null = null; + +function genRequestId(): string { + // 规范示例为 web-20260922-0001;移动端用短随机串,降低可读性暴露风险 + return `app-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function isEnvelope(value: unknown): value is ApiEnvelope { + return ( + typeof value === 'object' && + value !== null && + 'code' in value && + 'data' in value + ); +} + +interface InternalOptions extends RequestOptions { + /** 标记这是刷新请求本身,避免 401 时递归刷新 */ + _isRefresh?: boolean; +} + +async function doRefresh(): Promise { + 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(path: string, opts: InternalOptions): Promise { + const url = `${API_BASE_URL}${path}`; + const requestId = genRequestId(); + const headers: Record = { + '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(path, { ...opts, _isRefresh: true }); + } + throw new ApiError({ + httpStatus: 401, + code: isEnvelope(parsed) ? parsed.code : 401, + message: '登录已失效,请重新登录', + requestId: resRequestId, + }); + } + + // HTTP 状态码优先(§8) + if (!response.ok) { + const msg = isEnvelope(parsed) + ? parsed.message + : pickHttpMessage(response.status); + throw new ApiError({ + httpStatus: response.status, + code: isEnvelope(parsed) ? parsed.code : response.status, + message: msg, + requestId: resRequestId, + }); + } + + // 信封 code 校验(§3):后端约定 code=200 表示成功 + if (isEnvelope(parsed) && parsed.code !== 200) { + throw new ApiError({ + httpStatus: response.status, + code: parsed.code, + message: parsed.message || '业务处理失败', + requestId: resRequestId, + }); + } + + // 返回 data;无信封时回退为空对象(部分接口无 data 字段) + if (isEnvelope(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(path: string, opts: RequestOptions = {}): Promise { + return rawRequest(path, opts); +} + +/** 健康检查(§1):data.status === 'UP' 表示后端可用 */ +export async function checkHealth(): Promise { + try { + const data = await request<{ status: string }>('/health', { + method: 'GET', + auth: false, + }); + return data?.status === 'UP'; + } catch { + return false; + } +} diff --git a/src/api/index.ts b/src/api/index.ts index 750d690..444e5ec 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -115,14 +115,12 @@ export { fetchLegalServiceRequests, createLegalServiceRequest, loginByPhone, - loginByPassword, loginByCarrier, fetchCarrierPhone, maskPhone, clampSocialBase, fetchCacheSize, clearAppCache, - sendSmsCode, resetPassword, changePassword, validatePassword, @@ -187,3 +185,33 @@ export { updateNotificationSetting, updatePrivacySetting, } from './api'; + +/* ===================== 真实后端:鉴权接口 ===================== */ +export { + sendSmsCode, + loginByPassword, + loginBySms, + fetchCurrentUser, + requestLogout, + registerCompany, + mapBackendRole, +} from './auth'; +export type { + AuthTokens, + SmsSendPayload, + PasswordLoginPayload, + SmsLoginPayload, + CurrentUser, + CompanyRegisterPayload, + BackendRole, +} from './auth'; + +/* ===================== 真实后端:统一请求层 ===================== */ +export { + request, + checkHealth, + setUnauthorizedHandler, + API_BASE_URL, +} from './http'; +export { ApiError } from './http'; +export type { ApiEnvelope, ApiErrorDetail, RequestOptions, HttpMethod } from './http'; diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 8a42ae2..f72f531 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,6 +1,8 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; import { storageGet, storageSet } from '../lib/storage'; import { TOKEN_TTL_MS } from '../constants'; +import { clearTokens, setTokens, hydrateTokens } from '../lib/token-store'; +import { setUnauthorizedHandler } from '../api/http'; /* ===================== 鉴权(引导/登录)上下文 ===================== * 管理「引导页是否看过」「是否已登录」「登录令牌」「分端标记」。 @@ -21,6 +23,8 @@ export interface AuthContextValue { isLoggedIn: boolean; /** 登录令牌(mock 态为占位串) */ token: string | null; + /** 刷新令牌(双 Token 方案;mock 态为 null) */ + refreshToken: string | null; /** 令牌过期时间戳(毫秒);null 表示无有效令牌 */ tokenExpiresAt: number | null; /** @@ -46,6 +50,13 @@ export interface AuthContextValue { * @param expiresAt 过期时间戳(毫秒);不传时按 TOKEN_TTL_MS 推算 */ login: (token: string, expiresAt?: number) => void; + /** + * 真实登录成功:写入双 Token 并持久化(access + refresh)。 + * @param accessToken 业务请求用访问令牌 + * @param refreshToken 仅提交给 /auth/refresh 的刷新令牌 + * @param expiresAt accessToken 过期时间戳(毫秒),后端可能不返回 + */ + setSession: (accessToken: string, refreshToken: string, expiresAt?: number) => void; /** 退出登录,清空令牌 */ logout: () => void; /** 标记求职意向已设置 */ @@ -60,6 +71,7 @@ interface MemoryAuth { onboardingSeen: boolean; isLoggedIn: boolean; token: string | null; + refreshToken: string | null; tokenExpiresAt: number | null; candidateSetupDone: boolean; enterpriseVerified: boolean; @@ -71,6 +83,7 @@ const DEFAULT_AUTH: MemoryAuth = { onboardingSeen: false, isLoggedIn: false, token: null, + refreshToken: null, tokenExpiresAt: null, candidateSetupDone: false, enterpriseVerified: false, @@ -99,11 +112,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [state, setState] = useState(memoryAuth); const [isHydrated, setIsHydrated] = useState(false); - // 启动时从 async-storage 恢复登录/引导/分端标记 + // 启动时从 async-storage 恢复登录/引导/分端标记;同时回填双 Token 内存态 useEffect(() => { let active = true; - loadAuth() - .then((stored) => { + Promise.all([loadAuth(), hydrateTokens()]) + .then(([stored]) => { if (active && stored) { memoryAuth = stored; setState(stored); @@ -113,6 +126,22 @@ export function AuthProvider({ children }: { children: ReactNode }) { // 无论读取成功、为空还是异常,都必须置位,否则入口页会一直停在占位态 if (active) setIsHydrated(true); }); + // 401 清会话回调:刷新失败 / 无 refreshToken 时由请求层调用 + setUnauthorizedHandler(() => { + if (!active) return; + const loggedOut: MemoryAuth = { + ...memoryAuth, + isLoggedIn: false, + token: null, + refreshToken: null, + tokenExpiresAt: null, + candidateSetupDone: false, + enterpriseVerified: false, + }; + persistAuth(loggedOut); + setState(loggedOut); + void clearTokens(); + }); return () => { active = false; }; @@ -135,17 +164,32 @@ export function AuthProvider({ children }: { children: ReactNode }) { setState(next); }; + const setSession = (accessToken: string, refreshToken: string, expiresAt?: number) => { + const next: MemoryAuth = { + ...state, + isLoggedIn: true, + token: accessToken, + refreshToken, + tokenExpiresAt: expiresAt ?? Date.now() + TOKEN_TTL_MS, + }; + persistAuth(next); + setState(next); + void setTokens(accessToken, refreshToken, expiresAt); + }; + const logout = () => { const next: MemoryAuth = { ...state, isLoggedIn: false, token: null, + refreshToken: null, tokenExpiresAt: null, candidateSetupDone: false, enterpriseVerified: false, }; persistAuth(next); setState(next); + void clearTokens(); }; const completeCandidateSetup = () => { @@ -172,6 +216,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isTokenValid, completeOnboarding, login, + setSession, logout, completeCandidateSetup, completeEnterpriseVerify, diff --git a/src/hooks/usePasswordLogin.ts b/src/hooks/usePasswordLogin.ts index 1711d62..12be845 100644 --- a/src/hooks/usePasswordLogin.ts +++ b/src/hooks/usePasswordLogin.ts @@ -1,9 +1,9 @@ import { useCallback, useState } from 'react'; -import { loginByPassword, type LoginResult } from '../api'; +import { loginByPassword, type AuthTokens } from '../api'; export interface UsePasswordLoginReturn { - /** 手机号 + 密码登录,成功返回 LoginResult */ - login: (phone: string, password: string) => Promise; + /** 手机号 + 密码登录,成功返回双 Token */ + login: (phone: string, password: string) => Promise; /** 登录进行中 */ loading: boolean; /** 登录失败的错误(用于页面提示) */ @@ -22,7 +22,7 @@ export function usePasswordLogin(): UsePasswordLoginReturn { setLoading(true); setError(null); try { - return await loginByPassword(phone, password); + return await loginByPassword({ phone, password }); } catch (e) { const err = e instanceof Error ? e : new Error('登录失败,请稍后重试'); setError(err); diff --git a/src/hooks/useSmsCode.ts b/src/hooks/useSmsCode.ts index 981585e..8d8b04f 100644 --- a/src/hooks/useSmsCode.ts +++ b/src/hooks/useSmsCode.ts @@ -2,8 +2,12 @@ import { useCallback, useState } from 'react'; import { sendSmsCode } from '../api'; export interface UseSmsCodeReturn { - /** 发送短信验证码(失败抛出,由页面提示) */ - send: (phone: string) => Promise; + /** + * 发送短信验证码(失败抛出,由页面提示)。 + * @param phone 手机号 + * @param purpose 验证码用途(后端固定值):验证码登录传 'login',重置密码传 'reset-password' + */ + send: (phone: string, purpose: string) => Promise; /** 发送进行中(用于防重复点击) */ loading: boolean; /** 发送失败的错误 */ @@ -18,11 +22,11 @@ export function useSmsCode(): UseSmsCodeReturn { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const send = useCallback(async (phone: string) => { + const send = useCallback(async (phone: string, purpose: string) => { setLoading(true); setError(null); try { - await sendSmsCode(phone); + await sendSmsCode({ phone, purpose }); } catch (e) { const err = e instanceof Error ? e : new Error('验证码发送失败,请稍后重试'); setError(err); diff --git a/src/lib/token-store.ts b/src/lib/token-store.ts new file mode 100644 index 0000000..6a412a5 --- /dev/null +++ b/src/lib/token-store.ts @@ -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 { + 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 { + memory = { accessToken, refreshToken, expiresAt }; + const writes: Promise[] = [ + 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 { + memory = null; + await Promise.all([ + storageRemove(KEY_ACCESS), + storageRemove(KEY_REFRESH), + storageRemove(KEY_EXP), + ]); +} + +/** 仅供测试:复位内存态,避免用例间污染 */ +export function __resetTokenMemory(): void { + memory = null; +} diff --git a/src/types/user.ts b/src/types/user.ts index 0bee4c5..79d9a8b 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -35,11 +35,13 @@ export interface UserStats { /** * 应用角色 - * - candidate:求职者 - * - enterprise:企业 / HR + * - candidate:求职者(对应后端 STUDENT) + * - enterprise:企业 / HR(对应后端 COMPANY) + * - admin:平台管理员(对应后端 ADMIN) + * 角色值一律来自 /auth/me 后端下发并经 mapBackendRole 映射,客户端不自行决定。 * 用于按角色下发不同的功能菜单,避免求职者端出现企业功能、反之亦然 */ -export type AppRole = 'candidate' | 'enterprise'; +export type AppRole = 'candidate' | 'enterprise' | 'admin'; /** 功能菜单项 */ export interface MenuItem { diff --git a/后端联调前端实现.md b/后端联调前端实现.md new file mode 100644 index 0000000..92148de --- /dev/null +++ b/后端联调前端实现.md @@ -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:`,**勿擅自改名**(会导致老用户本地态失效)。