Browse Source

fix(api): 按实网响应校正岗位/偏好/评价汇总字段与 0-based 分页

实网联调(job.youthome.com.cn,学生号 19900009021)发现前端已接端点与后端契约不同构:

- 岗位 DTO:后端 title/company.name/薪资单位元,前端 positionName/companyName/K;
  新增 mapBackendJobItem 统一转换(列表/收藏/详情三处共用),缺失字段留空不拿 mock 顶替
- 偏好 DTO:后端多选数组+英文 token,前端单值中文;读写双向映射,
  salary/status 后端无字段不发送
- 评价汇总:totalReviews→total、ratingDistribution{rating5..}→distribution[]
- 分页:后端全站 0-based,前端按 1-based 传会丢第一页(/applications/me 首页恒空);
  新增 toBackendPage/toFrontendPage 集中转换
- 新增 scripts/audit_coverage.py(248 端点覆盖率审计)与 live_probe.py(实网批量探测)

tsc 0 错误;jest 518 全过
master
doublex001 4 weeks ago
parent
commit
a914f5221c
  1. 114
      scripts/audit_coverage.py
  2. 203
      scripts/live_probe.py
  3. 18
      src/__tests__/backend-bridge-write.test.ts
  4. 60
      src/__tests__/job-api.test.ts
  5. 362
      src/api/api.ts
  6. 20
      src/constants/index.ts
  7. 4
      src/types/index.ts
  8. 42
      src/types/job.ts

114
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<q>['\"`])(?P<path>[^'\"`]+)(?P=q)",
re.DOTALL,
)
func_re = re.compile(
r"^\s*(?:export\s+)?(?:async\s+)?function\s+(?P<name>\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()

203
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()

18
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'],
});
});
});

60
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年');

