diff --git a/app.config.js b/app.config.js index 8006522..28621f0 100644 --- a/app.config.js +++ b/app.config.js @@ -77,6 +77,8 @@ export default { extra: { // 业务接口基地址(规范 §1)。默认生产 HTTPS 域名;联调/本地可用环境变量 API_BASE_URL 覆盖。 apiBaseUrl: process.env.API_BASE_URL || 'https://job.youthome.com.cn/api/v1', + // mock 开关(默认开):开发期不触网、不破坏 UI;联调期置 false 统一走真实后端。 + apiUseMock: true, // EAS 项目 ID:`eas init` 后会自动注入此处(与 updates.url 中的 PROJECT_ID 对应)。 eas: { projectId: 'REPLACE_WITH_PROJECT_ID', diff --git a/src/__tests__/job-api.test.ts b/src/__tests__/job-api.test.ts new file mode 100644 index 0000000..c5c6878 --- /dev/null +++ b/src/__tests__/job-api.test.ts @@ -0,0 +1,155 @@ +import { + fetchJobList, + fetchJobDetail, + fetchJobOverallScore, + fetchJobPreference, + fetchFavorites, + fetchJobProgress, +} from '../api/api'; +import { setMockMode, API_BASE_URL } from '../api/http'; + +// expo-constants:extra 为空 → 走默认生产域名;mock 开关由 setMockMode 控制 +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), + }; +}); + +// storage:内存实现,供 resolveJobPreference 等读取引导资料 +jest.mock('../lib/storage', () => { + const m = new Map(); + return { + storageGet: jest.fn(async (k: string) => m.get(k) ?? null), + storageSet: jest.fn(async (k: string, v: string) => { + m.set(k, v); + }), + storageRemove: jest.fn(async (k: string) => { + m.delete(k); + }), + }; +}); + +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: null, requestId: 'r', timestamp: 't' } }; + return { + status: spec.status, + ok: spec.status >= 200 && spec.status < 300, + headers: { get: () => 'r' }, + text: async () => JSON.stringify(spec.body), + } as unknown as Response; + }, + ); +} + +function okData(data: unknown): MockResponseSpec { + return { status: 200, body: { code: 200, message: 'ok', data, requestId: 'r', timestamp: 't' } }; +} + +beforeEach(() => { + fetchCalls = []; + responder = null; + installFetch(); +}); + +describe('岗位域 mock 开关', () => { + it('mock 模式:fetchJobDetail 返回本地详情且不发任何请求', async () => { + setMockMode(true); + const detail = await fetchJobDetail('1'); + expect(fetchCalls.length).toBe(0); + expect(detail.id).toBe('1'); + expect(typeof detail.level).toBe('string'); + }); + + it('真实模式:fetchJobDetail 打 /jobs/{id} 并返回 data', async () => { + setMockMode(false); + responder = () => okData({ id: '1', positionName: '后端工程师' }); + const detail = await fetchJobDetail('1'); + expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/jobs/1`); + expect(detail.positionName).toBe('后端工程师'); + }); + + it('真实模式:fetchJobOverallScore 打 /jobs/{id}/score', async () => { + setMockMode(false); + responder = () => okData({ totalScore: 88 }); + const score = await fetchJobOverallScore('2'); + expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/jobs/2/score`); + expect(score.totalScore).toBe(88); + }); + + it('真实模式:fetchJobPreference 打 /profile/me/job-preference', async () => { + setMockMode(false); + responder = () => okData({ city: '上海', position: '前端' }); + await fetchJobPreference(); + expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/profile/me/job-preference`); + }); + + it('真实模式:fetchFavorites 打 /profile/me/favorites', async () => { + setMockMode(false); + responder = () => okData([{ id: '1' }]); + const favs = await fetchFavorites(); + expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/profile/me/favorites`); + expect(favs[0].id).toBe('1'); + }); + + it('真实模式:fetchJobProgress 打 /tools/job-progress', async () => { + setMockMode(false); + responder = () => okData({ steps: [] }); + await fetchJobProgress(); + expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/tools/job-progress`); + }); + + it('真实模式:fetchJobList 拼装 query(数组逗号、undefined 跳过、分页键 size)', async () => { + setMockMode(false); + responder = () => okData({ jobs: [], total: 0 }); + await fetchJobList({ + sortBy: 'comprehensive', + salary: ['10-15K'], + experience: ['3-5年'], + }); + const url = fetchCalls[0].url; + expect(url.startsWith(`${API_BASE_URL}/jobs?`)).toBe(true); + expect(url).toContain('sortBy=comprehensive'); + expect(url).toContain('page=1'); + expect(url).toContain('size=10'); + expect(url).toContain('salary=10-15K'); + // 中文被 URLSearchParams 编码,解码后断言 + expect(decodeURIComponent(url)).toContain('experience=3-5年'); + // keyword / industry / city / education 未传 → 不出现在 query 中 + expect(url).not.toContain('keyword='); + expect(url).not.toContain('industry='); + }); + + it('mock 模式:fetchJobList 返回本地 mock 且分页字段齐全', async () => { + setMockMode(true); + const res = await fetchJobList({ sortBy: 'comprehensive' }); + expect(fetchCalls.length).toBe(0); + expect(Array.isArray(res.jobs)).toBe(true); + expect(typeof res.total).toBe('number'); + }); +}); diff --git a/src/__tests__/password.test.ts b/src/__tests__/password.test.ts index 8c61808..685ae52 100644 --- a/src/__tests__/password.test.ts +++ b/src/__tests__/password.test.ts @@ -5,7 +5,7 @@ import { validatePassword, validatePasswordConfirm, } from '../api/api'; -import { loginByPassword, sendSmsCode } from '../api'; +import { loginByPassword, sendSmsCode, setMockMode } from '../api'; import { PASSWORD_RULE_LABEL } from '../constants'; import type { PasswordRuleError } from '../types'; import { isValidPhone, isValidSmsCode } from '../utils'; @@ -111,6 +111,7 @@ describe('validators(手机号 / 验证码格式)', () => { describe('loginByPassword(手机号 + 密码登录 · 真实后端)', () => { beforeEach(() => { + setMockMode(false); (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(); }); @@ -163,6 +164,7 @@ describe('loginByPassword(手机号 + 密码登录 · 真实后端)', () => describe('sendSmsCode(发送验证码 · 真实后端)', () => { beforeEach(() => { + setMockMode(false); (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(); }); diff --git a/src/api/api.ts b/src/api/api.ts index 72d9707..f13a0a0 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -197,6 +197,7 @@ import { storageGet, storageSet, storageRemove } from '../lib/storage'; import { exportResumePdf } from '../lib/resumeExport'; import { clearCacheDir, readCacheBytes } from '../lib/cache'; import { getCarrierNumber, CarrierAuthError, MOCK_CARRIER_PHONE } from '../lib/carrier-sdk'; +import { isMockMode, request } from './http'; /** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */ export const API_BASE_URL: string = @@ -1035,12 +1036,8 @@ function mockRequest(data: T): Promise { export async function fetchJobList( params: JobListParams, ): Promise<{ jobs: Job[]; total: number }> { - // TODO: 真实请求(后端就绪后替换) - // const query = new URLSearchParams({ sortBy: params.sortBy, page: String(params.page ?? 1) }); - // const res = await fetch(`${API_BASE_URL}/api/jobs?${query}`, { method: 'GET' }); - // return res.json(); - - // 仅在「默认推荐视图」叠加偏好加分:用户主动选了城市或输入关键词时, + if (isMockMode()) { + // 仅在「默认推荐视图」叠加偏好加分:用户主动选了城市或输入关键词时, // 一律尊重用户的选择,不额外加分(也把回归面限制在默认视图内) const hasCityFilter = !!params.city && params.city !== ALL_CITY_LABEL; const hasKeyword = !!params.keyword?.trim(); @@ -1139,6 +1136,25 @@ export async function fetchJobList( resolve({ jobs, total }); }, MOCK_DELAY); }); + } + + // 真实后端:排序/筛选/搜索/分页交给后端,查询参数沿用客户端字段并映射分页键 + // (后端用 size 而非 pageSize;数组多选按逗号拼接)。响应结构以 OpenAPI 为准,此处为暂定契约。 + return request<{ jobs: Job[]; total: number }>('/jobs', { + method: 'GET', + auth: false, + query: { + sortBy: params.sortBy, + keyword: params.keyword, + industry: params.industry, + city: params.city, + salary: params.salary, + experience: params.experience, + education: params.education, + page: params.page ?? 1, + size: params.pageSize ?? 10, + }, + }); } /* ===================== 我的收藏 / 浏览历史(mock 数据) ===================== */ @@ -1162,8 +1178,11 @@ const MOCK_HISTORY: Omit[] = MOCK_JOBS.filter((job) => * @returns Promise[]> 收藏的职位数组 */ export async function fetchFavorites(): Promise[]> { - // TODO: const res = await fetch(`${API_BASE_URL}/api/user/favorites`, { method: 'GET' }); return res.json(); - return mockRequest(MOCK_FAVORITES); + if (isMockMode()) { + return mockRequest(MOCK_FAVORITES); + } + // 真实后端:当前用户收藏的职位(§5 STUDENT 旗下) + return request[]>('/profile/me/favorites', { method: 'GET' }); } /** @@ -1690,8 +1709,12 @@ async function resolveJobPreference(): Promise { * @remark 取值优先级见 `resolveJobPreference` */ export async function fetchJobPreference(): Promise { - // TODO: const res = await fetch(`${API_BASE_URL}/api/user/job-preference`, { method: 'GET' }); return res.json(); - return mockRequest(await resolveJobPreference()); + if (isMockMode()) { + // 引导采集阶段:偏好从 OnboardingProfile 派生(见 resolveJobPreference),未采集字段留空由页面渲染「未设置」 + return mockRequest(await resolveJobPreference()); + } + // 真实后端:/profile/me 旗下求职者偏好(§5 STUDENT 前缀) + return request('/profile/me/job-preference', { method: 'GET' }); } /** @@ -1713,24 +1736,24 @@ export async function fetchBadges(): Promise { * @returns Promise 岗位详情 */ export async function fetchJobDetail(id: string): Promise { - // TODO: 真实请求(后端就绪后替换) - // const res = await fetch(`${API_BASE_URL}/api/jobs/${id}`, { method: 'GET' }); - // return res.json(); - - const base = MOCK_JOBS.find((job) => job.id === id); - if (!base) throw new Error(`岗位不存在: ${id}`); - - // 详情 = 列表基础数据 + 详情页专属字段(无对应覆盖时用兜底值) - const detail: JobDetail = { - ...base, - level: scoreToLevel(base.score), - companySize: '1000-9999人', - status: '热招中', - views: 0, - salaryMonths: 13, - ...MOCK_JOB_DETAILS[id], - }; - return mockRequest(detail); + if (isMockMode()) { + const base = MOCK_JOBS.find((job) => job.id === id); + if (!base) throw new Error(`岗位不存在: ${id}`); + + // 详情 = 列表基础数据 + 详情页专属字段(无对应覆盖时用兜底值) + const detail: JobDetail = { + ...base, + level: scoreToLevel(base.score), + companySize: '1000-9999人', + status: '热招中', + views: 0, + salaryMonths: 13, + ...MOCK_JOB_DETAILS[id], + }; + return mockRequest(detail); + } + // 真实后端:公开岗位详情(§7) + return request(`/jobs/${id}`, { method: 'GET', auth: false }); } /** @@ -1741,24 +1764,27 @@ export async function fetchJobDetail(id: string): Promise { * @returns Promise 综合评分 */ export async function fetchJobOverallScore(id: string): Promise { - // TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/score`, { method: 'GET' }); return res.json(); - // 必须按 id 取该岗位自己的分:原先 `void id;` 直接返回固定的一份(恒 92 分 · 优秀), - // 于是列表卡上「产品经理 78 分 · 良好」点进去变成「92 分 · 优秀」,9 个岗位里只有第 1 个对得上。 - const score = jobScoreById(id); - const base = MOCK_JOB_OVERALL_SCORE.totalScore; - const factor = base > 0 ? score / base : 1; - return mockRequest({ - ...MOCK_JOB_OVERALL_SCORE, - totalScore: score, - level: JOB_LEVEL_LABEL[scoreToLevel(score)], - // 八维画像 mock 阶段只有这一份,按该岗位的实际综合分等比缩放后再画雷达图; - // 否则一个 65 分的岗位会配上一张最低值 78 分的雷达图,卡片自己和自己对不上。 - // 真实后端就绪后由接口按岗位返回各自的画像,这段缩放随之删除。 - dimensions: MOCK_JOB_OVERALL_SCORE.dimensions.map((dim) => ({ - ...dim, - value: Math.round(dim.value * factor), - })), - }); + if (isMockMode()) { + // 必须按 id 取该岗位自己的分:原先 `void id;` 直接返回固定的一份(恒 92 分 · 优秀), + // 于是列表卡上「产品经理 78 分 · 良好」点进去变成「92 分 · 优秀」,9 个岗位里只有第 1 个对得上。 + const score = jobScoreById(id); + const base = MOCK_JOB_OVERALL_SCORE.totalScore; + const factor = base > 0 ? score / base : 1; + return mockRequest({ + ...MOCK_JOB_OVERALL_SCORE, + totalScore: score, + level: JOB_LEVEL_LABEL[scoreToLevel(score)], + // 八维画像 mock 阶段只有这一份,按该岗位的实际综合分等比缩放后再画雷达图; + // 否则一个 65 分的岗位会配上一张最低值 78 分的雷达图,卡片自己和自己对不上。 + // 真实后端就绪后由接口按岗位返回各自的画像,这段缩放随之删除。 + dimensions: MOCK_JOB_OVERALL_SCORE.dimensions.map((dim) => ({ + ...dim, + value: Math.round(dim.value * factor), + })), + }); + } + // 真实后端:岗位综合评分(§7 公开读取;响应结构以 OpenAPI 为准) + return request(`/jobs/${id}/score`, { method: 'GET', auth: false }); } /** @@ -2856,8 +2882,11 @@ const MOCK_JOB_PROGRESS: JobProgress = { * @returns Promise 求职进度数据 */ export async function fetchJobProgress(): Promise { - // TODO: const res = await fetch(`${API_BASE_URL}/api/tools/job-progress`, { method: 'GET' }); return res.json(); - return mockRequest(MOCK_JOB_PROGRESS); + if (isMockMode()) { + return mockRequest(MOCK_JOB_PROGRESS); + } + // 真实后端:求职进度时间轴(公开工具数据,无需鉴权) + return request('/tools/job-progress', { method: 'GET', auth: false }); } /* ===================== 简历 ===================== */ diff --git a/src/api/auth.ts b/src/api/auth.ts index 7ed63fd..b623928 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,4 +1,4 @@ -import { request } from './http'; +import { request, isMockMode } from './http'; import type { AppRole } from '../types'; /* ===================== 鉴权接口(真实后端) ===================== @@ -50,6 +50,7 @@ export async function sendSmsCode(payload: SmsSendPayload): Promise { if (!isValidPhone(payload.phone)) { throw new Error('手机号格式不正确'); } + if (isMockMode()) return; await request('/auth/sms/send', { method: 'POST', auth: false, body: payload }); } @@ -63,6 +64,9 @@ export async function loginByPassword(payload: PasswordLoginPayload): Promise('/auth/password/login', { method: 'POST', auth: false, @@ -70,6 +74,15 @@ export async function loginByPassword(payload: PasswordLoginPayload): Promise { + if (isMockMode()) { + return mockAuthTokens(`sms_${payload.phone}`); + } return request('/auth/login', { method: 'POST', auth: false, body: payload }); } @@ -94,6 +110,9 @@ export interface CurrentUser { /** 拉取当前用户与角色(§4 步骤3):角色以后端为准并映射 */ export async function fetchCurrentUser(): Promise { + if (isMockMode()) { + return { id: 'mock-user', role: 'candidate' }; + } const data = await request<{ id: string; role: BackendRole } & Record>( '/auth/me', { method: 'GET' }, @@ -103,6 +122,7 @@ export async function fetchCurrentUser(): Promise { /** 退出登录(§4 步骤5):前端清会话由 AuthContext 负责,这里只通知后端 */ export async function requestLogout(): Promise { + if (isMockMode()) return; await request('/auth/logout', { method: 'POST' }); } @@ -127,6 +147,9 @@ export async function registerCompany(payload: CompanyRegisterPayload): Promise< if (!isValidPhone(payload.phone)) { throw new Error('手机号格式不正确'); } + if (isMockMode()) { + return mockAuthTokens(`company_${payload.phone}`); + } return request('/auth/company/register', { method: 'POST', auth: false, diff --git a/src/api/http.ts b/src/api/http.ts index 12c252a..ad82738 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -23,6 +23,24 @@ import { export const API_BASE_URL: string = Constants.expoConfig?.extra?.apiBaseUrl ?? 'https://job.youthome.com.cn/api/v1'; +/* ===================== mock 开关 ===================== + * 开发期(Expo Go / 联调前)默认开 mock:业务函数返回本地数据,不触网、不破坏 UI。 + * 联调期在 app.config.js 的 extra.apiUseMock 设 false,统一翻为真实请求。 + * 默认 true 是为了保持当前「app 全程 mock 可跑」的状态;翻开关即进入联调形态。 + */ +let mockMode: boolean = + (Constants.expoConfig?.extra?.apiUseMock as boolean | undefined) ?? true; + +/** 当前是否处于 mock 模式(业务函数据此决定是否直连后端) */ +export function isMockMode(): boolean { + return mockMode; +} + +/** 仅供单测切换;生产代码不要调用 */ +export function setMockMode(value: boolean): void { + mockMode = value; +} + /** 后端响应外层结构(§3) */ export interface ApiEnvelope { code: number; @@ -64,6 +82,8 @@ export interface RequestOptions { auth?: boolean; /** 超时(毫秒),默认 10000 */ timeoutMs?: number; + /** 查询参数;值为数组时按逗号拼接(后端多选用法),undefined 跳过 */ + query?: Record>; } /** 401 清会话回调:由 AuthContext 注册,刷新失败 / 无 refreshToken 时触发 */ @@ -94,6 +114,21 @@ interface InternalOptions extends RequestOptions { _isRefresh?: boolean; } +function buildQuery(query?: RequestOptions['query']): string { + if (!query) return ''; + const params = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => { + if (value === undefined) return; + if (Array.isArray(value)) { + if (value.length > 0) params.set(key, value.join(',')); + } else { + params.set(key, String(value)); + } + }); + const s = params.toString(); + return s ? `?${s}` : ''; +} + async function doRefresh(): Promise { if (refreshPromise) return refreshPromise; refreshPromise = (async () => { @@ -128,7 +163,7 @@ async function doRefresh(): Promise { } async function rawRequest(path: string, opts: InternalOptions): Promise { - const url = `${API_BASE_URL}${path}`; + const url = `${API_BASE_URL}${path}${buildQuery(opts.query)}`; const requestId = genRequestId(); const headers: Record = { 'Content-Type': 'application/json', diff --git a/src/api/index.ts b/src/api/index.ts index 444e5ec..69b386b 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -211,6 +211,8 @@ export { request, checkHealth, setUnauthorizedHandler, + isMockMode, + setMockMode, API_BASE_URL, } from './http'; export { ApiError } from './http'; diff --git a/后端联调前端实现.md b/后端联调前端实现.md index 92148de..f0d5b27 100644 --- a/后端联调前端实现.md +++ b/后端联调前端实现.md @@ -14,8 +14,9 @@ | 会话 | `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` 环境变量覆盖) | +| mock 开关 | `app.config.js` + `http.ts` | `extra.apiUseMock`(默认 `true`);`isMockMode()` 决定业务函数走 mock 还是真实请求;联调期置 `false` | -业务端点(`jobs` / `profile` / `messages` 等 100+ mock 函数)**仍走 mock**,按 AGENTS.md 约定后续逐个替换为真实 `request()` 调用即可,页面与 hook 零改动。 +业务端点按 **mock 开关** 分阶段落地:开发期(`apiUseMock=true`)返回本地数据不触网,联调期(`false`)直连后端。`jobs` / `profile` / `messages` 等 100+ mock 函数**逐个替换**为真实 `request()` 调用,页面与 hook 零改动。 ## 二、规范条款逐条对照 @@ -33,13 +34,37 @@ | §8 状态码语义 | `pickHttpMessage` 覆盖 400/401/403/404/409/429/500/503;网络异常降级为 `httpStatus=0` | | §10 安全 | 不写 Token/验证码/密码到日志或前端 DOM;`X-Request-Id` 用于联调报障 | -## 三、明确未做(下一阶段) +## 三、业务端点转换进度(mock 开关 + 岗位域) + +> 转换规则:保留函数签名与返回类型 `T`(即 AGENTS.md「保留签名、只换函数体」),`if (isMockMode())` 分支保留本地数据,真实分支走 `request(path, { auth, query })`。路径按规范 §7 重映射(旧 `@url` 多带 `/api` 前缀、未带 `/v1`,已校正)。 + +### 3.1 已完成(岗位域 · 候选侧读取) + +| 函数 | mock 分支 | 真实路径(§7) | 鉴权 | 说明 | +|---|---|---|---|---| +| `fetchJobList` | 本地筛选/排序/分页 | `GET /jobs` | 否 | query 映射:`pageSize→size`;数组多选逗号拼接;`keyword/industry/city/education` 等空值跳过 | +| `fetchJobDetail` | 本地详情 | `GET /jobs/{id}` | 否 | 公开岗位详情 | +| `fetchJobOverallScore` | 本地八维缩放 | `GET /jobs/{id}/score` | 否 | 综合评分;响应结构以 OpenAPI 为准 | +| `fetchJobPreference` | `resolveJobPreference()` 派生 | `GET /profile/me/job-preference` | 是 | 求职者偏好(§5 STUDENT 前缀) | +| `fetchFavorites` | 本地收藏子集 | `GET /profile/me/favorites` | 是 | 当前用户收藏 | +| `fetchJobProgress` | 本地进度 | `GET /tools/job-progress` | 否 | 求职进度工具数据 | + +> 鉴权列「是」= 默认带 Bearer;「否」= `auth:false` 匿名读取(§7 公开岗位)。 + +### 3.2 待转(下一阶段,按业务域分批) + +- **岗位详情子资源**:`fetchSalaryData` / `fetchEmployeeReviews` / `createJobReview` / `fetchCompareData`(仍处于 `/api/jobs/:id/...` 旧路径,需校正)。 +- **企业端岗位**:`fetchJobManagement` / `fetchJobPublishForm` / `fetchJobEvaluationManagement` / `fetchJobEvaluationEmptyState`(§7 未列,需 OpenAPI 确认前缀)。 +- **简历 / 资料**:`fetchResume` / `fetchUserProfile` / `fetchProfileXxx`(STUDENT 前缀)。 +- **消息 / 沟通**:`fetchConversations` / `fetchNotifications` / `fetchMessages`(§7 未列)。 +- **其余公开读取**:`fetchJobFilters` / `fetchJobSearch` 等若后端拆成独立端点,需补映射。 + +## 四、明确未做(下一阶段) -- **业务端点转真实**:`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:`,**勿擅自改名**(会导致老用户本地态失效)。