Browse Source

feat(auth): 接入后端联调地基(统一请求层+双Token+角色权威化)

落实《职来后端接口联调说明》(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
doublex001 1 month ago
parent
commit
3ef1df2eab
  1. 3
      app.config.js
  2. 24
      components/onboarding/screens/P2PasswordLogin.tsx
  3. 2
      components/onboarding/screens/P2PasswordReset.tsx
  4. 4
      components/onboarding/screens/P2PhoneCode.tsx
  5. 252
      src/__tests__/http.test.ts
  6. 114
      src/__tests__/password.test.ts
  7. 42
      src/api/api.ts
  8. 135
      src/api/auth.ts
  9. 248
      src/api/http.ts
  10. 32
      src/api/index.ts
  11. 51
      src/context/AuthContext.tsx
  12. 8
      src/hooks/usePasswordLogin.ts
  13. 12
      src/hooks/useSmsCode.ts
  14. 84
      src/lib/token-store.ts
  15. 8
      src/types/user.ts
  16. 45
      后端联调前端实现.md

3
app.config.js

@ -75,7 +75,8 @@ export default {
], ],
], ],
extra: { 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 项目 ID:`eas init` 后会自动注入此处(与 updates.url 中的 PROJECT_ID 对应)。
eas: { eas: {
projectId: 'REPLACE_WITH_PROJECT_ID', projectId: 'REPLACE_WITH_PROJECT_ID',

24
components/onboarding/screens/P2PasswordLogin.tsx

@ -7,6 +7,7 @@ import { useAgreementGate, useOnboarding, usePasswordLogin } from '../../../src/
import { isValidPhone } from '../../../src/utils'; import { isValidPhone } from '../../../src/utils';
import { useAuth } from '../../../src/context/AuthContext'; import { useAuth } from '../../../src/context/AuthContext';
import { useRole } from '../../../src/context/RoleContext'; import { useRole } from '../../../src/context/RoleContext';
import { fetchCurrentUser } from '../../../src/api';
import { PasswordField } from '../../../src/components/PasswordField'; import { PasswordField } from '../../../src/components/PasswordField';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementCheckbox } from '../ui/AgreementCheckbox';
@ -23,9 +24,9 @@ export function P2PasswordLogin() {
const { data, setData, navigate, back } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
const { gate, modalProps } = useAgreementGate(); const { gate, modalProps } = useAgreementGate();
const { login, loading, error } = usePasswordLogin(); const { login, loading, error } = usePasswordLogin();
/** 登录态写入:换取令牌后必须落库,否则冷启动会被打回登录页 */ /** 双 Token 写入:换取令牌后必须落库,否则冷启动会被打回登录页 */
const { login: writeAuth } = useAuth(); const { setSession } = useAuth();
const { role } = useRole(); const { role, setRole } = useRole();
const router = useRouter(); const router = useRouter();
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@ -39,15 +40,26 @@ export function P2PasswordLogin() {
if (!canSubmit || loading) return; if (!canSubmit || loading) return;
try { try {
const result = await login(phone, password); const result = await login(phone, password);
writeAuth(result.token); // 双 Token 落库(access + refresh),后续请求自动带 Bearer
await setSession(result.accessToken, result.refreshToken, result.expiresAt);
} catch { } catch {
// 失败原因由 usePasswordLogin 记录,页面下方展示 // 失败原因由 usePasswordLogin 记录,页面下方展示
return; return;
} }
// 角色以 /auth/me 后端返回为准(规范 §5),不一致时纠正路由
let landingRole = role;
try {
const me = await fetchCurrentUser();
setRole(me.role);
landingRole = me.role;
} catch {
// /auth/me 失败时退回到本地已选身份,不阻断登录
}
// 已有身份的老用户直接进对应主界面,新用户才去选身份 // 已有身份的老用户直接进对应主界面,新用户才去选身份
if (role) { if (landingRole) {
router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); router.replace(landingRole === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs');
return; return;
} }
navigate('P3'); navigate('P3');

2
components/onboarding/screens/P2PasswordReset.tsx

@ -55,7 +55,7 @@ export function P2PasswordReset() {
const sendCodeNow = async () => { const sendCodeNow = async () => {
if (!phoneValid) return; if (!phoneValid) return;
try { try {
await sendCode(phone); await sendCode(phone, 'reset-password');
} catch { } catch {
// 失败原因由 useSmsCode 记录,页面下方展示 // 失败原因由 useSmsCode 记录,页面下方展示
} }

4
components/onboarding/screens/P2PhoneCode.tsx

@ -39,7 +39,7 @@ export function P2PhoneCode() {
const requestCode = async () => { const requestCode = async () => {
if (!phoneValid) return; if (!phoneValid) return;
try { try {
await sendCode(phone); await sendCode(phone, 'login');
setStage('code'); setStage('code');
} catch { } catch {
// 失败原因由 useSmsCode 记录,页面下方统一展示 // 失败原因由 useSmsCode 记录,页面下方统一展示
@ -48,7 +48,7 @@ export function P2PhoneCode() {
/** 阶段2 重新获取(不改变阶段) */ /** 阶段2 重新获取(不改变阶段) */
const resendCode = () => { const resendCode = () => {
void sendCode(phone).catch(() => { void sendCode(phone, 'login').catch(() => {
// 同上,错误已记录 // 同上,错误已记录
}); });
}; };

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

114
src/__tests__/password.test.ts

@ -1,12 +1,11 @@
import { import {
changePassword, changePassword,
getPasswordRuleMessage, getPasswordRuleMessage,
loginByPassword,
resetPassword, resetPassword,
sendSmsCode,
validatePassword, validatePassword,
validatePasswordConfirm, validatePasswordConfirm,
} from '../api/api'; } from '../api/api';
import { loginByPassword, sendSmsCode } from '../api';
import { PASSWORD_RULE_LABEL } from '../constants'; import { PASSWORD_RULE_LABEL } from '../constants';
import type { PasswordRuleError } from '../types'; import type { PasswordRuleError } from '../types';
import { isValidPhone, isValidSmsCode } from '../utils'; import { isValidPhone, isValidSmsCode } from '../utils';
@ -28,6 +27,16 @@ const ALL_RULE_ERRORS: PasswordRuleError[] = [
'same-as-old', '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(密码强度业务规则)', () => { describe('validatePassword(密码强度业务规则)', () => {
it('空密码返回 empty', () => { it('空密码返回 empty', () => {
expect(validatePassword('')).toBe('empty'); expect(validatePassword('')).toBe('empty');
@ -100,29 +109,88 @@ describe('validators(手机号 / 验证码格式)', () => {
}); });
}); });
describe('loginByPassword(手机号 + 密码登录)', () => { describe('loginByPassword(手机号 + 密码登录 · 真实后端)', () => {
it('手机号非法时拒绝', async () => { beforeEach(() => {
await expect(loginByPassword('138', 'abc12345')).rejects.toThrow('手机号格式不正确'); (global as unknown as { fetch: jest.Mock }).fetch = jest.fn();
});
it('密码不合规时统一提示账号或密码错误(不泄露账号是否存在)', async () => {
await expect(loginByPassword('13800138000', 'abc')).rejects.toThrow('手机号或密码错误');
});
it('参数合法时返回令牌', async () => {
const result = await loginByPassword('13800138000', 'abc12345');
expect(result.phone).toBe('13800138000');
expect(result.token).toContain('13800138000');
});
});
describe('sendSmsCode(发送验证码)', () => {
it('手机号非法时拒绝', async () => {
await expect(sendSmsCode('138')).rejects.toThrow('手机号格式不正确');
}); });
it('手机号合法时成功', async () => { it('手机号非法时本地拒绝,不发请求', async () => {
await expect(sendSmsCode('13800138000')).resolves.toBeUndefined(); 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(发送验证码 · 真实后端)', () => {
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' });
}); });
}); });

42
src/api/api.ts

@ -8160,47 +8160,7 @@ export function getPasswordRuleMessage(error: PasswordRuleError): string {
return PASSWORD_RULE_LABEL[error]; return PASSWORD_RULE_LABEL[error];
} }
/* ===================== 账号:手机号 + 密码登录 ===================== */ /* ===================== 账号:手机号 + 密码登录(真实实现见 src/api/auth.ts) ===================== */
/**
* 手机号 + 密码登录。
*
* 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<LoginResult> {
if (!isValidPhone(phone)) {
return Promise.reject(new Error('手机号格式不正确'));
}
const ruleError = validatePassword(password);
if (ruleError) {
// 真实后端应统一返回「手机号或密码错误」,此处不区分,避免账号枚举
return Promise.reject(new Error('手机号或密码错误'));
}
return mockRequest<LoginResult>({ token: `mock_token_${phone}`, phone });
}
/**
* 发送短信验证码。
*
* 此前页面「获取验证码」只做前端倒计时、无任何接口调用,本函数补齐链路。
*
* @method POST
* @url /api/auth/sms-code
* @param phone 手机号(11 位)
* @returns 无返回体;失败时 reject
*/
export function sendSmsCode(phone: string): Promise<void> {
if (!isValidPhone(phone)) {
return Promise.reject(new Error('手机号格式不正确'));
}
return mockRequest<void>(undefined);
}
/** /**
* 重置 / 首次设置登录密码(忘记密码与首次设密码共用同一入口)。 * 重置 / 首次设置登录密码(忘记密码与首次设密码共用同一入口)。

135
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<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,
});
}

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

32
src/api/index.ts

@ -115,14 +115,12 @@ export {
fetchLegalServiceRequests, fetchLegalServiceRequests,
createLegalServiceRequest, createLegalServiceRequest,
loginByPhone, loginByPhone,
loginByPassword,
loginByCarrier, loginByCarrier,
fetchCarrierPhone, fetchCarrierPhone,
maskPhone, maskPhone,
clampSocialBase, clampSocialBase,
fetchCacheSize, fetchCacheSize,
clearAppCache, clearAppCache,
sendSmsCode,
resetPassword, resetPassword,
changePassword, changePassword,
validatePassword, validatePassword,
@ -187,3 +185,33 @@ export {
updateNotificationSetting, updateNotificationSetting,
updatePrivacySetting, updatePrivacySetting,
} from './api'; } 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';

51
src/context/AuthContext.tsx

@ -1,6 +1,8 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { storageGet, storageSet } from '../lib/storage'; import { storageGet, storageSet } from '../lib/storage';
import { TOKEN_TTL_MS } from '../constants'; 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; isLoggedIn: boolean;
/** 登录令牌(mock 态为占位串) */ /** 登录令牌(mock 态为占位串) */
token: string | null; token: string | null;
/** 刷新令牌(双 Token 方案;mock 态为 null) */
refreshToken: string | null;
/** 令牌过期时间戳(毫秒);null 表示无有效令牌 */ /** 令牌过期时间戳(毫秒);null 表示无有效令牌 */
tokenExpiresAt: number | null; tokenExpiresAt: number | null;
/** /**
@ -46,6 +50,13 @@ export interface AuthContextValue {
* @param expiresAt 过期时间戳(毫秒);不传时按 TOKEN_TTL_MS 推算 * @param expiresAt 过期时间戳(毫秒);不传时按 TOKEN_TTL_MS 推算
*/ */
login: (token: string, expiresAt?: number) => void; 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; logout: () => void;
/** 标记求职意向已设置 */ /** 标记求职意向已设置 */
@ -60,6 +71,7 @@ interface MemoryAuth {
onboardingSeen: boolean; onboardingSeen: boolean;
isLoggedIn: boolean; isLoggedIn: boolean;
token: string | null; token: string | null;
refreshToken: string | null;
tokenExpiresAt: number | null; tokenExpiresAt: number | null;
candidateSetupDone: boolean; candidateSetupDone: boolean;
enterpriseVerified: boolean; enterpriseVerified: boolean;
@ -71,6 +83,7 @@ const DEFAULT_AUTH: MemoryAuth = {
onboardingSeen: false, onboardingSeen: false,
isLoggedIn: false, isLoggedIn: false,
token: null, token: null,
refreshToken: null,
tokenExpiresAt: null, tokenExpiresAt: null,
candidateSetupDone: false, candidateSetupDone: false,
enterpriseVerified: false, enterpriseVerified: false,
@ -99,11 +112,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<MemoryAuth>(memoryAuth); const [state, setState] = useState<MemoryAuth>(memoryAuth);
const [isHydrated, setIsHydrated] = useState(false); const [isHydrated, setIsHydrated] = useState(false);
// 启动时从 async-storage 恢复登录/引导/分端标记 // 启动时从 async-storage 恢复登录/引导/分端标记;同时回填双 Token 内存态
useEffect(() => { useEffect(() => {
let active = true; let active = true;
loadAuth() Promise.all([loadAuth(), hydrateTokens()])
.then((stored) => { .then(([stored]) => {
if (active && stored) { if (active && stored) {
memoryAuth = stored; memoryAuth = stored;
setState(stored); setState(stored);
@ -113,6 +126,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// 无论读取成功、为空还是异常,都必须置位,否则入口页会一直停在占位态 // 无论读取成功、为空还是异常,都必须置位,否则入口页会一直停在占位态
if (active) setIsHydrated(true); 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 () => { return () => {
active = false; active = false;
}; };
@ -135,17 +164,32 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setState(next); 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 logout = () => {
const next: MemoryAuth = { const next: MemoryAuth = {
...state, ...state,
isLoggedIn: false, isLoggedIn: false,
token: null, token: null,
refreshToken: null,
tokenExpiresAt: null, tokenExpiresAt: null,
candidateSetupDone: false, candidateSetupDone: false,
enterpriseVerified: false, enterpriseVerified: false,
}; };
persistAuth(next); persistAuth(next);
setState(next); setState(next);
void clearTokens();
}; };
const completeCandidateSetup = () => { const completeCandidateSetup = () => {
@ -172,6 +216,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isTokenValid, isTokenValid,
completeOnboarding, completeOnboarding,
login, login,
setSession,
logout, logout,
completeCandidateSetup, completeCandidateSetup,
completeEnterpriseVerify, completeEnterpriseVerify,

8
src/hooks/usePasswordLogin.ts

@ -1,9 +1,9 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { loginByPassword, type LoginResult } from '../api'; import { loginByPassword, type AuthTokens } from '../api';
export interface UsePasswordLoginReturn { export interface UsePasswordLoginReturn {
/** 手机号 + 密码登录,成功返回 LoginResult */ /** 手机号 + 密码登录,成功返回双 Token */
login: (phone: string, password: string) => Promise<LoginResult>; login: (phone: string, password: string) => Promise<AuthTokens>;
/** 登录进行中 */ /** 登录进行中 */
loading: boolean; loading: boolean;
/** 登录失败的错误(用于页面提示) */ /** 登录失败的错误(用于页面提示) */
@ -22,7 +22,7 @@ export function usePasswordLogin(): UsePasswordLoginReturn {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
return await loginByPassword(phone, password); return await loginByPassword({ phone, password });
} catch (e) { } catch (e) {
const err = e instanceof Error ? e : new Error('登录失败,请稍后重试'); const err = e instanceof Error ? e : new Error('登录失败,请稍后重试');
setError(err); setError(err);

12
src/hooks/useSmsCode.ts

@ -2,8 +2,12 @@ import { useCallback, useState } from 'react';
import { sendSmsCode } from '../api'; import { sendSmsCode } from '../api';
export interface UseSmsCodeReturn { export interface UseSmsCodeReturn {
/** 发送短信验证码(失败抛出,由页面提示) */ /**
send: (phone: string) => Promise<void>; * 发送短信验证码(失败抛出,由页面提示)。
* @param phone 手机号
* @param purpose 验证码用途(后端固定值):验证码登录传 'login',重置密码传 'reset-password'
*/
send: (phone: string, purpose: string) => Promise<void>;
/** 发送进行中(用于防重复点击) */ /** 发送进行中(用于防重复点击) */
loading: boolean; loading: boolean;
/** 发送失败的错误 */ /** 发送失败的错误 */
@ -18,11 +22,11 @@ export function useSmsCode(): UseSmsCodeReturn {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const send = useCallback(async (phone: string) => { const send = useCallback(async (phone: string, purpose: string) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
await sendSmsCode(phone); await sendSmsCode({ phone, purpose });
} catch (e) { } catch (e) {
const err = e instanceof Error ? e : new Error('验证码发送失败,请稍后重试'); const err = e instanceof Error ? e : new Error('验证码发送失败,请稍后重试');
setError(err); setError(err);

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

8
src/types/user.ts

@ -35,11 +35,13 @@ export interface UserStats {
/** /**
* 应用角色 * 应用角色
* - candidate:求职者 * - candidate:求职者(对应后端 STUDENT)
* - enterprise:企业 / HR * - enterprise:企业 / HR(对应后端 COMPANY)
* - admin:平台管理员(对应后端 ADMIN)
* 角色值一律来自 /auth/me 后端下发并经 mapBackendRole 映射,客户端不自行决定。
* 用于按角色下发不同的功能菜单,避免求职者端出现企业功能、反之亦然 * 用于按角色下发不同的功能菜单,避免求职者端出现企业功能、反之亦然
*/ */
export type AppRole = 'candidate' | 'enterprise'; export type AppRole = 'candidate' | 'enterprise' | 'admin';
/** 功能菜单项 */ /** 功能菜单项 */
export interface MenuItem { export interface MenuItem {

45
后端联调前端实现.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:`,**勿擅自改名**(会导致老用户本地态失效)。
Loading…
Cancel
Save