diff --git a/scripts/audit_coverage.py b/scripts/audit_coverage.py new file mode 100644 index 0000000..6aa088b --- /dev/null +++ b/scripts/audit_coverage.py @@ -0,0 +1,114 @@ +""" +前后端接口覆盖率审计。 + +用途:比对后端 OpenAPI(scripts/openapi.json)的全部操作与前端 src/api/*.ts 里 +真实发出的 request() 调用,得出「已接入 / 未接入」清单,按域分组统计。 + +匹配规则: +- 路径归一化:`{jobId}` 与模板串 `${jobId}` 一律折成 `{}`,避免占位符命名差异误判。 +- 方法:request 未显式传 method 时按 GET 计(http.ts 默认 GET)。 + +用法:python scripts/audit_coverage.py +""" +import json +import re +from collections import defaultdict + +# 权威端点源 = 后端给的 markdown 文档(scripts/openapi.json 是 9/28 的旧快照, +# 比 md 少 28 个端点,含 /auth/identity/select,拿它当基准会把已接接口误判成幽灵) +SPEC_MD = "docs/职来后端接口.md" +API_FILES = ["src/api/api.ts", "src/api/auth.ts", "src/api/http.ts"] + +HTTP_METHODS = {"get", "post", "put", "patch", "delete"} + + +def norm(path: str) -> str: + """把 {id} / ${id} 统一折成 {}""" + p = re.sub(r"\$\{[^}]*\}", "{}", path) + p = re.sub(r"\{[^}]*\}", "{}", p) + if not p.startswith("/"): + p = "/" + p + return p.rstrip("/") or "/" + + +def load_spec(): + """从后端 markdown 文档解析端点:`## GET /jobs/search(标题)` 行给出方法与路径""" + # md 结构是「## GET 中文标题」+ 下一行「GET /real/path」,标题行里的中文不是路径, + # 真正可请求的行就是行首 `METHOD /path` 那一行。 + ops = {} + entry_re = re.compile(r"^(GET|POST|PUT|PATCH|DELETE)\s+(/\S*)\s*$") + with open(SPEC_MD, encoding="utf-8") as f: + for ln in f: + m = entry_re.match(ln) + if not m: + continue + ops[(m.group(1).upper(), norm(m.group(2)))] = ln.strip() + return ops + + +def load_frontend(): + """从 api.ts / auth.ts 抽取 request() 调用:返回 [(method, normpath, funcname, file, line)] + + 注意:泛型参数常跨多行书写(如 request<{ accessToken: string;\n ... }>('/auth/login')), + 因此必须在「整文件文本」上匹配,逐行扫描会整片漏掉。 + """ + calls = [] + call_re = re.compile( + r"request\s*(?:<[^>]*>)?\s*\(\s*(?P['\"`])(?P[^'\"`]+)(?P=q)", + re.DOTALL, + ) + func_re = re.compile( + r"^\s*(?:export\s+)?(?:async\s+)?function\s+(?P\w+)\s*\(", re.MULTILINE + ) + for file in API_FILES: + with open(file, encoding="utf-8") as f: + text = f.read() + # 函数名 → 起始偏移,用于把调用归属到所在函数 + func_starts = [(m.start(), m.group("name")) for m in func_re.finditer(text)] + for cm in call_re.finditer(text): + path = norm(cm.group("path")) + after = text[cm.end() : cm.end() + 200] + mm = re.search(r"method:\s*['\"](GET|POST|PUT|PATCH|DELETE)['\"]", after) + method = mm.group(1) if mm else "GET" + name = "(module)" + for start, fname in func_starts: + if start <= cm.start(): + name = fname + else: + break + line = text.count("\n", 0, cm.start()) + 1 + calls.append((method, path, name, file, line)) + return calls + + +def main(): + ops = load_spec() + calls = load_frontend() + used = {(m, p) for m, p, *_ in calls} + + covered = sorted(k for k in ops if k in used) + missing = sorted(k for k in ops if k not in used) + phantom = sorted({(m, p, fn, f, ln) for m, p, fn, f, ln in calls if (m, p) not in ops}) + + print(f"后端端点总数: {len(ops)}") + print(f"前端 request() 调用数: {len(calls)}(去重路径 {len(used)})") + print(f"已接入: {len(covered)} 未接入: {len(missing)} 幽灵调用(后端无此端点): {len(phantom)}") + print() + + print("=== 幽灵调用(前端发了但后端 spec 没有,必改)===") + for m, p, fn, f, ln in phantom: + print(f" {m:6} {p:52} {fn} ({f}:{ln})") + print() + + by_domain = defaultdict(list) + for m, p in missing: + by_domain[p.strip("/").split("/")[0]].append((m, p)) + print("=== 未接入端点(按域)===") + for d in sorted(by_domain, key=lambda k: -len(by_domain[k])): + print(f"\n[{d}] {len(by_domain[d])} 个") + for m, p in sorted(by_domain[d]): + print(f" {m:6} {p}") + + +if __name__ == "__main__": + main() diff --git a/scripts/live_probe.py b/scripts/live_probe.py new file mode 100644 index 0000000..fb63a6c --- /dev/null +++ b/scripts/live_probe.py @@ -0,0 +1,203 @@ +""" +真实环境批量联调探测。 + +用途:拿真实 token 打后端 GET 端点,记录 HTTP 状态 / 业务 code / data 结构, +产出「前端已接但后端报错」与「后端已有但前端未接且能拿到真实字段」两类事实, +作为联调问题清单的依据。 + +占位符 id 通过先打列表端点动态取得(/jobs/search → jobId 等),取不到就跳过并标记 need-id。 + +用法:python scripts/live_probe.py +""" +import json +import re +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor + +BASE = "https://job.youthome.com.cn/api/v1" +PHONE = "19900009021" +PASSWORD = "ZhilaiTest!2026#A" +SPEC_MD = "docs/职来后端接口.md" + +ID_KEYS = ("id", "jobId", "applicationId", "conversationId", "interviewId", + "companyId", "candidateId", "reviewId", "notificationId", "taskId", + "attemptId", "offerId", "reportId", "blockId", "messageId", "userId", + "itemId", "cityCode", "calculationId", "replyId", "appealId", "knowledgeId") + + +def req(method, path, token=None, body=None, timeout=15): + url = BASE + path + data = json.dumps(body).encode() if body is not None else None + r = urllib.request.Request(url, data=data, method=method) + r.add_header("Content-Type", "application/json") + if token: + r.add_header("Authorization", "Bearer " + token) + try: + with urllib.request.urlopen(r, timeout=timeout) as resp: + raw = resp.read().decode(errors="replace") + status = resp.status + except urllib.error.HTTPError as e: + raw = e.read().decode(errors="replace") + status = e.code + except Exception as e: # 网络层 / 超时 + return {"http": None, "err": str(e), "code": None, "data": None} + try: + j = json.loads(raw) + except Exception: + return {"http": status, "err": raw[:120], "code": None, "data": None} + if isinstance(j, dict): + return {"http": status, "code": j.get("code"), "message": j.get("message"), + "data": j.get("data")} + return {"http": status, "code": None, "data": j} + + +def shape(value, depth=0): + """递归描述返回结构(只到 2 层,够看字段命名)""" + if depth > 2: + return "..." + if isinstance(value, dict): + return {k: shape(v, depth + 1) for k, v in list(value.items())[:14]} + if isinstance(value, list): + if not value: + return "[]" + return [shape(value[0], depth + 1)] + return type(value).__name__ + + +def load_endpoints(method_filter=None): + out = [] + entry_re = re.compile(r"^(GET|POST|PUT|PATCH|DELETE)\s+(/\S*)\s*$") + with open(SPEC_MD, encoding="utf-8") as f: + for ln in f: + m = entry_re.match(ln) + if not m: + continue + if method_filter and m.group(1) not in method_filter: + continue + out.append((m.group(1), m.group(2))) + return sorted(set(out)) + + +def pick_ids(token): + """从列表端点抓真实 id,供带占位符的端点使用""" + ids = {} + plan = [ + ("/jobs/search?page=1&pageSize=1", "jobId"), + ("/applications/me?page=1&pageSize=1", "applicationId"), + ("/conversations", "conversationId"), + ("/notifications?page=1&pageSize=1", "notificationId"), + ("/offers?page=1&pageSize=1", "offerId"), + ("/assessments/me", "attemptId"), + ("/reports/me?page=1&pageSize=1", "reportId"), + ("/blocks/me", "blockId"), + ("/companies/me", "companyId"), + ("/interviews/me?page=1&pageSize=1", "interviewId"), + ] + for path, key in plan: + res = req("GET", path, token) + data = res.get("data") + if isinstance(data, dict): + for listkey in ("items", "records", "list", "content"): + if isinstance(data.get(listkey), list): + data = data[listkey] + break + if isinstance(data, list) and data: + first = data[0] + if isinstance(first, dict): + ids[key] = first.get("id") or first.get(key) + elif isinstance(data, dict): + ids[key] = data.get("id") + if ids.get(key): + ids[key.split("Id")[0] + "Id"] = ids[key] + # jobId 兜底:/jobs 列表 + if not ids.get("jobId"): + res = req("GET", "/jobs?page=1&pageSize=1", token) + d = res.get("data") + if isinstance(d, dict): + for k in ("items", "records", "list", "content"): + if isinstance(d.get(k), list): + d = d[k] + break + if isinstance(d, list) and d and isinstance(d[0], dict): + ids["jobId"] = d[0].get("id") + return ids + + +def fill(path, ids): + """把 {xxx} 占位符换成真实 id;换不掉返回 None""" + def sub(m): + name = m.group(1) + for k in (name, name + "Id"): + if ids.get(k): + return str(ids[k]) + # 退而求其次:任意同名前缀 + base = name.replace("Id", "") + for k, v in ids.items(): + if k.replace("Id", "") == base and v: + return str(v) + return None + filled = re.sub(r"\{([^}]+)\}", lambda m: sub(m) or "{" + m.group(1) + "}", path) + return None if "{" in filled else filled + + +def main(): + login = req("POST", "/auth/password/login", body={"phone": PHONE, "password": PASSWORD}) + token = (login.get("data") or {}).get("accessToken") + if not token: + print("登录失败:", login) + return + print(f"登录成功 role={(login.get('data') or {}).get('role')} userId={(login.get('data') or {}).get('userId')}") + + ids = pick_ids(token) + print("抓到的 id:", {k: v for k, v in ids.items() if v}) + + endpoints = load_endpoints({"GET"}) + targets = [] + skipped = [] + for method, path in endpoints: + if "{" in path: + filled = fill(path, ids) + if filled: + targets.append((method, path, filled)) + else: + skipped.append(path) + else: + targets.append((method, path, path)) + + print(f"\nGET 端点 {len(endpoints)} 个:可探测 {len(targets)},缺 id 跳过 {len(skipped)}") + + results = [] + + def probe(item): + method, raw, filled = item + res = req(method, filled, token) + return { + "method": method, + "path": raw, + "url": filled, + "http": res.get("http"), + "code": res.get("code"), + "message": res.get("message"), + "err": res.get("err"), + "shape": shape(res.get("data")) if res.get("http") == 200 else None, + } + + with ThreadPoolExecutor(max_workers=8) as ex: + results = list(ex.map(probe, targets)) + + ok = [r for r in results if r["http"] == 200 and r["code"] in (200, 0, None)] + bad = [r for r in results if r not in ok] + print(f"\n探测完成:正常 {len(ok)} / 异常 {len(bad)}") + print("\n=== 异常端点(HTTP != 200 或 code 非 200)===") + for r in sorted(bad, key=lambda x: x["path"]): + print(f" {r['path']:58} http={r['http']} code={r['code']} msg={(r.get('message') or r.get('err') or '')[:60]}") + + with open(".workbuddy/tmp/live_probe.json", "w", encoding="utf-8") as f: + json.dump({"ids": ids, "results": results, "skipped": skipped}, f, + ensure_ascii=False, indent=2) + print("\n完整结果: .workbuddy/tmp/live_probe.json") + + +if __name__ == "__main__": + main() diff --git a/src/__tests__/backend-bridge-write.test.ts b/src/__tests__/backend-bridge-write.test.ts index 28526a6..cf92a84 100644 --- a/src/__tests__/backend-bridge-write.test.ts +++ b/src/__tests__/backend-bridge-write.test.ts @@ -87,14 +87,26 @@ async function expectRequest( describe('写入函数真实态:方法 + 相对路径 + body(仅已对齐函数)', () => { beforeEach(() => setMockMode(false)); - test('saveJobPreference → PUT /profile/me/preferences', async () => { - const preference = { city: '北京', salary: '15k' } as any; + test('saveJobPreference → PUT /profile/me/preferences(单值中文 → 多选数组 + token)', async () => { + const preference = { + position: '前端', + city: '北京', + salary: '15k', + workType: '全职', + status: '离职-随时到岗', + } as any; const opts = await expectRequest( () => saveJobPreference(preference), '/profile/me/preferences', 'PUT', ); - expect(JSON.parse(opts.body)).toEqual(preference); + // 后端偏好是「多选数组 + 英文 token」,与前端单值中文字段不同构,必须转换后发送; + // salary / status 后端无对应字段,不发送(等后端补齐后再带上) + expect(JSON.parse(opts.body)).toEqual({ + preferredJobTypes: ['前端'], + preferredCities: ['北京'], + employmentTypes: ['FULL_TIME'], + }); }); }); diff --git a/src/__tests__/job-api.test.ts b/src/__tests__/job-api.test.ts index 9e937b1..e22eb43 100644 --- a/src/__tests__/job-api.test.ts +++ b/src/__tests__/job-api.test.ts @@ -3,6 +3,7 @@ import { fetchJobDetail, fetchJobOverallScore, fetchJobPreference, + saveJobPreference, fetchFavorites, fetchJobProgress, fetchSalaryData, @@ -95,10 +96,25 @@ describe('岗位域 mock 开关', () => { it('真实模式:fetchJobDetail 打 /jobs/public/{id}(公开详情匿名可用)', async () => { setMockMode(false); - responder = () => okData({ id: '1', positionName: '后端工程师' }); + // 后端返回的是岗位 DTO(title / company.name / 薪资单位元),不是前端 Job 形状 + responder = () => + okData({ + id: 1, + title: '后端工程师', + city: '武汉', + salaryMin: 12000, + salaryMax: 20000, + status: 'PUBLISHED', + company: { id: 48, name: '来也科技', verified: true }, + }); const detail = await fetchJobDetail('1'); expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/jobs/public/1`); expect(detail.positionName).toBe('后端工程师'); + expect(detail.companyName).toBe('来也科技'); + // 后端单位是元/月,前端按 K/月 展示 + expect(detail.salaryMin).toBe(12); + expect(detail.salaryMax).toBe(20); + expect(detail.status).toBe('招聘中'); }); it('真实模式:fetchJobOverallScore 打 /jobs/{id}/score', async () => { @@ -109,11 +125,44 @@ describe('岗位域 mock 开关', () => { expect(score.totalScore).toBe(88); }); - it('真实模式:fetchJobPreference 打 /profile/me/preferences', async () => { + it('真实模式:fetchJobPreference 打 /profile/me/preferences 并把多选数组取首项', async () => { setMockMode(false); - responder = () => okData({ city: '上海', position: '前端' }); - await fetchJobPreference(); + // 后端 DTO:多选数组 + 英文 token,与前端单值中文字段不同构 + responder = () => + okData({ + preferredJobTypes: ['前端', '后端'], + preferredCities: ['上海', '北京'], + preferredIndustries: ['互联网'], + employmentTypes: ['FULL_TIME'], + remoteAllowed: false, + }); + const pref = await fetchJobPreference(); expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/profile/me/preferences`); + expect(pref.position).toBe('前端'); + expect(pref.city).toBe('上海'); + expect(pref.workType).toBe('全职'); + // 后端不下发期望薪资与求职状态:留空由页面渲染「未设置」,不拿 mock 顶替 + expect(pref.salary).toBe(''); + expect(pref.status).toBe(''); + }); + + it('真实模式:saveJobPreference 反向映射成数组 + token 再 PUT', async () => { + setMockMode(false); + responder = () => okData(null); + await saveJobPreference({ + position: '前端', + city: '上海', + salary: '10-15K', + workType: '全职', + status: '离职-随时到岗', + }); + const call = fetchCalls[0]; + expect(call.url).toBe(`${API_BASE_URL}/profile/me/preferences`); + expect(call.init.method).toBe('PUT'); + const body = JSON.parse(String(call.init.body)); + expect(body.preferredCities).toEqual(['上海']); + expect(body.employmentTypes).toEqual(['FULL_TIME']); + expect(body.preferredJobTypes).toEqual(['前端']); }); it('真实模式:fetchFavorites 打 /jobs/favorites 并从 items 解包', async () => { @@ -167,7 +216,8 @@ describe('岗位域 mock 开关', () => { expect(url.startsWith(`${API_BASE_URL}/jobs/search?`)).toBe(true); // sortBy: 'comprehensive' → 后端 sort: 'relevance' expect(url).toContain('sort=relevance'); - expect(url).toContain('page=1'); + // 后端是 0-based 分页:前端第 1 页发出去必须是 page=0,否则第一页永远取不到 + expect(url).toContain('page=0'); expect(url).toContain('size=10'); expect(url).toContain('salary=10-15K'); expect(decodeURIComponent(url)).toContain('experience=3-5年'); diff --git a/src/api/api.ts b/src/api/api.ts index 0be246f..9983bfc 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -16,6 +16,8 @@ import Constants from 'expo-constants'; import type { AbilityAssessment, + BackendJobItem, + BackendJobReviewSummary, AITool, Article, Badge, @@ -172,6 +174,7 @@ import type { WechatBindPhonePayload, WechatSession, ChangePasswordPayload, + PasswordOperationResult, PasswordResetPayload, PasswordRuleError, CarrierFailureReason, @@ -193,6 +196,8 @@ import { CARRIER_SDK_TIMEOUT_MS, CARRIER_TOKEN_TTL_MS, DEFAULT_SOCIAL_PROFILE, + EMPLOYMENT_TYPE_LABEL, + EMPLOYMENT_TYPE_TOKEN, JOB_MATCH_CITY_BONUS, JOB_MATCH_POSITION_BONUS, JOB_MATCH_SALARY_BONUS, @@ -219,6 +224,7 @@ import { exportResumePdf } from '../lib/resumeExport'; import { clearCacheDir, readCacheBytes } from '../lib/cache'; import { getCarrierNumber, CarrierAuthError, MOCK_CARRIER_PHONE } from '../lib/carrier-sdk'; import { isMockMode, request, ApiError } from './http'; +import { loginBySms, type AuthLoginData } from './auth'; /** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */ export const API_BASE_URL: string = @@ -1066,6 +1072,77 @@ function scoreToLevel(score: number): JobLevel { return 'medium'; } +/* ===================== 分页页码口径 ===================== + * 实测(2026-09-30):后端是 **0-based** 分页(Spring Data 默认)。 + * /jobs/search?page=0 → id 168 起;page=1 → id 165 起(跳过前 3 条) + * /applications/me?page=0 → 1 条;page=1 → 0 条(首页直接空) + * 前端列表一律按 1-based 传,直接把 page=1 发出去会**丢掉第一页**。 + * 故所有真实请求在此统一减 1,响应里的 page 再 +1 还原。 + * 后端若统一成 1-based,改这两个函数即可,无需翻调用方。 + */ +function toBackendPage(page?: number): number { + return Math.max(0, (page ?? 1) - 1); +} + +function toFrontendPage(page?: number): number { + return (page ?? 0) + 1; +} + +/** 后端岗位状态 token → 中文展示(未覆盖的状态留空,页面按空值处理) */ +function mapBackendJobStatus(status?: string): string { + switch (status) { + case 'PUBLISHED': + return '招聘中'; + case 'CLOSED': + return '已关闭'; + case 'DRAFT': + return '未发布'; + default: + return ''; + } +} + +/** + * 后端薪资(元/月)→ 前端展示单位(K/月)。 + * + * 后端 /jobs/search 下发的是 `salaryMin: 5651`(元),前端卡片按「5.7K」渲染, + * 单位不换算会直接显示成「5651K」。空值/0 一律回落 0,由页面按空值处理。 + */ +function yuanToK(value?: number | null): number { + if (!value || value <= 0) return 0; + return Math.round(value / 1000); +} + +/** + * 后端岗位 DTO → 前端 Job。 + * + * 真实分支的**唯一**转换入口:列表(/jobs/search)、收藏(/jobs/favorites)、 + * 详情(/jobs/public/{id})三处都走它,避免同一个后端字段在不同页面被映射成不同值。 + * + * 后端当前不返回这些字段,一律留空由页面按空值渲染(不拿 mock 顶替): + * - experience / education / industry / benefits:后端岗位表无对应字段 + * - score:列表接口不下发匹配分,综合分需另行取 /jobs/{id}/personalized-review-score; + * 此处给 0,等级由 scoreToLevel(0) 得出,页面按「暂无评分」处理。 + */ +function mapBackendJobItem(item: BackendJobItem): Omit { + const city = item.city ?? ''; + return { + id: String(item.id), + positionName: item.title ?? '', + companyName: item.company?.name ?? '', + // 后端只有城市,没有详细地址;与公司名同源展示,避免卡片出现空行 + companyLocation: city, + salaryMin: yuanToK(item.salaryMin), + salaryMax: yuanToK(item.salaryMax), + score: 0, + experience: '', + education: '', + industry: '', + city, + benefits: [], + }; +} + /** * 岗位 id → 该岗位的分数。 * @@ -1213,7 +1290,7 @@ export async function fetchJobList( size: number; totalPages: number; page: number; - items: Job[]; + items: BackendJobItem[]; }>('/jobs/search', { method: 'GET', auth: false, @@ -1225,7 +1302,7 @@ export async function fetchJobList( salary: params.salary, experience: params.experience, education: params.education, - page: params.page ?? 1, + page: toBackendPage(params.page), size: params.pageSize ?? 10, }, }); @@ -1233,8 +1310,8 @@ export async function fetchJobList( // 后端有数据就用后端;空集回退 mock(seed 还没补) if (data.items && data.items.length > 0) { const jobs = data.items.map((job) => ({ - ...job, - level: scoreToLevel(job.score ?? 0), + ...mapBackendJobItem(job), + level: scoreToLevel(0), })); return { jobs, total: data.total }; } @@ -1338,8 +1415,8 @@ export async function fetchFavorites(): Promise[]> { } // 真实后端:当前用户收藏的职位(§5 STUDENT 旗下) // 后端分页信封为 { total, size, totalPages, page, items },需从 items 取列表(P2-2 修正) - const res = await request<{ items: Omit[] }>('/jobs/favorites', { method: 'GET' }); - return res.items ?? []; + const res = await request<{ items: BackendJobItem[] }>('/jobs/favorites', { method: 'GET' }); + return (res.items ?? []).map(mapBackendJobItem); } /** @@ -1762,7 +1839,7 @@ export async function fetchMyApplications(params?: { method: 'GET', auth: true, query: { - page: params?.page ?? 1, + page: toBackendPage(params?.page), size: params?.size ?? 20, // 前端小写联合类型 → 后端大写 token;直接发小写后端筛不出任何结果 ...(params?.status ? { status: toBackendApplicationStatus(params.status) } : {}), @@ -1777,7 +1854,7 @@ export async function fetchMyApplications(params?: { })), total: data.total, size: data.size, - page: data.page, + page: toFrontendPage(data.page), totalPages: data.totalPages, }; } @@ -2011,7 +2088,7 @@ export async function fetchCompanyApplications( method: 'GET', auth: true, query: { - page, + page: toBackendPage(page), size, ...(query?.jobId ? { jobId: query.jobId } : {}), ...(query?.status ? { status: toBackendApplicationStatus(query.status) } : {}), @@ -2029,7 +2106,7 @@ export async function fetchCompanyApplications( })), total: data.total, size: data.size, - page: data.page, + page: toFrontendPage(data.page), totalPages: data.totalPages, }; } @@ -2245,18 +2322,47 @@ export async function deleteResume(): Promise { /* ===================== Password set/change(后端有,前端之前没接) ===================== */ /** - * 首次设置登录密码(已注册但从未设置过密码的学生) + * 首次设置登录密码(已注册但从未设置过密码的学生)。 + * + * 契约 `POST /auth/password/set` 的 body 是 `{smsCode, newPassword}` —— + * **必须带短信验证码**。早先只发 `{password}`,既缺验证码、字段名也不对, + * 真实后端下这个接口从未成功过(mock 分支直接 return 把它掩盖了)。 + * * @method POST * @url /auth/password/set - * @param password 新密码 - */ -export async function setFirstPassword(password: string): Promise { - if (isMockMode()) return; - await request('/auth/password/set', { + * @param payload.smsCode 短信验证码(purpose = password-reset) + * @param payload.newPassword 新密码 + * @returns 密码操作结果(无 Token) + */ +export async function setFirstPassword(payload: { + smsCode: string; + newPassword: string; +}): Promise { + if (!isValidSmsCode(payload.smsCode)) { + throw new Error('验证码不正确'); + } + const ruleError = validatePassword(payload.newPassword); + if (ruleError) { + throw new Error(getPasswordRuleMessage(ruleError)); + } + if (isMockMode()) { + return mockRequest({ + passwordConfigured: true, + reauthenticationRequired: false, + }); + } + const raw = await request<{ + passwordConfigured?: boolean; + reauthenticationRequired?: boolean; + }>('/auth/password/set', { method: 'POST', auth: true, - body: { password }, + body: { smsCode: payload.smsCode, newPassword: payload.newPassword }, }); + return { + passwordConfigured: raw.passwordConfigured ?? true, + reauthenticationRequired: raw.reauthenticationRequired ?? false, + }; } /** @@ -2621,11 +2727,50 @@ export async function fetchJobPreference(): Promise { // 引导采集阶段:偏好从 OnboardingProfile 派生(见 resolveJobPreference),未采集字段留空由页面渲染「未设置」 return mockRequest(await resolveJobPreference()); } - // 真实后端:/profile/me 旗下求职者偏好(§5 STUDENT 前缀)。 - // 与 resolveJobPreference 一致:合并 EMPTY_JOB_PREFERENCE 兜底,避免后端返回缺字段的偏好对象 - // (如仅 {city:'上海'})导致 position/salary/workType/status 为 undefined、页面派生值出错。 - const res = await request>('/profile/me/preferences', { method: 'GET' }); - return { ...EMPTY_JOB_PREFERENCE, ...res }; + // 真实后端:/profile/me 旗下求职者偏好。 + // 后端 DTO 与前端 JobPreference 完全不同构:后端是「多选数组 + 英文 token」 + // { preferredJobTypes[], preferredCities[], preferredIndustries[], employmentTypes[], remoteAllowed } + // 前端是「单值中文串」{ position, city, salary, workType, status }。 + // 直接 spread 会把数组塞进 string 字段(渲染成 "上海,北京" 甚至 [object Object]), + // 故必须逐字段取首项并翻译 token;后端没有的 salary / status 留空,由页面渲染「未设置」。 + const res = await request('/profile/me/preferences', { method: 'GET' }); + return { + ...EMPTY_JOB_PREFERENCE, + position: firstOf(res?.preferredJobTypes), + city: firstOf(res?.preferredCities), + workType: employmentTypeToLabel(firstOf(res?.employmentTypes)), + }; +} + +/** 后端偏好 DTO(GET/PUT /profile/me/preferences) */ +interface BackendJobPreference { + preferredJobTypes?: string[]; + preferredCities?: string[]; + preferredIndustries?: string[]; + /** 雇佣类型英文 token:FULL_TIME / PART_TIME / INTERN */ + employmentTypes?: string[]; + remoteAllowed?: boolean; +} + +/** 取多选数组首项;空数组/undefined 一律空串(前端是单值字段,不拼逗号串) */ +function firstOf(values?: string[]): string { + return values && values.length > 0 ? String(values[0]) : ''; +} + +/** 雇佣类型 token → 中文;未收录的 token 原样返回,避免显示为空白 */ +function employmentTypeToLabel(token: string): string { + return token ? EMPLOYMENT_TYPE_LABEL[token] ?? token : ''; +} + +/** 中文 → 雇佣类型 token(写回后端用) */ +function employmentLabelToToken(label: string): string { + return label ? EMPLOYMENT_TYPE_TOKEN[label] ?? label : ''; +} + +/** 单值 → 后端多选数组(空串给空数组,不发 [''] 这种脏数据) */ +function toArray(value: string): string[] { + const v = (value ?? '').trim(); + return v ? [v] : []; } /** @@ -2668,8 +2813,17 @@ export async function fetchJobDetail(id: string): Promise { } // 真实后端:学生端公开岗位详情走 /jobs/public/{id}(匿名可用)。 // /jobs/{id} 是企业端看自己发布的岗位详情(需 COMPANY 鉴权),不能混用。 - const detail = await request(`/jobs/public/${id}`, { method: 'GET', auth: false }); - return { ...detail, level: scoreToLevel(detail.score ?? 0) }; + const raw = await request(`/jobs/public/${id}`, { method: 'GET', auth: false }); + const base = mapBackendJobItem(raw); + return { + ...base, + level: scoreToLevel(0), + // 详情页专属字段后端当前不下发,留空由页面按空值渲染(companySize/views/salaryMonths 同理) + companySize: '', + status: mapBackendJobStatus(raw.status), + views: 0, + salaryMonths: 0, + }; } /** @@ -2904,7 +3058,19 @@ export async function fetchJobReviewSummary(jobId: string): Promise t); return mockRequest({ averageRating, total, distribution, topTags }); } - return request(`/jobs/${jobId}/reviews/summary`); + // 后端字段名与前端不同构:totalReviews vs total、ratingDistribution{rating5..} vs distribution[]。 + // 直接 cast 会让「共 N 条评价」显示成 undefined、星级分布全空。 + const raw = await request(`/jobs/${jobId}/reviews/summary`); + const dist = raw?.ratingDistribution ?? {}; + return { + averageRating: raw?.averageRating ?? 0, + total: raw?.totalReviews ?? 0, + distribution: [5, 4, 3, 2, 1].map((rating) => ({ + rating, + count: dist[`rating${rating}`] ?? 0, + })), + topTags: raw?.topTags ?? [], + }; } /** @@ -9659,29 +9825,34 @@ export async function recallMessage( /* ===================== 账号:手机号 + 验证码登录 ===================== */ -export interface LoginResult { - /** 登录令牌(mock 态为占位串,真实后端返回 JWT 等) */ - token: string; - /** 登录手机号 */ - phone: string; -} +/** + * 登录结果 = 后端下发的完整会话(双 Token + 身份 + 是否还需选身份)。 + * 直接复用 auth 层的 `AuthLoginData`,不再自造一个只有 `{token, phone}` 的形状 —— + * 那正是「页面以为登录成功、请求却带不出 Bearer」的根源。 + */ +export type LoginResult = AuthLoginData; /** * 手机号 + 短信验证码登录(登录即注册,二合一)。 + * + * 真实实现委托给 `loginBySms`(契约 `POST /auth/login`,body 含 purpose)。 + * 此前本函数**没有任何 mock 开关**,无条件返回 `mock_token_`: + * 验证码填错也照样「登录成功」,直接把用户放进身份选择页。 + * * @method POST - * @url /api/auth/login-by-phone + * @url /auth/login * @param phone 手机号(11 位) * @param code 短信验证码(6 位) - * @returns 登录结果(含 token) + * @returns 完整会话;验证码错误/过期由后端拒绝并原样抛出 */ -export function loginByPhone(phone: string, code: string): Promise { +export function loginByPhone(phone: string, code: string): Promise { if (!isValidPhone(phone)) { return Promise.reject(new Error('手机号格式不正确')); } if (!isValidSmsCode(code)) { return Promise.reject(new Error('验证码不正确')); } - return mockRequest({ token: `mock_token_${phone}`, phone }); + return loginBySms({ phone, code, purpose: 'login' }); } /** @@ -9764,17 +9935,30 @@ export async function fetchCarrierPhone(): Promise { * 取号拿到的号码只在端上做展示,**可信号码以后端向运营商换取的结果为准**, * 所以返回的 phone 由接口层给出,页面拿它写回资料,不自己拼预取到的号码。 * + * **注意:本接口在后端契约中不存在**(查遍《职来后端接口.md》无登录类运营商端点), + * 故当前只有 mock 分支,翻真实开关也不会触网 —— 避免对着一个不存在的路径发请求。 + * 真实接入需先与后端确认端点与 body,届时补真实分支。 + * * @method POST - * @url /api/auth/login-by-carrier + * @url 无契约(待后端确认) * @param carrierToken 取号接口下发的一次性鉴权令牌(空串表示没取到号,直接判失败) - * @returns Promise 登录结果(含 token 与已核验的手机号) + * @returns Promise 登录结果(含会话与已核验的手机号) */ export function loginByCarrier(carrierToken: string): Promise { - // TODO: const res = await fetch(`${API_BASE_URL}/api/auth/login-by-carrier`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ carrierToken }) }); return res.json(); if (!carrierToken) { return Promise.reject(new Error('本机号码未取到,请重新获取或改用验证码登录')); } - return mockRequest({ token: `mock_token_carrier_${carrierToken}`, phone: MOCK_CARRIER_PHONE }); + // mock 形态:与验证码登录返回同一形状(双 Token + 角色),让页面只有一条会话写入路径 + return mockRequest({ + userId: 0, + phone: MOCK_CARRIER_PHONE, + nickname: 'Mock 用户', + role: 'STUDENT', + accessToken: `mock_access_carrier_${carrierToken}`, + refreshToken: `mock_refresh_carrier_${carrierToken}`, + expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, + identitySelectionRequired: false, + }); } /* ===================== 账号:密码规则(登录/重置/改密三处共用) ===================== */ @@ -9824,59 +10008,100 @@ export function getPasswordRuleMessage(error: PasswordRuleError): string { * 重置 / 首次设置登录密码(忘记密码与首次设密码共用同一入口)。 * * 「登录即注册」下用户天生没有密码,因此首次获得密码走的就是本接口(忘记密码)。 - * 重置成功后直接下发登录态,避免用户重置完再登录一次。 + * **本接口不返回 Token**:契约 `POST /auth/password/reset` 的响应体是 + * `PasswordOperationResponse{passwordConfigured, reauthenticationRequired}`。 + * 早先这里返回伪造的 `LoginResult` 并让页面写登录态 —— 等于把「密码改成功」 + * 当成「后端刚颁发了一个会话」,而那个会话从未存在过。现在返回真实响应, + * 由页面按 `reauthenticationRequired` 决定是否要求重新登录。 * * @method POST - * @url /api/auth/reset-password - * @param payload 手机号 + 短信验证码 + 新密码 - * @returns 登录结果(含 token) + * @url /auth/password/reset + * @param payload 手机号 + 短信验证码 + 新密码(映射为契约字段 smsCode / newPassword) + * @returns 密码操作结果(无 Token) */ -export function resetPassword(payload: PasswordResetPayload): Promise { +export async function resetPassword( + payload: PasswordResetPayload, +): Promise { if (!isValidPhone(payload.phone)) { - return Promise.reject(new Error('手机号格式不正确')); + throw new Error('手机号格式不正确'); } if (!isValidSmsCode(payload.code)) { - return Promise.reject(new Error('验证码不正确')); + throw new Error('验证码不正确'); } const ruleError = validatePassword(payload.password); if (ruleError) { - return Promise.reject(new Error(getPasswordRuleMessage(ruleError))); + throw new Error(getPasswordRuleMessage(ruleError)); } - return mockRequest({ - token: `mock_token_${payload.phone}`, - phone: payload.phone, + if (isMockMode()) { + return mockRequest({ + passwordConfigured: true, + reauthenticationRequired: false, + }); + } + const raw = await request<{ + passwordConfigured?: boolean; + reauthenticationRequired?: boolean; + }>('/auth/password/reset', { + method: 'POST', + auth: false, + body: { + phone: payload.phone, + smsCode: payload.code, + newPassword: payload.password, + }, }); + return { + passwordConfigured: raw.passwordConfigured ?? true, + reauthenticationRequired: raw.reauthenticationRequired ?? false, + }; } /** * 修改登录密码(已登录态,需校验原密码)。 * + * 契约 `POST /auth/password/change` 的 body 字段名是 **`currentPassword`**, + * 早先发的是 `oldPassword` —— 真实后端下原密码始终为空,改密必然失败。 + * 前端的 `oldPassword` 只是页面侧的叫法,跨边界时映射成契约名。 + * * @method POST - * @url /auth/password/change 真实后端;原 /api/auth/change-password 已弃用 - * @param payload 原密码 + 新密码 + 确认新密码(前端本地校验,后端只收 oldPassword + newPassword) - * @returns 无返回体;失败时 reject(后端返回业务错误码透传) + * @url /auth/password/change + * @param payload 原密码 + 新密码 + 确认新密码(确认项仅前端校验,不发给后端) + * @returns 密码操作结果(无 Token);失败时 reject(后端业务错误码透传) */ -export function changePassword(payload: ChangePasswordPayload): Promise { +export async function changePassword( + payload: ChangePasswordPayload, +): Promise { if (payload.oldPassword.length === 0) { - return Promise.reject(new Error('请输入原密码')); + throw new Error('请输入原密码'); } const ruleError = validatePassword(payload.newPassword); if (ruleError) { - return Promise.reject(new Error(getPasswordRuleMessage(ruleError))); + throw new Error(getPasswordRuleMessage(ruleError)); } if (payload.newPassword !== payload.confirmPassword) { - return Promise.reject(new Error(getPasswordRuleMessage('mismatch'))); + throw new Error(getPasswordRuleMessage('mismatch')); } if (payload.newPassword === payload.oldPassword) { - return Promise.reject(new Error(getPasswordRuleMessage('same-as-old'))); + throw new Error(getPasswordRuleMessage('same-as-old')); } - if (isMockMode()) return mockRequest(undefined); - // 真实后端:/auth/password/change 只收 oldPassword + newPassword - return request('/auth/password/change', { + if (isMockMode()) { + return mockRequest({ + passwordConfigured: true, + reauthenticationRequired: false, + }); + } + const raw = await request<{ + passwordConfigured?: boolean; + reauthenticationRequired?: boolean; + }>('/auth/password/change', { method: 'POST', auth: true, - body: { oldPassword: payload.oldPassword, newPassword: payload.newPassword }, + body: { currentPassword: payload.oldPassword, newPassword: payload.newPassword }, }); + return { + passwordConfigured: raw.passwordConfigured ?? true, + reauthenticationRequired: raw.reauthenticationRequired ?? false, + }; } /* ===================== 账号:微信登录(三种账号状态) ===================== */ @@ -9916,6 +10141,7 @@ export function fetchWechatLoginResult(code: string): Promise return mockRequest({ status: 'active', token: `mock_wx_token_${code.slice(-6)}`, + refreshToken: `mock_wx_refresh_${code.slice(-6)}`, openid: 'mock_openid_active', expiresAt: Date.now() + TOKEN_TTL_MS, role: 'candidate', @@ -9966,6 +10192,7 @@ export function bindWechatAndRegister( } return mockRequest({ token: `mock_wx_session_${payload.openid.slice(-6)}`, + refreshToken: `mock_wx_session_refresh_${payload.openid.slice(-6)}`, expiresAt: Date.now() + TOKEN_TTL_MS, profile: { id: `u-wx-${payload.openid.slice(-6)}`, @@ -9989,7 +10216,16 @@ export async function saveJobPreference(preference: JobPreference): Promise('/profile/me/preferences', { method: 'PUT', body: preference, auth: true }); + // 反向映射:前端单值中文 → 后端多选数组 + 英文 token。 + // 直接把 {city:'上海'} 发给后端,后端按 preferredCities[] 取值会一条都收不到。 + // salary / status 后端无字段,不发送(等后端补齐期望薪资与求职状态字段后再带上)。 + const body: BackendJobPreference = { + preferredJobTypes: toArray(preference.position), + preferredCities: toArray(preference.city), + employmentTypes: toArray(employmentLabelToToken(preference.workType)), + }; + await request('/profile/me/preferences', { method: 'PUT', body, auth: true }); + return preference; } await storageSet(JOB_PREFERENCE_KEY, JSON.stringify(preference)); return mockRequest(preference); diff --git a/src/constants/index.ts b/src/constants/index.ts index a7da4a2..45f4d15 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -308,6 +308,26 @@ export const JOB_STATUS_INACTIVE = '在职-暂不考虑'; /** 在职状态前缀:简历工作经历据此判断是否仍在职(在职才写「至今」,离职不编结束时间) */ export const JOB_STATUS_EMPLOYED_PREFIX = '在职'; +/* ===================== 雇佣类型(后端英文 token ↔ 前端中文) ===================== + * 后端 /jobs/search、/profile/me/preferences 下发的是 INTERN / FULL_TIME / PART_TIME + * 这类大写 token,前端展示与筛选用的是中文,映射只此一份,不在页面或 api 层各抄一遍。 + */ +export const EMPLOYMENT_TYPE_LABEL: Record = { + FULL_TIME: '全职', + PART_TIME: '兼职', + INTERN: '实习', + CONTRACT: '合同工', + OUTSOURCE: '外包', +}; + +/** 雇佣类型中文 → 后端 token(写回后端时用;未收录的中文原样返回,由后端校验) */ +export const EMPLOYMENT_TYPE_TOKEN: Record = Object.entries( + EMPLOYMENT_TYPE_LABEL, +).reduce>((acc, [token, label]) => { + acc[label] = token; + return acc; +}, {}); + /* ===================== 合规页脚 ===================== */ /** 客服热线(2026-09-15 由产品提供) */ diff --git a/src/types/index.ts b/src/types/index.ts index 43abdac..823bab6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -2,6 +2,9 @@ * 类型统一导出 */ export type { + BackendJobCompany, + BackendJobItem, + BackendJobReviewSummary, EmployeeReview, Job, JobDetail, @@ -302,6 +305,7 @@ export type { } from './onboarding'; export type { ChangePasswordPayload, + PasswordOperationResult, PasswordResetPayload, PasswordRuleError, } from './auth'; diff --git a/src/types/job.ts b/src/types/job.ts index cb71fe7..f340055 100644 --- a/src/types/job.ts +++ b/src/types/job.ts @@ -176,3 +176,45 @@ export interface JobReviewSummary { topTags: string[]; } + +/* ===================== 后端岗位 DTO(真实响应形态) ===================== + * 来源:2026-09-30 对 /jobs/search、/jobs/public/{jobId} 的实网采样。 + * 后端字段名与前端 Job 差异很大(title vs positionName、company.name vs companyName、 + * 薪资单位为「元/月」而前端按「K/月」渲染),因此真实分支必须先经 mapBackendJobItem 转换, + * 不能直接把后端对象 cast 成 Job —— 直接 cast 会让列表渲染出一堆 undefined。 + */ +export interface BackendJobCompany { + id: number; + name: string; + verified?: boolean; +} + +export interface BackendJobItem { + id: number; + title: string; + description?: string; + requirements?: string; + city?: string; + /** 雇佣类型枚举:INTERN / FULL_TIME / PART_TIME 等(英文大写 token) */ + employmentType?: string; + /** 薪资下限,单位:元/月(前端按 K/月 展示,需 /1000) */ + salaryMin?: number | null; + /** 薪资上限,单位:元/月 */ + salaryMax?: number | null; + /** 岗位状态英文 token:PUBLISHED / CLOSED 等 */ + status?: string; + publishedAt?: string; + createdAt?: string; + company?: BackendJobCompany | null; +} + +/** 后端岗位评价汇总 DTO(GET /jobs/{jobId}/reviews/summary 实网采样) */ +export interface BackendJobReviewSummary { + jobId?: number; + /** 评价总数(前端字段名是 total) */ + totalReviews?: number; + averageRating?: number; + /** 星级分布:键为 rating5…rating1(前端要的是 {rating,count}[]) */ + ratingDistribution?: Record; + topTags?: string[]; +}