Compare commits

...

7 Commits

Author SHA1 Message Date
doublex001 19bbaca18a feat(api): 接通评分权重设置到真实后端(读权重/模板 + 互斥双模式保存) 4 weeks ago
doublex001 9911277e10 feat(api): 接通评分权重设置到真实后端(读权重/模板 + 互斥双模式保存) 4 weeks ago
doublex001 6349740c0a fix(scripts): 修正覆盖率审计的泛型解析缺陷,覆盖率不再被低估 4 weeks ago
doublex001 6d8487a11f refactor(auth): 鉴权域整体硬切真实后端,移除 mock 分支 4 weeks ago
doublex001 61b0d150d6 feat(api): 接入双向打分(候选人意向分 / HR 适配分) 4 weeks ago
doublex001 b22ed57151 fix(api): 投递时间线字段映射 + 接入投递匹配状态 4 weeks ago
doublex001 20a741d54d fix(http): 错误响应缺失 data 字段时透传后端 message 4 weeks ago
  1. 7
      app/jobs/weight-settings.tsx
  2. 157
      docs/联调问题清单-致后端.md
  3. 69
      scripts/audit_coverage.py
  4. 21
      src/__tests__/backend-bridge-remaining.test.ts
  5. 60
      src/__tests__/enterprise-applications.test.ts
  6. 15
      src/__tests__/http.test.ts
  7. 250
      src/__tests__/weight-settings.test.ts
  8. 303
      src/api/api.ts
  9. 76
      src/api/auth.ts
  10. 28
      src/api/http.ts
  11. 4
      src/api/index.ts
  12. 44
      src/constants/index.ts
  13. 14
      src/hooks/useWeightSettings.ts
  14. 35
      src/types/application.ts
  15. 12
      src/types/index.ts
  16. 52
      src/types/weight.ts

7
app/jobs/weight-settings.tsx