362
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<Job, 'level'> {
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<Omit<Job, 'level'>[]> {
}
// 真实后端:当前用户收藏的职位(§5 STUDENT 旗下)
// 后端分页信封为 { total, size, totalPages, page, items },需从 items 取列表(P2-2 修正)
const res = await request<{ items: Omit<Job, 'level'>[] }>('/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<void> {
/* ===================== 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<void> {
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<PasswordOperationResult> {
if (!isValidSmsCode(payload.smsCode)) {
throw new Error('验证码不正确');
}
const ruleError = validatePassword(payload.newPassword);
if (ruleError) {
throw new Error(getPasswordRuleMessage(ruleError));
}
if (isMockMode()) {
return mockRequest<PasswordOperationResult>({
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<JobPreference> {
// 引导采集阶段:偏好从 OnboardingProfile 派生(见 resolveJobPreference),未采集字段留空由页面渲染「未设置」
return mockRequest(await resolveJobPreference());
}
// 真实后端:/profile/me 旗下求职者偏好(§5 STUDENT 前缀)。
// 与 resolveJobPreference 一致:合并 EMPTY_JOB_PREFERENCE 兜底,避免后端返回缺字段的偏好对象
// (如仅 {city:'上海'})导致 position/salary/workType/status 为 undefined、页面派生值出错。
const res = await request<Partial<JobPreference>>('/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<BackendJobPreference>('/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<JobDetail> {
}
// 真实后端:学生端公开岗位详情走 /jobs/public/{id}(匿名可用)。
// /jobs/{id} 是企业端看自己发布的岗位详情(需 COMPANY 鉴权),不能混用。
const detail = await request<JobDetail>(`/jobs/public/${id}`, { method: 'GET', auth: false });
return { ...detail, level: scoreToLevel(detail.score ?? 0) };
const raw = await request<BackendJobItem>(`/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<JobReviewSum
.map(([t]) => t);
return mockRequest({ averageRating, total, distribution, topTags });
}
return request<JobReviewSummary>(`/jobs/${jobId}/reviews/summary`);
// 后端字段名与前端不同构:totalReviews vs total、ratingDistribution{rating5..} vs distribution[]。
// 直接 cast 会让「共 N 条评价」显示成 undefined、星级分布全空。
const raw = await request<BackendJobReviewSummary>(`/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_<phone>`:
* 验证码填错也照样「登录成功」,直接把用户放进身份选择页。
*
* @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<LoginResult> {
export function loginByPhone(phone: string, code: string): Promise<AuthLoginData> {
if (!isValidPhone(phone)) {
return Promise.reject(new Error('手机号格式不正确'));
}
if (!isValidSmsCode(code)) {
return Promise.reject(new Error('验证码不正确'));
}
return mockRequest<LoginResult>({ token: `mock_token_${phone}`, phone });
return loginBySms({ phone, code, purpose: 'login' });
}
/**
@ -9764,17 +9935,30 @@ export async function fetchCarrierPhone(): Promise<CarrierPhoneResult> {
* 取号拿到的号码只在端上做展示,**可信号码以后端向运营商换取的结果为准**,
* 所以返回的 phone 由接口层给出,页面拿它写回资料,不自己拼预取到的号码。
*
* **注意:本接口在后端契约中不存在**(查遍《职来后端接口.md》无登录类运营商端点),
* 故当前只有 mock 分支,翻真实开关也不会触网 —— 避免对着一个不存在的路径发请求。
* 真实接入需先与后端确认端点与 body,届时补真实分支。
*
* @method POST
* @url /api/auth/login-by-carrier
* @url 无契约(待后端确认)
* @param carrierToken 取号接口下发的一次性鉴权令牌(空串表示没取到号,直接判失败)
* @returns Promise<LoginResult> 登录结果(含 token 与已核验的手机号)
* @returns Promise<LoginResult> 登录结果(含会话与已核验的手机号)
*/
export function loginByCarrier(carrierToken: string): Promise<LoginResult> {
// 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<LoginResult>({ token: `mock_token_carrier_${carrierToken}`, phone: MOCK_CARRIER_PHONE });
// mock 形态:与验证码登录返回同一形状(双 Token + 角色),让页面只有一条会话写入路径
return mockRequest<LoginResult>({
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<LoginResult> {
export async function resetPassword(
payload: PasswordResetPayload,
): Promise<PasswordOperationResult> {
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<LoginResult>({
token: `mock_token_${payload.phone}`,
phone: payload.phone,
if (isMockMode()) {
return mockRequest<PasswordOperationResult>({
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<void> {
export async function changePassword(
payload: ChangePasswordPayload,
): Promise<PasswordOperationResult> {
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<void>(undefined);
// 真实后端:/auth/password/change 只收 oldPassword + newPassword
return request('/auth/password/change', {
if (isMockMode()) {
return mockRequest<PasswordOperationResult>({
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<WechatLoginResult>
return mockRequest<WechatLoginResult>({
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<WechatSession>({
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<JobP
// @url /profile/me/preferences
if (!isMockMode()) {
return request<JobPreference>('/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<void>('/profile/me/preferences', { method: 'PUT', body, auth: true });
return preference;
}
await storageSet(JOB_PREFERENCE_KEY, JSON.stringify(preference));
return mockRequest<JobPreference>(preference);

20
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<string, string> = {
FULL_TIME: '全职',
PART_TIME: '兼职',
INTERN: '实习',
CONTRACT: '合同工',
OUTSOURCE: '外包',
};
/** 雇佣类型中文 → 后端 token(写回后端时用;未收录的中文原样返回,由后端校验) */
export const EMPLOYMENT_TYPE_TOKEN: Record<string, string> = Object.entries(
EMPLOYMENT_TYPE_LABEL,
).reduce<Record<string, string>>((acc, [token, label]) => {
acc[label] = token;
return acc;
}, {});
/* ===================== 合规页脚 ===================== */
/** 客服热线(2026-09-15 由产品提供) */

4
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';

42
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<string, number>;
topTags?: string[];
}

Loading…
Cancel
Save