@ -1,11 +1,13 @@
/**
* 评分权重设置页:调整九大评价维度权重,支持套用预设模板。
* 评分权重设置页:调整评价维度权重,支持套用预设模板。
* 数据通过 useWeightSettings 获取,页面只负责交互与渲染。
* 维度集合、模板、权重上下限均以服务端下发为准(不再固定「九大维度」)。
*/
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { WEIGHT_TOTAL } from '../../src/constants';
import { useWeightSettings } from '../../src/hooks';
import {colors, spacing} from '../../src/theme';
@ -40,7 +42,8 @@ export default function WeightSettingsScreen() {
save,
} = useWeightSettings();
const isValid = totalWeight === 100;
// 后端强校验权重总和必须等于 100,不等直接 400,故此处同步置灰保存按钮
const isValid = totalWeight === WEIGHT_TOTAL;
// 错误态
if (error) {

157
docs/联调问题清单-致后端.md

@ -1,10 +1,11 @@
# 职来(有职)前端 × 后端 联调问题清单(致后端同事)
> 生成时间:2026-09-30
> 最近复测:2026-09-30(第二轮:权重设置域接通 + 覆盖率审计脚本修正)
> 前端仓库:`mydreamjob`(React Native + Expo,包管理器 yarn)
> 后端基址:`https://job.youthome.com.cn/api/v1`
> 文档口径:以《职来后端接口.md》(248 个端点)为准。`scripts/openapi.json` 已过期(仅 220 个,缺 `/auth/identity/select` 等 28 个),后续请以 md 为准。
> 产生方式:① 覆盖审计(`scripts/audit_coverage.py`:md 端点 ↔ 前端 `request()` 双向比对);② 实网探测(`scripts/live_probe.py`:真实学生账号登录后批量 GET)。
> 产生方式:① 覆盖审计(`scripts/audit_coverage.py`:md 端点 ↔ 前端 `request()` 双向比对);② 实网探测(`scripts/live_probe.py`:真实学生账号登录后批量 GET);③ 写入类端点实网试跑(本轮新增:PUT 变体逐个试,定位出接口文档的 body 示例错误)。
---
@ -13,13 +14,16 @@
| 指标 | 数值 |
|---|---|
| 后端端点总数 | 248 |
| 前端已接入 | 80 |
| 前端未接入 | 168 |
| 前端「幽灵调用」(调了后端不存在的端点) | **0**(本次已全部修正) |
| 前端已接入 | **85**(上轮 83) |
| 前端未接入 | **163**(上轮 165) |
| 前端「幽灵调用」(调了后端不存在的端点) | **0** |
| 实网可探测 GET 端点 | 142(另有 42 个因缺路径 id 未探测) |
| 实网探测 正常 / 异常 | 50 / 50 |
| 实网探测 正常 / 异常 | 50 / 50(本轮复测与上轮一致,无新增异常) |
| 后端已实现且有**真实数据**、但前端未接 | 25 个(见第 6.2 节,可排期接通) |
需后端配合的事项分三类:**① 契约确认(第 1 节)② 端点缺失需补齐(第 2 节)③ 异常端点需关注(第 3 节)**;测试账号问题见第 4 节;前端已自行修掉的问题见第 5 节。
需后端配合的事项分四类:**① 契约确认(第 1 节)② 端点缺失需补齐(第 2 节)③ 异常端点需关注(第 3 节)④ 接口文档与实际不符(第 1.12 节)**;测试账号问题见第 4 节;前端已自行修掉的问题见第 5 节。
> **本轮最重要的一条**:`PUT /profile/me/review-weights` 的实际行为与接口文档**不一致**,照文档 body 写必失败(400 `code=1001`)。详见 §1.12,附可复现命令。
---
@ -38,6 +42,61 @@
| 9 | **测评(assessment)** | 按「答卷(attempt)」模型桥接:一次抽 30 题 → 批量保存 → 提交出结果;保存/提交带乐观锁 `version`,且 `typeCode`+`version` 锁定同一套题 | 并发冲突返回什么码/结构?前端需据此提示「题目已更新,请重试」 |
| 10 | **能力测评(ability)** | 用 `attemptQuestionId` + `selectedOptionId` 提交,**无 `version`** | 与测评(9)不一致是否有意?能力测评是否也需要乐观锁 |
| 11 | **岗位详情口径** | 学生端公开详情走 `GET /jobs/public/{jobId}`;`GET /jobs/{jobId}` 是公司端看自己发布的岗位 | 确认:`/jobs/{jobId}` 对 STUDENT 返回 403 是**预期**(学生不该走这条) |
| 12 | **权重保存写入模式** | 实测为**两种互斥模式**:套模板只传 `templateCode`、自定义只传 `weights`;**不能同时传** | ⚠️ 接口文档的 body 示例 `{templateCode, weights}` **是错的**,照抄必 400。详见 §1.12 |
| 13 | **权重维度集合与命名** | 后端 8 个维度(`BENEFITS`/`WORK_LIFE_BALANCE`/`GROWTH`/`TEAM_ATMOSPHERE`/`PROMOTION`/`AUTHENTICITY`/`JD_CLARITY`/`SALARY_TRANSPARENCY`),**只下发英文 token,不下发中文名** | 前端已自建 token→中文名映射表兜底。**建议后端在权重/模板响应里补 `displayName`**,否则后端每加一个维度,前端都要发版才能显示中文 |
| 14 | **权重来源枚举** | 实测三种:`SYSTEM_DEFAULT`(从未保存)/ `TEMPLATE`(套过模板)/ `CUSTOM`(自定义过) | 确认这三态是否稳定;`updatedAt` 是否只在保存过之后下发 |
| 15 | **错误码补齐** | 本轮新观测:`2005` = 手机号或密码错误 | 并入 §1.5 的「完整错误码表」诉求 |
---
### 1.12 接口文档与实际不符:`PUT /profile/me/review-weights`(需后端修正文档或实现)
**问题**:《职来后端接口.md》给该端点的 body 示例是
```json
{ "templateCode": "string", "weights": { "property1": 0, "property2": 0 } }
```
但实网(学生账号 `19900009021`,2026-09-30 实测)**照此提交必失败**。逐变体实测结果:
| 提交的 body | 实网结果 |
|---|---|
| `{ "templateCode": "BALANCED" }` | ✅ 200,`source` 变 `TEMPLATE` |
| `{ "weights": { …八个维度全给,总和 100… } }` | ✅ 200,`source` 变 `CUSTOM`,`templateCode` 消失 |
| `{ "templateCode": "BALANCED", "weights": {…} }` ← **文档示例** | ❌ 400 `code=1001 bad request` |
| `{ "configuredWeights": {…} }` | ❌ 400 `code=1001` |
| `{ "weights": {…}, "source": "USER_CUSTOM", "templateCode": "CUSTOM" }` | ❌ 400 `code=1001` |
| `{ "templateCode": "CUSTOM" }`(自定义模板码) | ❌ 400 `code=1001` |
| `{ "templateCode": "NONEXIST" }` | ❌ 400 `code=1001` |
| `{ "weights": {…总和 90…} }` | ❌ 400 `code=1001` |
| `{ "weights": {…少一个维度…} }` | ❌ 400 `code=1001` |
| `{ "weights": {…含未知 token UNKNOWN…} }` | ❌ 400 `code=1001` |
| `{}` / `{ "weights": {} }` | ❌ 400 `code=1001` |
**可复现命令**(替换 `<TOKEN>`):
```bash
# 1) 登录取 token
curl -s -X POST https://job.youthome.com.cn/api/v1/auth/password/login \
-H 'Content-Type: application/json' \
-d '{"phone":"19900009021","password":"ZhilaiTest!2026#A"}'
# 2) 文档示例(同时传两者)→ 400 code=1001
curl -s -X PUT https://job.youthome.com.cn/api/v1/profile/me/review-weights \
-H "Authorization: Bearer <TOKEN>" -H 'Content-Type: application/json' \
-d '{"templateCode":"BALANCED","weights":{"BENEFITS":12.5,"WORK_LIFE_BALANCE":12.5,"GROWTH":12.5,"TEAM_ATMOSPHERE":12.5,"PROMOTION":12.5,"AUTHENTICITY":12.5,"JD_CLARITY":12.5,"SALARY_TRANSPARENCY":12.5}}'
# 3) 只传 weights → 200
curl -s -X PUT https://job.youthome.com.cn/api/v1/profile/me/review-weights \
-H "Authorization: Bearer <TOKEN>" -H 'Content-Type: application/json' \
-d '{"weights":{"BENEFITS":12.5,"WORK_LIFE_BALANCE":12.5,"GROWTH":12.5,"TEAM_ATMOSPHERE":12.5,"PROMOTION":12.5,"AUTHENTICITY":12.5,"JD_CLARITY":12.5,"SALARY_TRANSPARENCY":12.5}}'
```
**需要后端**(二选一):
- **(推荐)修正文档**:把 body 示例改为「二选一」的说明,并注明互斥规则与 400 `code=1001`;
- 或修正实现:允许同时传(此时以 `weights` 为准、`templateCode` 仅作来源标注)。
**另请补两点**:① 400 的 message 只有 `bad request`,无法定位是「总和不为 100」「缺维度」还是「两个字段同时传」——**建议按下述细分错误码**,前端要据此给不同的提示文案;② 权重总和是否必须**严格等于 100**(实测不等即 400),是否允许 99.9/100.1 这类浮点误差。
---
@ -53,8 +112,11 @@
| `GET /account/deletion-request` | 404 | 账号注销申请查询(POST 同路径待验) | 确认注销链路:查询端点是否存在 |
| `GET /admin/reviews/replies/pending`、`GET /company/review-reply/stats` | 404 | 评价回复审核 / 统计 | 确认是否已实现 |
| `GET /profile/me/resume` | 404(`code=6001 profile not found`) | 简历页 | 学生**尚未创建简历**时建议返回 **200 + 空结构**,否则前端把「未填写」当「请求失败」 |
| `GET /applications/me` | 200,但 items 只有 `{id, jobId, studentUserId, status, createdAt, updatedAt}` | 投递列表 / 求职进度页 | **缺岗位名、公司名、城市、薪资等展示字段**,前端只拿到 `jobId`。请 join 出岗位摘要;否则前端只能逐条反查岗位(N+1,列表 20 条即 20 个请求) |
> 本轮复测(2026-09-30 第二轮):上表 404 组的**结论无变化**,均仍为 404。另 `GET /jobs/{jobId}/salary` 在接口文档中不存在(salary-calculator 域也 404)。早期前端误调该端点,已在本次修正中**移除**。若产品需要「按经验段拆分的真实薪资」,请后端设计并给出端点。
另:**`GET /jobs/{jobId}/salary` 在接口文档中不存在**(salary-calculator 域也 404)。早期前端误调该端点,已在本次修正中**移除**。若产品需要「按经验段拆分的真实薪资」,请后端设计并给出端点。
> 附:`/jobs/filters` 可正常返回(城市 10 个、薪资区间 2207–74568、雇佣类型 4 种),但**前端岗位筛选仍是本地硬编码**;若后端确认该端点稳定,前端下一轮改为下发制(避免两边枚举分叉)。
---
@ -88,8 +150,10 @@
| 账号 | 密码 | 实网结果 |
|---|---|---|
| `19900009021`(学生) | `ZhilaiTest!2026#A` | ✅ 可用,登录返回 `role=STUDENT, userId=4` |
| `19900009801`(企业) | 文档给的密码 | ❌ 登录 401 |
| `19900009802`(企业) | 文档给的密码 | ❌ 登录 401 |
| `19900009801`(企业) | 文档给的密码 | ❌ 401 `code=2005 手机号或密码错误` |
| `19900009802`(企业) | 文档给的密码 | ❌ 401 `code=2005 手机号或密码错误` |
> 本轮(2026-09-30 第二轮)对两个企业号复测了 4 组常见口令变体(`ZhilaiTest!2026#A` / `…#B` / `Test!2026#A` / `Zhilai2026!`),**全部返回 401 `code=2005`**。两点请求:① 确认这两个手机号是否**真的已注册为企业账号**(也可能只是没注册,那 2005 属于正确返回,但请给一组真实可用的);② `code=2005` 同时用于「手机号不存在」与「密码错误」,前端无法区分,建议拆分或至少让文案可辨别。
**需后端提供一组可用的企业端测试账号(含正确口令)**,否则企业端 `/companies/me/**`、`/company/**` 无法联调(学生 token 一律 403)。同时请提供一组 ADMIN 测试账号用于后台域联调。
@ -105,44 +169,85 @@
| 岗位评分调了不存在的 `GET /jobs/{id}/score` | 改打真实端点 `GET /jobs/{id}/personalized-review-score`,新增 `mapPersonalizedScore`(后端 `dimensionScores` 对象 → 雷达图维度数组) |
| 岗位详情调了不存在的 `GET /jobs/{id}/salary`,且它在 `Promise.all` 中 → 404 会让**整个详情页加载失败** | 移除该调用与「真实薪资区间」表;薪资仍由岗位对象的 `salaryMin/salaryMax` 展示 |
| 测评 / 能力测评的单题推进 UI 与后端「答卷模型」不同构 | Hook/API 层桥接(抽题→答题→提交→取果),沉淀 `mapAttemptToAssessment` 等 |
| 后端错误响应体无 `data` 字段,导致真实 message(如 `sms rate limited`、`unsupported sms purpose`)被覆盖成笼统的「请求参数有误」 | 请求层新增 `extractError()`:错误分支不再要求 `data` 存在。**影响所有非 2xx**,不止验证码 |
| 覆盖率审计脚本用正则 `<[^>]*>` 解析 `request<T>()` 泛型,遇到嵌套泛型(如 `request<{items: Omit<X,'id'>[]}>` 中第一个 `>` 就收尾)会**静默漏检**,覆盖率被系统性低估 | 改为平衡尖括号解析(忽略 `=>`)。修正后已接入 83 → **85**,未接入 165 → **163** |
| 评分权重设置页(`app/jobs/weight-settings.tsx` + `useWeightSettings`)此前全走 mock,维度是前端自造的「九大中文维度」 | 本轮接通真实后端:并行拉 `GET /profile/me/review-weights` + `GET /review-weight-templates`,维度集合改为**以后端下发为准**(8 个英文 token → 自建中文名映射,未登记 token 原样兜底显示);保存按 §1.12 的互斥双模式择一提交 |
> 说明:当前 `app.config.js` 的 `extra.apiUseMock = true`,已接通的域走真实后端,未接通的域回落 mock,保证 App 随时可跑、不回归。**遇 mock 数据即说明该域尚未接通**,不是后端返回了假数据。
> **⚠️ 关于 mock 开关的事实更正(前一版文档此处描述有误)**:
> `app.config.js` 的 `extra.apiUseMock = true` 时,`isMockMode()` 为真,**api.ts 中 71 处 `if (isMockMode())` 的业务函数一律返回 mock**——包括已与后端对齐的投递、岗位、测评等域。也就是说「已接通的域走真实后端」**并不自动成立**,必须把 `apiUseMock` 置为 `false` 才会真实触网。
> 唯一的例外是 `src/api/auth.ts`(鉴权域已于 2026-09-30 整体硬切真实后端,不再读该开关)。
> **因此 App 内联调的正确姿势是:`apiUseMock: false`**。当前为 `true`(保 App 全程可跑、不回归),此时 App 里看到的岗位/投递/权重数据**都是 mock**,不代表后端返回了假数据;真实链路的验证目前走 `scripts/live_probe.py` 与 curl 直连。
> 未接通的「幽灵域」函数已按约定写成「真实模式下也不触网、直接返回 mock」,故翻为 `false` 不会引发大面积报错;但仍建议翻开关后跑一轮全页面冒烟。
---
## 6. 覆盖率明细与后续排期
**未接入 168 个端点,按域分布:**
**未接入 163 个端点,按域分布(2026-09-30 第二轮,审计脚本修正后):**
| 域 | 未接入数 | 性质 |
|---|---|---|
| admin | 34 | 前端未建后台页(后端应已实现) |
| profile | 32 | 部分前端未接(候选人评分/数据导出/推荐/隐私/权重等) |
| profile | 28 | 候选人评分/数据导出/推荐/隐私/匹配阈值等 |
| companies | 20 | 企业端未接 |
| offers | 12 | 前端未接(Offer 域) |
| company | 11 | 企业端候选人/评分 |
| company | 10 | 企业端候选人/评分 |
| onboarding | 9 | 含 2 个后端 404 |
| interviews | 9 | 面试评分域,前端未接 |
| applications | 7 | 投递域,前端未接 |
| jobs | 7 | 含比较/竞争排名等 |
| salary-calculator | 6 | 后端 404 |
| reviews | 4 | 评价回复域 |
| blocks | 3 | 屏蔽域 |
| reports | 3 | 举报域 |
| skills | 3 | 后端 404 |
| blocks / reports / applications / skills | 3 × 4 | 屏蔽 / 举报 / 投递剩余 / 后端 404 |
| account / health / info / 等 | 6 | 工具类端点 |
## 6.1 前端后续接入优先级
**前端后续接入优先级:**
- **P0**:求职者核心闭环 —— `applications`(投递)、`interviews`(面试)、`offers`(Offer)
- **P0**:求职者核心闭环 —— `applications`(投递剩余)、`interviews`(面试)、`offers`(Offer)
- **P1**:企业端 `companies/**`、`company/**`、`reviews` 回复、`blocks`、`reports`
- **P2**:`admin/**`(后台)、`profile` 剩余(候选人评分、数据导出、推荐)
- **P2**:`admin/**`(后台)、`profile` 剩余(候选人评分、数据导出、推荐、匹配阈值)
## 6.2 后端已实现且有真实数据、前端尚未接通的端点(本轮盘点,可直接排期)
> 这 25 个端点实网返回 **200 且带真实数据**,是「接通即见效」的部分,不需要等后端做任何事。
> 数据来源:`scripts/live_probe.py` 实网结果 × 覆盖率审计未接入清单 求交集。
| 端点 | 实网返回要点 | 建议 |
|---|---|---|
| `GET /profile/me/job-recommendations` | 推荐岗位列表,含 `title/city/employmentType/salaryMin/Max/mode/reasons` | **P1** 求职者侧可直接出「为你推荐」 |
| `GET /profile/me/review-weights` ✅本轮已接 | 8 维度权重 + `source/templateCode` | 已完成 |
| `GET /review-weight-templates` ✅本轮已接 | 4 个模板(BALANCED/SALARY_FIRST/GROWTH_FIRST/STABILITY_FIRST) | 已完成 |
| `PUT /profile/me/review-weights` ✅本轮已接 | 见 §1.12 互斥双模式 | 已完成 |
| `GET /jobs/{jobId}/match` | `matchScore / eligible / matchedConditions / explanation / gaps`(**含「未设置城市偏好」这类可执行诊断**) | **P1** 「双向透明」核心:岗位详情页展示「为什么匹配 / 差在哪」 |
| `GET /jobs/filters` | 城市 10 个、薪资区间 2207–74568、雇佣类型 4 种 | **P1** 替换前端硬编码筛选枚举 |
| `GET /profile/me/privacy` + `PUT` | `resumeVisibility / contactVisible / version` | **P1** 简历可见性与联系方式开关(合规相关) |
| `GET /profile/me/match-rule` + `PUT` | `requiredHrScore`(默认 60) | **P1** 学生侧双向匹配阈值 |
| `GET /profile/me/talent-discovery` + `PUT` | `enabled / version`(默认 false) | **P2** 「让企业发现我」开关 |
| `GET /profile/me/candidate-scoring` | `overallScore` + 8 维 `weights` | **P2** 候选人评分卡 |
| `GET /profile/me/candidate-scoring/trend` | 月度趋势(含 `rulesVersion`) | **P2** |
| `GET /profile/me/candidate-percentile` | 百分位(当前 `score/percentile` 为 null,`cohortSize` 有值) | 需后端确认「样本不足」时的口径 |
| `GET /profile/me/candidate-score-card` | 评分卡(`overallScore` 为 null) | 同上 |
| `GET /job-recommendation-taxonomies` | 行业 / 职能分类树(`code/type/displayName/parentCode`) | **P2** 推荐偏好选择器 |
| `GET /profile/me/deletion` + `POST` | `{userId, status, version}` | **P2** 注销状态查询(注意:`/account/deletion-request` 是 404 的另一套路径,见 §2) |
**实网 200 但返回空、需先造数据才能联调的端点**(不阻塞接口本身):
`GET /offers`、`GET /blocks/me`、`GET /reports/me`、`GET /profile/me/talent-invitations`、`GET /profile/me/data-export-tasks`、`GET /profile/me/candidate-score-trend`、`GET /profile/me/candidate-scoring/appeals`。
→ 其中 **`/interviews/me`、`/offers`、`/conversations` 返回空数组**,加上企业账号 401,是面试/Offer 域目前**无法实网验证字段**的根因(见 §4)。请后端为学生测试号造 1–2 条面试与 Offer 数据,或提供有数据的学生账号。
---
## 7. 待后端回复的清单(速览)
1. 第 1 节 11 项契约逐条确认(尤其:分页基准、错误码表、测评 version 冲突码、岗位 status 枚举、偏好缺「期望薪资/求职状态」)。
2. 第 2 节 404 端点:是「未实现」还是「路径变更」?给出排期或确认砍掉。
3. 第 3.2 节 400 端点:确认必填参数名。
4. 第 4 节:提供可用的**企业端**与 **ADMIN** 测试账号。
5. 第 1.8 / 2 节:是否需要后端补「岗位分经验段薪资」端点(前端已移除误调)。
6. `GET /jobs/{jobId}` 对学生的 403 是否为预期(学生端是否只应走 `/jobs/public/{jobId}`)。
**本轮新增(P0,优先)**
1. **§1.12 `PUT /profile/me/review-weights` 的 body 契约**:接口文档示例是错的,同时传 `templateCode` + `weights` 必 400 `code=1001`。请确认「二选一」为准,并修正文档;同时请把 `bad request` 细分为可辨识的错误码(总和≠100 / 缺维度 / 未知 token / 两字段同时传)。
2. **§1.13 权重维度补 `displayName`**:后端只下发英文 token(8 个维度),前端不得不自建映射表——后端每新增维度都要前端发版。建议在 `review-weights` 与 `review-weight-templates` 响应里带上中文名。
3. **§2 `GET /applications/me` 缺展示字段**:items 只有 jobId,没有岗位名/公司名/城市/薪资,投递列表无法直接渲染,请 join 出岗位摘要。
4. **§4 企业账号仍全部 401(`code=2005`)**:试了 4 组口令变体均失败。请确认这两个号是否真已注册,并提供一组可用的企业 / ADMIN 账号;另请为学生测试号造 1–2 条**面试**与 **Offer** 数据(`/interviews/me`、`/offers` 现为空数组,字段无法验证)。
**沿用上轮**
5. 第 1 节 11 项契约逐条确认(尤其:分页基准、**完整错误码表**、测评 version 冲突码、岗位 status 枚举、偏好缺「期望薪资/求职状态」)。
6. 第 2 节 404 端点:是「未实现」还是「路径变更」?给出排期或确认砍掉(尤其 `salary-calculator/**`、`skills/**`、`onboarding/company/**`)。
7. 第 3.2 节 400 端点:确认必填参数名(`ids` / `jobId` / `period` / `bucket` / `verificationId`)。
8. `GET /jobs/{jobId}` 对学生的 403 是否为预期(学生端是否只应走 `/jobs/public/{jobId}`)。
9. `/profile/me/privacy` 的 `version` 字段语义(乐观锁?前端 PUT 是否需要回传?当前前端未回传也能成功,请确认)。
10. `candidate-percentile` / `candidate-score-card` 的 `score`/`percentile`/`overallScore` 当前为 `null`:是「样本不足」的正常态,还是数据未产出?(前端需据此决定展示「暂无数据」还是继续 loading)
11. 是否需要后端补「岗位分经验段薪资」端点(前端已移除误调的 `/jobs/{jobId}/salary`)。

69
scripts/audit_coverage.py

@ -46,17 +46,58 @@ def load_spec():
return ops
def _skip_generics(text: str, i: int) -> int:
"""从 text[i] 开始(i 指向 '<')跳过一层平衡尖括号,返回 '>' 之后的位置。
⚠️ 不能简单用正则 `<[^>]*>`:泛型里常嵌套 `>`,例如
request<{ items: Omit<ApplicationItem, 'id'>[] }>('/applications/me', ...)
↑ 正则会在第一个 '>' 就收尾,随后要求 '(' 而实际是 ' & {',
整个调用被静默漏检 → 覆盖率被系统性低估。
同时要忽略 `=>`(箭头函数类型),它的 '>' 不参与配对。
括号不平衡时返回 -1,表示这不是一个泛型。
"""
depth = 0
k = i
n = len(text)
while k < n:
ch = text[k]
if ch == ">" and k > 0 and text[k - 1] == "=": # `=>` 箭头,跳过
k += 1
continue
if ch == "<":
depth += 1
elif ch == ">":
depth -= 1
if depth == 0:
return k + 1
k += 1
return -1
def _call_paren(text: str, pos: int) -> int:
"""text[pos:] 以 `request` 开头时,返回其后调用括号 '(' 的下标,否则 -1。"""
i = pos + len("request")
n = len(text)
while i < n and text[i].isspace():
i += 1
if i < n and text[i] == "<":
i = _skip_generics(text, i)
if i < 0:
return -1
while i < n and text[i].isspace():
i += 1
return i if i < n and text[i] == "(" else -1
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,
)
head_re = re.compile(r"\brequest\b")
arg_re = re.compile(r"\s*(?P<q>['\"`])(?P<path>[^'\"`]+)(?P=q)")
func_re = re.compile(
r"^\s*(?:export\s+)?(?:async\s+)?function\s+(?P<name>\w+)\s*\(", re.MULTILINE
)
@ -65,18 +106,24 @@ def load_frontend():
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]
for hm in head_re.finditer(text):
paren = _call_paren(text, hm.start())
if paren < 0:
continue
am = arg_re.match(text, paren + 1)
if not am:
continue # 路径不是字面量(变量/拼接),无法静态判定
path = norm(am.group("path"))
after = text[am.end() : am.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():
if start <= hm.start():
name = fname
else:
break
line = text.count("\n", 0, cm.start()) + 1
line = text.count("\n", 0, hm.start()) + 1
calls.append((method, path, name, file, line))
return calls

21
src/__tests__/backend-bridge-remaining.test.ts

@ -146,7 +146,7 @@ const FUNCS: Entry[] = [
[fetchBadges, [], '/user/badges', true],
[fetchOfferCompareData, [undefined], '/offers/compare', true],
[fetchPendingOfferCount, [], '/offers/pending/count', true],
[fetchWeightSettings, [], '/jobs/weight-settings', true],
[fetchWeightSettings, [], '/profile/me/review-weights', false],
[fetchSalaryRecords, [], '/salary/records', true],
[fetchAIInterviewData, [], '/interview/config', true],
[fetchHREvaluation, [], '/evaluation/hr', true],
@ -196,6 +196,8 @@ const REAL_FUNCS: Array<[(...a: any[]) => Promise<any>, any[], string]> = [
[fetchInterviewScore, ['1'], '/interviews/1/score'],
[fetchInterviewScoreDraft, ['1'], '/interviews/1/score'],
[fetchOfferCompareData, [undefined], '/offers/compare'],
// 权重设置已接真实后端:并行打「我的权重」与「模板列表」两个端点
[fetchWeightSettings, [], '/profile/me/review-weights'],
];
describe('后端桥接 - 真实态路径断言(已对齐函数)', () => {
@ -220,8 +222,8 @@ describe('后端桥接 - 真实态路径断言(已对齐函数)', () => {
(global as any).fetch = originalFetch;
});
it('已对齐函数数量正确(5 个)', () => {
expect(REAL_FUNCS.length).toBe(5);
it('已对齐函数数量正确(6 个)', () => {
expect(REAL_FUNCS.length).toBe(6);
});
REAL_FUNCS.forEach(([fn, args, path], i) => {
@ -229,10 +231,19 @@ describe('后端桥接 - 真实态路径断言(已对齐函数)', () => {
await fn(...args);
const calls = ((global as any).fetch as jest.Mock).mock.calls;
expect(calls.length).toBeGreaterThan(0);
const calledUrl: string = calls[calls.length - 1][0];
expect(calledUrl).toContain(path);
// 单个函数可能并行打多个端点(如 fetchWeightSettings),
// 故断言「本次调用的 URL 里存在该路径」,而不是只看最后一个请求。
const urls = calls.map((c) => String(c[0]));
expect(urls.some((u) => u.includes(path))).toBe(true);
});
});
it('fetchWeightSettings 同时拉取权重与模板列表', async () => {
await fetchWeightSettings();
const urls = ((global as any).fetch as jest.Mock).mock.calls.map((c) => String(c[0]));
expect(urls.some((u) => u.includes('/profile/me/review-weights'))).toBe(true);
expect(urls.some((u) => u.includes('/review-weight-templates'))).toBe(true);
});
});
describe('后端桥接 - mock 态冒烟(无回归)', () => {

60
src/__tests__/enterprise-applications.test.ts

@ -42,8 +42,12 @@ import {
createCandidateFeedback,
deleteCandidateFeedback,
fetchCandidateFeedback,
fetchApplicationMatch,
fetchApplicationTimeline,
fetchCompanyApplicationDetail,
fetchCompanyApplications,
submitCandidateInterestScore,
submitHrSuitabilityScore,
toBackendApplicationStatus,
updateApplicationStatus,
updateCandidateFeedback,
@ -227,4 +231,60 @@ describe('企业端投递 / 反馈(真实模式)', () => {
await updateCandidateFeedback('app-1001', { rating: 4, content: '改', tags: [] });
expect(seenMethod).toBe('PUT');
});
it('时间线把后端 toStatus/reason/createdAt 映射成 status/note/time', async () => {
setMockMode(false);
responder = (url) => {
expect(url).toBe(`${API_BASE_URL}/applications/app-1001/timeline`);
return okData([
{ id: 1, operatorUserId: 4, toStatus: 'SUBMITTED', reason: 'Student applied', createdAt: '2026-09-30T01:57:26' },
]);
};
const timeline = await fetchApplicationTimeline('app-1001');
expect(timeline).toEqual([
{ status: 'submitted', note: 'Student applied', time: '2026-09-30T01:57:26' },
]);
});
it('匹配状态打 /applications/{id}/match 并透传结构', async () => {
setMockMode(false);
responder = (url) => {
expect(url).toBe(`${API_BASE_URL}/applications/1/match`);
return okData({
status: 'PENDING_BOTH',
matchVersion: 'RULE_THRESHOLD_V1',
candidateScoreSource: 'MANUAL_INTEREST',
hrScoreSource: 'HR_MANUAL',
companyRequiredCandidateScore: 60,
studentRequiredHrScore: 60,
applicationId: 1,
jobId: 166,
companyId: 48,
candidateId: 4,
});
};
const match = await fetchApplicationMatch('1');
expect(match.status).toBe('PENDING_BOTH');
expect(match.companyRequiredCandidateScore).toBe(60);
expect(match.studentRequiredHrScore).toBe(60);
expect(match.jobId).toBe(166);
});
it('双向打分分别走 PUT candidate-interest-score / hr-suitability-score,body 只带 score', async () => {
setMockMode(false);
const seen: Array<{ url: string; method: string; body: unknown }> = [];
responder = (url, init) => {
seen.push({ url, method: String(init.method), body: JSON.parse(String(init.body)) });
return okData({ status: 'PENDING_HR', candidateInterestScore: 80, applicationId: 1, jobId: 166, companyId: 48, candidateId: 4 });
};
await submitCandidateInterestScore('1', 80);
expect(seen[0].url).toBe(`${API_BASE_URL}/applications/1/candidate-interest-score`);
expect(seen[0].method).toBe('PUT');
expect(seen[0].body).toEqual({ score: 80 });
await submitHrSuitabilityScore('1', 70);
expect(seen[1].url).toBe(`${API_BASE_URL}/applications/1/hr-suitability-score`);
expect(seen[1].method).toBe('PUT');
expect(seen[1].body).toEqual({ score: 70 });
});
});

15
src/__tests__/http.test.ts

@ -132,6 +132,21 @@ describe('信封 code 校验(规范 §3)', () => {
}
});
it('HTTP 非 2xx 且错误体没有 data 字段时,仍透传后端 message(回归:验证码限流曾显示「请求参数有误」)', async () => {
// 真实后端错误体是 {code, message, requestId, timestamp},不含 data 字段;
// 旧实现用 isEnvelope(要求 data)判断,恒为 false,把后端 message 覆盖成「请求参数有误」
responder = () => ({ status: 400, body: { code: 3005, message: 'sms rate limited', requestId: 'rid', timestamp: 't' } });
try {
await request('/auth/sms/send', { method: 'POST', auth: false, query: { phone: '19900009021', purpose: 'login' } });
throw new Error('不应成功');
} catch (e) {
expect(e).toBeInstanceOf(ApiError);
expect((e as ApiError).httpStatus).toBe(400);
expect((e as ApiError).code).toBe(3005);
expect((e as ApiError).message).toBe('sms rate limited');
}
});
it('HTTP 非 2xx 且信封无 message 时,回退到状态码映射文案(§8)', async () => {
responder = () => ({ status: 404, body: '' });
try {

250
src/__tests__/weight-settings.test.ts

@ -0,0 +1,250 @@
/**
* 评分权重设置 ↔ 后端契约测试
*
* 锁住三件容易回归的事:
* 1. 后端 configuredWeights(英文 token 对象)→ 前端维度数组的映射:顺序、中文名、未登记 token 兜底;
* 2. 保存时「套用模板」与「自定义权重」两种**互斥**写入模式的 body 选择
* (实网实测:同时提交 templateCode 与 weights → 400 code=1001);
* 3. 模板权重的键是 token,不是中文展示名(改回中文名会让套用模板静默失效)。
*/
jest.mock('expo-constants', () => ({ expoConfig: { extra: {} } }));
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),
};
});
jest.mock('../lib/storage', () => {
const m = new Map<string, string>();
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);
}),
};
});
import { setMockMode } from '../api/http';
import { fetchWeightSettings, matchWeightTemplate, saveWeightSettings } from '../api/api';
import type { WeightDimension, WeightTemplate } from '../types';
function jsonResponse(data: unknown): unknown {
const envelope = { code: 200, message: 'ok', data, requestId: 'r', timestamp: '' };
return {
ok: true,
status: 200,
headers: { get: () => 'x' },
text: async () => JSON.stringify(envelope),
json: async () => envelope,
};
}
/** 后端实际下发的八个维度(顺序刻意打乱,验证前端不依赖对象键序) */
const BACKEND_WEIGHTS = {
JD_CLARITY: 12.5,
BENEFITS: 12.5,
SALARY_TRANSPARENCY: 12.5,
AUTHENTICITY: 12.5,
GROWTH: 12.5,
PROMOTION: 12.5,
TEAM_ATMOSPHERE: 12.5,
WORK_LIFE_BALANCE: 12.5,
};
const BACKEND_TEMPLATES = [
{
code: 'BALANCED',
name: '均衡权重',
description: '八个维度平均分配',
weights: BACKEND_WEIGHTS,
},
{
code: 'SALARY_FIRST',
name: '薪资优先',
description: '重点关注薪资透明度与基础保障',
weights: {
BENEFITS: 20, WORK_LIFE_BALANCE: 5, GROWTH: 10, TEAM_ATMOSPHERE: 5,
PROMOTION: 5, AUTHENTICITY: 15, JD_CLARITY: 10, SALARY_TRANSPARENCY: 30,
},
},
];
function mockFetchByPath(map: Record<string, unknown>): jest.Mock {
const fn = jest.fn(async (url: string) => {
const hit = Object.keys(map).find((p) => String(url).includes(p));
if (!hit) throw new Error(`未预期的请求: ${url}`);
return jsonResponse(map[hit]);
});
(global as any).fetch = fn;
return fn;
}
afterEach(() => {
setMockMode(true);
});
describe('fetchWeightSettings:后端 token 对象 → 维度数组', () => {
beforeEach(() => {
setMockMode(false);
mockFetchByPath({
'/profile/me/review-weights': {
source: 'SYSTEM_DEFAULT',
configuredWeights: BACKEND_WEIGHTS,
},
'/review-weight-templates': BACKEND_TEMPLATES,
});
});
test('按 constants 的展示顺序输出,且映射出中文名', async () => {
const settings = await fetchWeightSettings();
expect(settings.dimensions.map((d) => d.id)).toEqual([
'SALARY_TRANSPARENCY',
'GROWTH',
'BENEFITS',
'WORK_LIFE_BALANCE',
'PROMOTION',
'TEAM_ATMOSPHERE',
'AUTHENTICITY',
'JD_CLARITY',
]);
expect(settings.dimensions[0].name).toBe('薪资透明度');
expect(settings.dimensions.map((d) => d.weight)).toEqual(Array(8).fill(12.5));
expect(settings.source).toBe('SYSTEM_DEFAULT');
});
test('模板 code → id,权重键保持 token', async () => {
const settings = await fetchWeightSettings();
expect(settings.templates.map((t) => t.id)).toEqual(['BALANCED', 'SALARY_FIRST']);
expect(settings.templates[1].weights.SALARY_TRANSPARENCY).toBe(30);
expect(settings.templates[1].weights['薪资透明度']).toBeUndefined();
});
test('未登记的新维度追加在末尾并原样显示 token(不吞数据)', async () => {
mockFetchByPath({
'/profile/me/review-weights': {
source: 'CUSTOM',
configuredWeights: { ...BACKEND_WEIGHTS, NEW_DIMENSION_X: 3 },
},
'/review-weight-templates': [],
});
const settings = await fetchWeightSettings();
const last = settings.dimensions[settings.dimensions.length - 1];
expect(last.id).toBe('NEW_DIMENSION_X');
expect(last.name).toBe('NEW_DIMENSION_X');
expect(last.weight).toBe(3);
});
test('source=TEMPLATE 时回传 templateCode 供展示', async () => {
mockFetchByPath({
'/profile/me/review-weights': {
source: 'TEMPLATE',
templateCode: 'SALARY_FIRST',
configuredWeights: BACKEND_TEMPLATES[1].weights,
},
'/review-weight-templates': BACKEND_TEMPLATES,
});
const settings = await fetchWeightSettings();
expect(settings.source).toBe('TEMPLATE');
expect(settings.templateCode).toBe('SALARY_FIRST');
});
});
describe('matchWeightTemplate:决定写入模式', () => {
const templates: WeightTemplate[] = [
{ id: 'BALANCED', name: '均衡权重', description: '', weights: BACKEND_WEIGHTS },
{
id: 'SALARY_FIRST',
name: '薪资优先',
description: '',
weights: BACKEND_TEMPLATES[1].weights,
},
];
const toDims = (weights: Record<string, number>): WeightDimension[] =>
Object.entries(weights).map(([id, weight]) => ({ id, name: id, weight }));
test('与模板完全一致 → 返回该模板 code', () => {
expect(matchWeightTemplate(toDims(BACKEND_WEIGHTS), templates)).toBe('BALANCED');
});
test('有一项不同 → null(走自定义模式)', () => {
const tweaked = { ...BACKEND_WEIGHTS, GROWTH: 13.5, BENEFITS: 11.5 };
expect(matchWeightTemplate(toDims(tweaked), templates)).toBeNull();
});
test('维度数量不同 → null(避免把残缺配置误判成模板)', () => {
const partial = { ...BACKEND_WEIGHTS } as Record<string, number>;
delete partial.GROWTH;
expect(matchWeightTemplate(toDims(partial), templates)).toBeNull();
});
});
describe('saveWeightSettings:两种互斥写入模式', () => {
const dims: WeightDimension[] = Object.entries(BACKEND_WEIGHTS).map(([id, weight]) => ({
id,
name: id,
weight,
}));
beforeEach(() => {
setMockMode(false);
});
test('传入 templateCode → body 只有 templateCode,不带 weights', async () => {
const fn = mockFetchByPath({
'/profile/me/review-weights': {
source: 'TEMPLATE',
templateCode: 'BALANCED',
configuredWeights: BACKEND_WEIGHTS,
},
});
const saved = await saveWeightSettings(dims, 'BALANCED');
const [url, opts] = fn.mock.calls[0];
expect(String(url)).toContain('/profile/me/review-weights');
expect(opts.method).toBe('PUT');
// 同时传 templateCode 与 weights 会被后端拒(400 code=1001)
expect(JSON.parse(opts.body)).toEqual({ templateCode: 'BALANCED' });
expect(saved).toHaveLength(8);
});
test('不传 templateCode → body 只有 weights(八个 token 全给)', async () => {
const fn = mockFetchByPath({
'/profile/me/review-weights': { source: 'CUSTOM', configuredWeights: BACKEND_WEIGHTS },
});
await saveWeightSettings(dims);
const opts = fn.mock.calls[0][1];
const body = JSON.parse(opts.body);
expect(Object.keys(body)).toEqual(['weights']);
expect(Object.keys(body.weights)).toHaveLength(8);
expect(body.weights.SALARY_TRANSPARENCY).toBe(12.5);
});
});
describe('mock 态不触网', () => {
beforeEach(() => setMockMode(true));
test('返回与真实同构的八维 + 四个模板,且不与常量同引用', async () => {
const fn = jest.fn();
(global as any).fetch = fn;
const settings = await fetchWeightSettings();
expect(fn).not.toHaveBeenCalled();
expect(settings.dimensions).toHaveLength(8);
expect(settings.templates).toHaveLength(4);
// 改动返回值不应污染后续调用
settings.dimensions[0].weight = 999;
const again = await fetchWeightSettings();
expect(again.dimensions[0].weight).toBe(12.5);
});
});

303
src/api/api.ts

@ -25,6 +25,8 @@ import type {
BackendAssessmentType,
BackendJobItem,
BackendJobReviewSummary,
BackendReviewWeight,
BackendWeightTemplate,
AITool,
Article,
Badge,
@ -191,8 +193,11 @@ import type {
CarrierPhoneResult,
ApplicationDetail,
ApplicationItem,
ApplicationMatch,
MatchRule,
ApplicationStatus,
ApplicationTimelineEvent,
BackendApplicationTimelineEvent,
CandidateFeedback,
CandidateFeedbackInput,
CompanyApplicationItem,
@ -225,6 +230,9 @@ import {
SALARY_RANGES,
SOCIAL_SECURITY_CITY_PROFILES,
TOKEN_TTL_MS,
WEIGHT_DIMENSION_LABEL,
WEIGHT_DIMENSION_ORDER,
WEIGHT_REVERSED_CODES,
} from '../constants';
import { buildResumeExportHtml, isValidPhone, isValidSmsCode } from '../utils';
import { colors } from '../theme';
@ -2188,11 +2196,95 @@ export async function fetchApplicationTimeline(id: string): Promise<ApplicationT
if (isMockMode()) {
return [];
}
const data = await request<{ timeline: ApplicationTimelineEvent[] } | ApplicationTimelineEvent[]>(
`/applications/${id}/timeline`,
{ method: 'GET', auth: true },
);
return Array.isArray(data) ? data : data.timeline ?? [];
// 后端 timeline 节点字段是 toStatus / reason / createdAt,与前端 ApplicationTimelineEvent
// 的 status / note / time 不同构,必须在此映射,否则时间线渲染成一片空白。
const raw = await request<BackendApplicationTimelineEvent[]>(`/applications/${id}/timeline`, {
method: 'GET',
auth: true,
});
return (raw ?? []).map((e) => ({
status: toApplicationStatus(e.toStatus),
note: e.reason,
time: e.createdAt,
}));
}
/**
* 获取投递匹配状态(学生本人或岗位所属企业可见)
* @method GET
* @url /applications/{id}/match
* @param id 投递记录 ID
* @returns Promise<ApplicationMatch> 匹配状态
*/
export async function fetchApplicationMatch(id: string): Promise<ApplicationMatch> {
if (isMockMode()) {
throw new Error('mock 模式下无匹配数据');
}
return request<ApplicationMatch>(`/applications/${id}/match`, { method: 'GET', auth: true });
}
/**
* 提交候选人意向分(0-100,仅投递本人;MATCHED/CLOSED 后不可改)。
* 返回最新匹配状态(学生打分后 status 从 PENDING_BOTH 变为 PENDING_HR)。
* @method PUT
* @url /applications/{id}/candidate-interest-score
*/
export async function submitCandidateInterestScore(id: string, score: number): Promise<ApplicationMatch> {
if (isMockMode()) {
throw new Error('mock 模式下无匹配数据');
}
return request<ApplicationMatch>(`/applications/${id}/candidate-interest-score`, {
method: 'PUT',
auth: true,
body: { score },
});
}
/**
* 提交企业 HR 适配分(0-100,仅岗位所属企业;MATCHED/CLOSED 后不可改)。
* @method PUT
* @url /applications/{id}/hr-suitability-score
*/
export async function submitHrSuitabilityScore(id: string, score: number): Promise<ApplicationMatch> {
if (isMockMode()) {
throw new Error('mock 模式下无匹配数据');
}
return request<ApplicationMatch>(`/applications/${id}/hr-suitability-score`, {
method: 'PUT',
auth: true,
body: { score },
});
}
/**
* 获取学生匹配阈值(未配置返回默认 60)
* @method GET
* @url /profile/me/match-rule
* @returns Promise<MatchRule>
*/
export async function fetchMatchRule(): Promise<MatchRule> {
if (isMockMode()) {
return mockRequest({ studentUserId: 0, requiredHrScore: 60 });
}
return request<MatchRule>('/profile/me/match-rule', { method: 'GET', auth: true });
}
/**
* 更新学生匹配阈值(requiredHrScore 0-100;首次更新创建配置)
* @method PUT
* @url /profile/me/match-rule
* @param requiredHrScore 要求企业 HR 适配分达到的最低分
* @returns Promise<MatchRule>
*/
export async function updateMatchRule(requiredHrScore: number): Promise<MatchRule> {
if (isMockMode()) {
return mockRequest({ studentUserId: 0, requiredHrScore });
}
return request<MatchRule>('/profile/me/match-rule', {
method: 'PUT',
auth: true,
body: { requiredHrScore },
});
}
/**
@ -3656,101 +3748,172 @@ export async function fetchPendingOfferCount(): Promise<number> {
/* ===================== 评分权重设置 ===================== */
/** 默认权重维度(9 个,总和 100%) */
const MOCK_WEIGHT_DIMENSIONS: WeightDimension[] = [
{ id: 'salary', name: '薪资竞争力', weight: 20 },
{ id: 'growth', name: '发展空间', weight: 15 },
{ id: 'atmosphere', name: '工作氛围', weight: 12 },
{ id: 'benefit', name: '福利待遇', weight: 12 },
{ id: 'team', name: '团队水平', weight: 10 },
{ id: 'culture', name: '企业文化', weight: 10 },
{ id: 'intensity', name: '工作强度', weight: 8, isReversed: true },
{ id: 'location', name: '地理位置', weight: 8 },
{ id: 'authenticity', name: '岗位真实性', weight: 5 },
];
/*
* 已接通真实后端(2026-09-30 实网验证):
* 读:GET /profile/me/review-weights → configuredWeights(八个英文 token → 权重)
* GET /review-weight-templates → 4 个预设模板(code/name/description/weights)
* 写:PUT /profile/me/review-weights,**两种互斥模式**:
* · 套用模板:{ templateCode }
* · 自定义: { weights }(八个维度全给、总和 100)
* 同时传两者 / 未知 templateCode / 缺维度 / 总和 ≠ 100 → 一律 400 code=1001。
* 详见《联调问题清单-致后端》§1.12(接口文档里的 body 示例是错的)。
*
* mock 形态与真实同构(同样八维 token + 同 4 个模板),避免两套口径分叉。
*/
/** 预设权重模板(3 个,各维度权重按维度名称匹配) */
/** 八个维度的默认均衡权重(12.5 × 8 = 100) */
const MOCK_WEIGHT_DIMENSIONS: WeightDimension[] = WEIGHT_DIMENSION_ORDER.map((code) => ({
id: code,
name: WEIGHT_DIMENSION_LABEL[code] ?? code,
weight: 12.5,
...(WEIGHT_REVERSED_CODES.includes(code) ? { isReversed: true } : {}),
}));
/** 预设权重模板(与后端 /review-weight-templates 的四个模板一致) */
const MOCK_WEIGHT_TEMPLATES: WeightTemplate[] = [
{
id: 'salary-first',
id: 'BALANCED',
name: '均衡权重',
description: '八个维度平均分配',
weights: {
BENEFITS: 12.5, WORK_LIFE_BALANCE: 12.5, GROWTH: 12.5, TEAM_ATMOSPHERE: 12.5,
PROMOTION: 12.5, AUTHENTICITY: 12.5, JD_CLARITY: 12.5, SALARY_TRANSPARENCY: 12.5,
},
},
{
id: 'SALARY_FIRST',
name: '薪资优先',
description: '薪资30% · 发展15%',
description: '重点关注薪资透明度与基础保障',
weights: {
薪资竞争力: 30,
发展空间: 15,
工作氛围: 10,
福利待遇: 12,
团队水平: 10,
企业文化: 8,
工作强度: 5,
地理位置: 5,
岗位真实性: 5,
BENEFITS: 20, WORK_LIFE_BALANCE: 5, GROWTH: 10, TEAM_ATMOSPHERE: 5,
PROMOTION: 5, AUTHENTICITY: 15, JD_CLARITY: 10, SALARY_TRANSPARENCY: 30,
},
},
{
id: 'growth-first',
id: 'GROWTH_FIRST',
name: '成长优先',
description: '发展30% · 团队15%',
description: '重点关注成长空间与晋升机制',
weights: {
薪资竞争力: 12,
发展空间: 30,
工作氛围: 10,
福利待遇: 10,
团队水平: 15,
企业文化: 8,
工作强度: 5,
地理位置: 5,
岗位真实性: 5,
BENEFITS: 5, WORK_LIFE_BALANCE: 5, GROWTH: 30, TEAM_ATMOSPHERE: 10,
PROMOTION: 20, AUTHENTICITY: 10, JD_CLARITY: 15, SALARY_TRANSPARENCY: 5,
},
},
{
id: 'stable-first',
id: 'STABILITY_FIRST',
name: '稳定优先',
description: '福利25%',
description: '重点关注工作生活平衡与基础保障',
weights: {
薪资竞争力: 12,
发展空间: 12,
工作氛围: 12,
福利待遇: 25,
团队水平: 8,
企业文化: 10,
工作强度: 6,
地理位置: 10,
岗位真实性: 5,
BENEFITS: 25, WORK_LIFE_BALANCE: 30, GROWTH: 5, TEAM_ATMOSPHERE: 5,
PROMOTION: 5, AUTHENTICITY: 10, JD_CLARITY: 5, SALARY_TRANSPARENCY: 15,
},
},
];
/**
* 获取评分权重设置(含维度 + 预设模板)
* 后端 `configuredWeights` / 模板 `weights` → 维度数组。
*
* 顺序以 WEIGHT_DIMENSION_ORDER 为准(后端返回的是对象,键序不可依赖);
* 未登记的 token 追加在末尾并原样显示 token 本身,不静默吞掉后端新增的维度。
*/
function toWeightDimensions(weights: Record<string, number> | undefined): WeightDimension[] {
const map = weights ?? {};
const codes = Object.keys(map);
const ordered = [
...WEIGHT_DIMENSION_ORDER.filter((code) => codes.includes(code)),
...codes.filter((code) => !WEIGHT_DIMENSION_ORDER.includes(code)),
];
return ordered.map((code) => ({
id: code,
name: WEIGHT_DIMENSION_LABEL[code] ?? code,
weight: map[code],
...(WEIGHT_REVERSED_CODES.includes(code) ? { isReversed: true } : {}),
}));
}
/** 后端模板数组 → 前端模板视图模型(`code` → `id`);非数组(异常响应解包为空对象)时退化为空列表 */
function toWeightTemplates(list: BackendWeightTemplate[] | undefined): WeightTemplate[] {
if (!Array.isArray(list)) return [];
return list.map((item) => ({
id: item.code,
name: item.name,
description: item.description,
weights: item.weights ?? {},
}));
}
/**
* 当前权重是否与某个模板完全一致,一致则返回该模板 code。
*
* 保存时必须据此在「套用模板」与「自定义」两种**互斥**写入模式间二选一:
* 后端不允许同时提交 templateCode 与 weights(400 code=1001)。
* 放在 API 层是因为这是业务规则,页面与 hook 都不该各判一遍。
*/
export function matchWeightTemplate(
dimensions: WeightDimension[],
templates: WeightTemplate[],
): string | null {
const isSame = (weights: Record<string, number>): boolean => {
const codes = Object.keys(weights);
if (codes.length !== dimensions.length) return false;
return dimensions.every((d) => {
const w = weights[d.id];
return typeof w === 'number' && Math.abs(w - d.weight) < 1e-6;
});
};
return templates.find((t) => isSame(t.weights))?.id ?? null;
}
/**
* 获取评分权重设置(当前权重 + 预设模板)
* @method GET
* @url /api/jobs/weight-settings
* @returns Promise<WeightSettings> 权重设置对象
* @url /profile/me/review-weights 与 /review-weight-templates(并行)
* @returns Promise<WeightSettings> 权重设置(含来源与当前模板)
*/
export async function fetchWeightSettings(): Promise<WeightSettings> {
// 后端未提供 OpenAPI 契约:保持 mock(翻真实开关也不触网),待后端就绪再接真实分支
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/weight-settings`, { method: 'GET' }); return res.json();
return mockRequest({
dimensions: MOCK_WEIGHT_DIMENSIONS.map((d) => ({ ...d })),
templates: MOCK_WEIGHT_TEMPLATES,
});
// 真实路径
if (isMockMode()) {
return mockRequest({
dimensions: MOCK_WEIGHT_DIMENSIONS.map((d) => ({ ...d })),
templates: MOCK_WEIGHT_TEMPLATES.map((t) => ({ ...t, weights: { ...t.weights } })),
source: 'SYSTEM_DEFAULT' as const,
});
}
const [mine, templates] = await Promise.all([
request<BackendReviewWeight>('/profile/me/review-weights', { method: 'GET', auth: true }),
request<BackendWeightTemplate[]>('/review-weight-templates', { method: 'GET', auth: true }),
]);
return {
dimensions: toWeightDimensions(mine?.configuredWeights),
templates: toWeightTemplates(templates),
source: mine?.source,
templateCode: mine?.templateCode,
};
}
/**
* 保存评分权重设置(各维度权重总和应为 100%)
* @method POST
* @url /api/jobs/weight-settings
* @param dimensions { WeightDimension[] } 完整维度权重列表
* @returns Promise<WeightDimension[]> 保存后的维度列表
* 保存评分权重设置
* @method PUT
* @url /profile/me/review-weights
* @param dimensions { WeightDimension[] } 完整维度权重列表(总和须为 100)
* @param templateCode { string } 可选;由 matchWeightTemplate() 判定后传入,
* 传入即按「套用模板」提交,否则按「自定义权重」提交(二者互斥)
* @returns Promise<WeightDimension[]> 保存后服务端生效的维度列表
*/
export async function saveWeightSettings(
dimensions: WeightDimension[],
templateCode?: string,
): Promise<WeightDimension[]> {
// @url /weight-settings
return mockRequest(dimensions.map((d) => ({ ...d })));
if (isMockMode()) {
return mockRequest(dimensions.map((d) => ({ ...d })));
}
const body = templateCode
? { templateCode }
: { weights: Object.fromEntries(dimensions.map((d) => [d.id, d.weight])) };
const saved = await request<BackendReviewWeight>('/profile/me/review-weights', {
method: 'PUT',
auth: true,
body,
});
return toWeightDimensions(saved?.configuredWeights);
}
/* ===================== 同岗位竞争力排名 ===================== */

76
src/api/auth.ts

@ -1,9 +1,15 @@
import { request, isMockMode, toExpiresAt } from './http';
import { request, toExpiresAt } from './http';
import type { AppRole } from '../types';
/* ===================== 鉴权接口(真实后端) =====================
/* ===================== 鉴权接口(真实后端 · 不走 mock) =====================
* 落实规范第 4、5、6 节。全部走 http 层;匿名接口显式 auth:false。
* 客户端不提交 role / userId / verified / passwordHash(后端生成)。
*
* 鉴权域已整体切真实后端(2026-09-30):短信网关、/auth/login、/auth/me、
* /auth/refresh、/auth/logout、/auth/identity/select、/auth/company/register
* 均已接通。本文件函数不再受全局 isMockMode() 控制,永远走真实接口;
* 业务域(api.ts 内未接通的接口)仍按 isMockMode 兜底 mock。
* 故 mockAuthTokens 辅助函数已删除——鉴权域不再有 mock 形态。
*/
/**
@ -75,12 +81,11 @@ function isValidPhone(phone: string): boolean {
return /^1[3-9]\d{9}$/.test(phone);
}
/** 发送短信验证码(§4 步骤1 / §6 步骤1) */
/** 发送短信验证码(§4 步骤1 / §6 步骤1):失败抛出,由 useSmsCode 在页面提示 */
export async function sendSmsCode(payload: SmsSendPayload): Promise<void> {
if (!isValidPhone(payload.phone)) {
throw new Error('手机号格式不正确');
}
if (isMockMode()) return;
// 后端约束:/auth/sms/send 只接受 Query 参数(body 传参会被当作缺失)
await request('/auth/sms/send', {
method: 'POST',
@ -99,16 +104,6 @@ export async function loginByPassword(payload: PasswordLoginPayload): Promise<Au
if (!isValidPhone(payload.phone)) {
throw new Error('手机号格式不正确');
}
if (isMockMode()) {
const mock = mockAuthTokens(`pwd_${payload.phone}`);
return {
...mock,
userId: 0,
phone: payload.phone,
nickname: 'Mock 用户',
role: 'STUDENT',
};
}
const raw = await request<{
userId: number;
phone: string;
@ -135,15 +130,6 @@ export async function loginByPassword(payload: PasswordLoginPayload): Promise<Au
};
}
/** mock 形态下返回的双 Token(开发期不触网) */
function mockAuthTokens(nonce: string): AuthTokens {
return {
accessToken: `mock_access_${nonce}`,
refreshToken: `mock_refresh_${nonce}`,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
};
}
export interface SmsLoginPayload {
phone: string;
code: string;
@ -163,17 +149,6 @@ export async function loginBySms(payload: SmsLoginPayload): Promise<AuthLoginDat
if (!isValidPhone(payload.phone)) {
throw new Error('手机号格式不正确');
}
if (isMockMode()) {
const mock = mockAuthTokens(`sms_${payload.phone}`);
return {
...mock,
userId: 0,
phone: payload.phone,
nickname: 'Mock 用户',
role: 'STUDENT',
identitySelectionRequired: false,
};
}
const raw = await request<{
userId: number;
phone: string;
@ -227,16 +202,6 @@ export interface CurrentUser {
/** 拉取当前用户与角色(§4 步骤3):角色以后端为准并映射 */
export async function fetchCurrentUser(): Promise<CurrentUser> {
if (isMockMode()) {
return {
id: 'mock-user',
role: 'candidate',
phone: '',
nickname: 'Mock 用户',
verified: true,
active: true,
};
}
const raw = await request<{
id: number;
role: BackendRole;
@ -259,7 +224,6 @@ export async function fetchCurrentUser(): Promise<CurrentUser> {
/** 退出登录(§4 步骤5):前端清会话由 AuthContext 负责,这里只通知后端 */
export async function requestLogout(): Promise<void> {
if (isMockMode()) return;
await request('/auth/logout', { method: 'POST' });
}
@ -284,16 +248,6 @@ export async function registerCompany(payload: CompanyRegisterPayload): Promise<
if (!isValidPhone(payload.phone)) {
throw new Error('手机号格式不正确');
}
if (isMockMode()) {
const mock = mockAuthTokens(`company_${payload.phone}`);
return {
...mock,
userId: 0,
phone: payload.phone,
nickname: '企业用户',
role: 'COMPANY',
};
}
const raw = await request<{
userId: number;
companyId?: number;
@ -320,7 +274,6 @@ export interface RefreshTokenPayload {
/** 用 Refresh Token 换取新凭证(旧 Refresh 立即失效) */
export async function refreshAuthToken(payload: RefreshTokenPayload): Promise<AuthTokens> {
if (isMockMode()) return mockAuthTokens('refreshed');
const raw = await request<{
accessToken: string;
refreshToken: string;
@ -362,17 +315,6 @@ export interface IdentitySelectionResult extends AuthLoginData {
* 首次成功撤销全部旧会话并返回新凭证;同身份重复请求只返回状态,不重复颁发凭证。
*/
export async function selectIdentity(payload: IdentitySelectionPayload): Promise<IdentitySelectionResult> {
if (isMockMode()) {
const mock = mockAuthTokens('identity');
return {
...mock,
userId: 0,
phone: '',
nickname: 'Mock 用户',
role: payload.identity,
identitySelectionRequired: false,
};
}
const raw = await request<{
userId: number;
role: BackendRole;

28
src/api/http.ts

@ -143,6 +143,24 @@ function isEnvelope<T>(value: unknown): value is ApiEnvelope<T> {
);
}
/**
* 从错误响应里提取业务 code 与 message。
*
* 后端**错误**响应是 `{code, message, requestId, timestamp}`,**没有 `data` 字段**;
* 而 `isEnvelope` 要求 `data` 存在(那是给「成功响应解包」用的),
* 若拿它判断错误体,会恒为 false,导致后端真实 message 被 `pickHttpMessage` 覆盖成
* 「请求参数有误」之类的笼统文案。故错误分支单独用本函数取值。
*/
function extractError(parsed: unknown): { code: number | null; message: string | null } {
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>;
const code = typeof obj.code === 'number' ? obj.code : null;
const message = typeof obj.message === 'string' && obj.message ? obj.message : null;
return { code, message };
}
return { code: null, message: null };
}
interface InternalOptions extends RequestOptions {
/** 标记这是刷新请求本身,避免 401 时递归刷新 */
_isRefresh?: boolean;
@ -247,7 +265,7 @@ async function rawRequest<T>(path: string, opts: InternalOptions): Promise<T> {
}
throw new ApiError({
httpStatus: 401,
code: isEnvelope<unknown>(parsed) ? parsed.code : 401,
code: extractError(parsed).code ?? 401,
message: '登录已失效,请重新登录',
requestId: resRequestId,
});
@ -255,13 +273,11 @@ async function rawRequest<T>(path: string, opts: InternalOptions): Promise<T> {
// HTTP 状态码优先(§8)
if (!response.ok) {
const msg = isEnvelope<unknown>(parsed)
? parsed.message
: pickHttpMessage(response.status);
const err = extractError(parsed);
throw new ApiError({
httpStatus: response.status,
code: isEnvelope<unknown>(parsed) ? parsed.code : response.status,
message: msg,
code: err.code ?? response.status,
message: err.message ?? pickHttpMessage(response.status),
requestId: resRequestId,
});
}

4
src/api/index.ts

@ -171,6 +171,7 @@ export {
fetchUserProfile,
fetchUserStats,
fetchWeightSettings,
matchWeightTemplate,
inviteCandidate,
estimateJobQuality,
optimizeResume,
@ -210,6 +211,9 @@ export {
fetchMyApplications,
fetchApplicationDetail,
fetchApplicationTimeline,
fetchApplicationMatch,
submitCandidateInterestScore,
submitHrSuitabilityScore,
withdrawApplication,
toBackendApplicationStatus,
fetchCompanyApplications,

44
src/constants/index.ts

@ -468,3 +468,47 @@ export const CANDIDATE_FEEDBACK_TAG_SUGGESTIONS: string[] = [
'沟通待改进',
'岗位匹配度低',
];
/* ===================== 评分权重维度(getMyWeights 契约) =====================
* 后端 `GET /profile/me/review-weights` 只下发英文维度 token(configuredWeights 的键),
* **不下发中文名**,因此「token → 中文名」只能由前端维护。这里是唯一真相源:
* 映射、排序、模板应用三处都读它,后端新增维度时只需在此补一行。
* 未登记的 token 会在展示层原样兜底显示 token 本身(不吞掉数据)。
*/
/** 维度 token → 中文展示名 */
export const WEIGHT_DIMENSION_LABEL: Record<string, string> = {
SALARY_TRANSPARENCY: '薪资透明度',
GROWTH: '成长空间',
BENEFITS: '福利待遇',
WORK_LIFE_BALANCE: '工作生活平衡',
PROMOTION: '晋升机制',
TEAM_ATMOSPHERE: '团队氛围',
AUTHENTICITY: '岗位真实性',
JD_CLARITY: 'JD 清晰度',
};
/** 维度的展示顺序(未登记的 token 按后端返回顺序追加在末尾) */
export const WEIGHT_DIMENSION_ORDER: readonly string[] = [
'SALARY_TRANSPARENCY',
'GROWTH',
'BENEFITS',
'WORK_LIFE_BALANCE',
'PROMOTION',
'TEAM_ATMOSPHERE',
'AUTHENTICITY',
'JD_CLARITY',
];
/**
* 反向指标维度(值越低评分越高)。
* 后端当前八个维度均为正向指标,故为空;保留扩展点,供后续维度接入时登记。
*/
export const WEIGHT_REVERSED_CODES: readonly string[] = [];
/**
* 权重总和必须等于该值。
* 后端强校验:总和不等、缺任一维度、含未知 token 一律 400 `code=1001`,
* 前端必须在提交前置灰保存按钮,不能把校验丢给后端报错。
*/
export const WEIGHT_TOTAL = 100;

14
src/hooks/useWeightSettings.ts

@ -3,7 +3,7 @@
* 业务规则(权重总和校验)放在 api 层,本层只负责本地编辑态与远端数据的合并。
*/
import { useCallback, useMemo, useState } from 'react';
import { fetchWeightSettings, saveWeightSettings } from '../api';
import { fetchWeightSettings, matchWeightTemplate, saveWeightSettings } from '../api';
import type { WeightDimension, WeightSettings } from '../types';
import { useAsyncData } from './useAsyncData';
@ -63,7 +63,8 @@ export function useWeightSettings(): WeightSettingsState {
const base = prev ?? remoteDimensions;
return base.map((item) => ({
...item,
weight: template.weights[item.name] ?? item.weight,
// 模板权重的键是后端维度 token,与维度 id 同源(不是中文展示名)
weight: template.weights[item.id] ?? item.weight,
}));
});
},
@ -73,12 +74,17 @@ export function useWeightSettings(): WeightSettingsState {
const save = useCallback(async () => {
setSaving(true);
try {
const saved = await saveWeightSettings(dimensions);
// 与某个预设模板完全一致时按「套用模板」提交,否则按「自定义权重」提交。
// 后端两种写入模式互斥:同时传 templateCode 与 weights 会 400 code=1001。
const templateCode = matchWeightTemplate(dimensions, data?.templates ?? []);
const saved = await saveWeightSettings(dimensions, templateCode ?? undefined);
// 用服务端回传的生效值覆盖本地编辑态;不调 refresh(),
// 否则整页会退回 loading 态闪一下(页面不展示 source,无需为它重新取数)。
setLocalDimensions(saved);
} finally {
setSaving(false);
}
}, [dimensions]);
}, [dimensions, data]);
return {
settings: data ?? null,

35
src/types/application.ts

@ -64,6 +64,41 @@ export interface ApplicationTimelineEvent {
note?: string;
}
/** 后端投递时间线节点(GET /applications/{id}/timeline 实测结构) */
export interface BackendApplicationTimelineEvent {
id: number;
operatorUserId: number;
/** 变更后状态(大写 token,如 SUBMITTED) */
toStatus: string;
/** 变更原因,如 "Student applied" */
reason?: string;
createdAt: string;
}
/** 投递匹配状态(GET /applications/{id}/match,学生/企业双方可见) */
export interface ApplicationMatch {
/** 匹配状态,如 PENDING_BOTH */
status: string;
/** 匹配规则版本,如 RULE_THRESHOLD_V1 */
matchVersion: string;
/** 候选人意向分来源,如 MANUAL_INTEREST */
candidateScoreSource: string;
/** HR 适合度分来源,如 HR_MANUAL */
hrScoreSource: string;
/** 企业要求的最低候选人意向分(0-100) */
companyRequiredCandidateScore: number;
/** 学生要求的最低 HR 适配分(0-100) */
studentRequiredHrScore: number;
applicationId: number;
jobId: number;
companyId: number;
candidateId: number;
/** 候选人意向分(0-100,仅打分接口下发) */
candidateInterestScore?: number;
/** 企业 HR 适配分(0-100,仅打分接口下发) */
hrSuitabilityScore?: number;
}
/** 分页结果(后端统一分页结构) */
export interface PageResult<T> {
items: T[];

12
src/types/index.ts

@ -151,7 +151,14 @@ export type {
UserProfile,
UserStats,
} from './user';
export type { WeightDimension, WeightSettings, WeightTemplate } from './weight';
export type {
BackendReviewWeight,
BackendWeightTemplate,
WeightDimension,
WeightSettings,
WeightSource,
WeightTemplate,
} from './weight';
export type {
Resume,
ResumeAIActionResult,
@ -321,12 +328,15 @@ export type { QualificationInfo, QualificationLicense, QualificationLicenseType
export type {
ApplicationDetail,
ApplicationItem,
ApplicationMatch,
ApplicationStatus,
ApplicationTimelineEvent,
BackendApplicationTimelineEvent,
CandidateFeedback,
CandidateFeedbackInput,
CompanyApplicationItem,
CompanyApplicationQuery,
CompanyCandidateDetail,
MatchRule,
PageResult,
} from './application';

52
src/types/weight.ts

@ -2,30 +2,70 @@
* 权重设置相关类型定义
*/
/** 权重模板 */
/** 权重模板(前端视图模型;由后端 /review-weight-templates 映射而来) */
export interface WeightTemplate {
/** 模板标识,取值同后端 templateCode(如 "SALARY_FIRST") */
id: string;
/** 模板名称,如 "薪资优先" */
name: string;
/** 模板描述,如 "薪资30% · 发展15%" */
description: string;
/** 模板各维度权重,键为维度名称,如 { "薪资竞争力": 30, "发展空间": 15 } */
/**
* 模板各维度权重,键为**后端维度 token**(如 { "SALARY_TRANSPARENCY": 30 })。
* 后端不下发维度中文名,展示名由前端 constants 的 WEIGHT_DIMENSION_LABEL 提供。
*/
weights: Record<string, number>;
}
/** 权重维度 */
export interface WeightDimension {
/** 维度标识,取值同后端 token(如 "GROWTH") */
id: string;
/** 维度名称,如 "薪资竞争力" */
/** 维度展示名,如 "成长空间" */
name: string;
/** 权重百分比,如 20 */
/** 权重百分比,如 12.5 */
weight: number;
/** true 表示反向指标(值越低评分越高,如工作强度) */
isReversed?: boolean;
}
/** 权重设置 */
/** 权重来源:系统默认 / 套用模板 / 用户自定义 */
export type WeightSource = 'SYSTEM_DEFAULT' | 'TEMPLATE' | 'CUSTOM';
/** 权重设置(页面消费的视图模型) */
export interface WeightSettings {
dimensions: WeightDimension[];
templates: WeightTemplate[];
}
/** 当前配置来源;仅真实后端下发时存在 */
source?: WeightSource;
/** 当前生效的模板标识;source === 'TEMPLATE' 时存在 */
templateCode?: string;
}
/* ---------------- 后端原始结构(getMyWeights / saveWeights / getTemplates) ---------------- */
/**
* `GET|PUT /profile/me/review-weights` 的 data。
*
* 注意 PUT 的两种**互斥**写入模式(实网 2026-09-30 实测,见《联调问题清单-致后端》§1.12):
* - 套用模板:body 只传 `{ templateCode }` → 响应 `source: "TEMPLATE"`
* - 自定义: body 只传 `{ weights }`(八个维度全给且总和 100)→ 响应 `source: "CUSTOM"`
* 两者同时传、或只传未知 templateCode、或权重不合法,后端一律 400 `code=1001`。
*/
export interface BackendReviewWeight {
source: WeightSource;
/** 仅 source === 'TEMPLATE' 时下发 */
templateCode?: string;
/** 维度 token → 权重;八个维度之和为 100 */
configuredWeights: Record<string, number>;
/** 仅在用户保存过之后下发(系统默认态无此字段) */
updatedAt?: string;
}
/** `GET /review-weight-templates` 的元素 */
export interface BackendWeightTemplate {
code: string;
name: string;
description: string;
weights: Record<string, number>;
}

Loading…
Cancel
Save