Browse Source

feat: deliver V39 job recommendations

feat/message-recall
Zhang Shun 2 months ago
parent
commit
55e39752a4
  1. 16
      docs/backend-development-roadmap.md
  2. 8
      docs/frontend-integration.md
  3. 20
      docs/openapi-rc.json
  4. 454
      docs/v39-job-recommendation-design.md
  5. 42
      docs/verify_openapi.py
  6. 3
      mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java
  7. 13
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  8. 2
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  9. 66
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java
  10. 40
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationProfile.java
  11. 10
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationProfileRepository.java
  12. 203
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java
  13. 29
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationTaxonomy.java
  14. 6
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationTaxonomyRepository.java
  15. 31
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/StudentJobRecommendationFeedback.java
  16. 9
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/StudentJobRecommendationFeedbackRepository.java
  17. 6
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/JobRecommendationItem.java
  18. 3
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationDecisionRequest.java
  19. 3
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationFeedbackRequest.java
  20. 3
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationFeedbackResponse.java
  21. 14
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationProfileRequest.java
  22. 7
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationProfileResponse.java
  23. 3
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/TaxonomyResponse.java
  24. 4
      mydreamjob-server/src/main/resources/application.yml
  25. 77
      mydreamjob-server/src/main/resources/db/migration/V37__job_recommendation_profiles.sql
  26. 53
      mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobRecommendationServiceTest.java

16
docs/backend-development-roadmap.md

@ -26,18 +26,18 @@
| 项目 | 当前状态 |
|---|---|
| OpenAPI | **148 paths、182 operations** |
| Flyway | **V1–V36 已应用**;**V34 为 RIASEC 测评迁移(功能版本 V35)**;**V35 为四维能力测评迁移(功能版本 V36)**;**V36 为防刷风险概览与规则配置迁移(功能版本 V37)** |
| 全量测试 | **1427/1427 通过**(`mvn clean verify`,0 Failures / 0 Errors / 0 Skipped) |
| OpenAPI | **154 paths、190 operations**(V39 已完成,待 Git 检查点) |
| Flyway | **V1–V37 已应用**;V37 为岗位推荐分类、画像与学生反馈迁移(功能版本 V39,真实 MySQL/Docker 关键黑盒已通过) |
| 全量测试 | **1428/1428 通过**(V39 自动化回归,0 Failures / 0 Errors / 0 Skipped) |
| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,已真实验收)**、**V36 四维能力测评(Flyway V35)**、**V37 防刷风险概览与运营规则配置(Flyway V36)**、**V38 四维能力测评有限接入候选评分(无新 Flyway、无新公网接口)**,及认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
| 当前结论 | V35 已通过真实 MySQL/Docker 验收(PASS=39/FAIL=0/SKIP=1;唯一 SKIP 为验收库无 INACTIVE 定义)。**V36 四维能力测评已通过真实 MySQL/Docker 验收(PASS=93/FAIL=0/SKIP=0);V37 防刷风险治理已通过真实 MySQL/Docker 验收(PASS=76/FAIL=0/SKIP=0)。** V37 修复前 4 条 `RISK_EVENT_DETECTED.entity_id=NULL` 审计只作历史观察项,不回填、不影响修复后验收。**V38 已完成代码、自动化与真实 MySQL/Docker 关键黑盒验收:开关关闭/开启、ABILITY 映射、最新结果优先、过期回退、V32 排名、权限、脱敏、快照与队列均通过,未发现 FAIL;当前仅待建立 Git 检查点。** 运行时/静态 OpenAPI 均为 148/182,风险扫描不写候选评分引擎。 |
| 当前结论 | V35 已通过真实 MySQL/Docker 验收(PASS=39/FAIL=0/SKIP=1;唯一 SKIP 为验收库无 INACTIVE 定义)。**V36 四维能力测评已通过真实 MySQL/Docker 验收(PASS=93/FAIL=0/SKIP=0);V37 防刷风险治理已通过真实 MySQL/Docker 验收(PASS=76/FAIL=0/SKIP=0)。** V37 修复前 4 条 `RISK_EVENT_DETECTED.entity_id=NULL` 审计只作历史观察项,不回填、不影响修复后验收。**V38 已完成代码、自动化与真实 MySQL/Docker 关键黑盒验收:开关关闭/开启、ABILITY 映射、最新结果优先、过期回退、V32 排名、权限、脱敏、快照与队列均通过,未发现 FAIL;当前仅待建立 Git 检查点。** **V39 测评驱动岗位推荐已完成真实 MySQL/Docker 关键黑盒:Flyway V37、3 张表/15 条分类、画像审核、FALLBACK/PERSONALIZED、反馈排除/撤销、最新能力结果优先、权限、脱敏、快照不写入和 RabbitMQ 队列均通过,未发现 FAIL;当前仅待建立 Git 检查点。** 运行时/静态 OpenAPI 均为 154/190。 |
| 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 |
### 2.0 模块状态分类
| 类别 | 模块 |
|---|---|
| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,PASS=39/FAIL=0/SKIP=1)**、**V36 四维能力测评(Flyway V35,PASS=93/FAIL=0/SKIP=0)**、**V37 防刷风险概览与运营规则配置(Flyway V36,PASS=76/FAIL=0/SKIP=0)**、**V38 四维能力测评有限接入候选评分(无新 Flyway/公网接口,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)** |
| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合、**V35 RIASEC 职业兴趣测评(Flyway V34,PASS=39/FAIL=0/SKIP=1)**、**V36 四维能力测评(Flyway V35,PASS=93/FAIL=0/SKIP=0)**、**V37 防刷风险概览与运营规则配置(Flyway V36,PASS=76/FAIL=0/SKIP=0)**、**V38 四维能力测评有限接入候选评分(无新 Flyway/公网接口,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)**、**V39 测评驱动岗位推荐(Flyway V37,真实 MySQL/Docker 关键黑盒通过,待 Git 检查点)** |
| 已完成、待真实 MySQL/Docker 复验 | — |
| 已设计未编码 | 公平性检测、SHAP/规则解释、企业评价管理、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 |
@ -132,7 +132,7 @@
- ~~霍兰德职业兴趣测评~~(已由 V35 实现:测评 + 结果 + 探索方向,Flyway V34);
- ~~四维能力测评~~(已由 V36 实现:限时答题、进度保存、结果、短板和历史,Flyway V35);
- 测评岗位推荐(V35/V36 已提供结果,但不根据结果推荐岗位,仍为缺失);
- ~~测评岗位推荐~~(V39 已完成:结构化岗位画像、受控分类、测评驱动推荐、反馈与审核);
- AI 简历诊断和优化;
- AI 模拟面试和面试题预测;
- 行业技能词云;
@ -342,7 +342,7 @@ jobs
2. ~~四维能力测评、答题和进度保存~~(V36 已完成);
3. ~~测评结果~~(V35/V36 已完成);
4. 测评结果进入 ABILITY/GROWTH(V38 已完成 ABILITY 有限接入;GROWTH 仍需独立评审与后续实现);
5. 根据测评结果推荐岗位。
5. ~~根据测评结果推荐岗位~~(V39 已完成)。
建议接口:
@ -457,7 +457,7 @@ scoring_risk_scan_checkpoints
| P0 | 个人数据导出 |
| P0 | 结构化面试评分 |
| P0/P1 | 同岗位竞争力排名 |
| P1 | 测评结果进入评分引擎、测评岗位推荐 |
| P1 | 测评结果进入评分引擎、测评岗位推荐(V38/V39 已完成) |
| P1 | 统一求职进度(V34 已完成) |
| P1 | 企业评价管理 |
| P1 | 企业人才推荐 |

8
docs/frontend-integration.md

@ -861,3 +861,11 @@ POST /admin/scoring/risk/events/{eventId}/actions 人工标记(ACKNOWLEDGE/DI
- 风险入口仅在管理后台;学生/企业端无任何风险接口。
- 规则/阈值的命中可能不会立刻出现:扫描默认每 60 秒跑一次(`app.risk.scan.fixed-delay-ms`)。
- 概览/趋势/分布由后端 SQL 聚合返回,前端无需额外拉取明细。
## 24. V39 测评驱动岗位推荐(编码中)
- 开关 `RECOMMENDATION_ASSESSMENT_ENABLED` 默认 `false`:关闭时 `GET /profile/me/job-recommendations` 仅返回 `mode=FALLBACK` 的最新公开岗位,不展示伪造的测评匹配分。
- 开启后按学生偏好 40%、RIASEC 聚合分 35%、四维能力聚合分 25% 做实时规则计算;缺少任一数据源时对可用项重归一化。推荐不读取题目、答案或候选评分快照。
- 新增公开 `GET /job-recommendation-taxonomies`;企业通过 `GET/PUT /companies/me/jobs/{jobId}/recommendation-profile` 选择平台受控行业/岗位族并填写画像。更新启用画像后状态为 `PENDING_REVIEW`,仅 ADMIN 审核通过的启用画像参与个性化推荐。
- 学生接口:`GET /profile/me/job-recommendations?page&size`;`POST /profile/me/job-recommendations/{jobId}/feedback`;`DELETE .../feedback?expectedVersion=`。`NOT_INTERESTED` 会持久化排除该岗位;解释反馈不影响首版分数。
- ADMIN:`GET /admin/job-recommendation-profiles`、`POST /admin/job-recommendation-profiles/{jobId}/decision`。企业端不得取得任何学生分数或推荐理由;学生端不得取得画像权重、测评结果 ID 或题库字段。

20
docs/openapi-rc.json

@ -8048,10 +8048,28 @@
}
}
}
}
},
"/job-recommendation-taxonomies": {"get":{"tags":["岗位推荐"],"operationId":"recommendationTaxonomies","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseListTaxonomyResponse"}}}}}}},
"/profile/me/job-recommendations": {"get":{"tags":["岗位推荐"],"operationId":"myJobRecommendations","parameters":[{"name":"page","in":"query","schema":{"type":"integer"}},{"name":"size","in":"query","schema":{"type":"integer"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponsePageJobRecommendationItem"}}}}}}},
"/profile/me/job-recommendations/{jobId}/feedback": {"post":{"tags":["岗位推荐"],"operationId":"createRecommendationFeedback","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecommendationFeedbackRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationFeedbackResponse"}}}}}},"delete":{"tags":["岗位推荐"],"operationId":"revokeNotInterestedFeedback","parameters":[{"name":"expectedVersion","in":"query","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"OK"}}}},
"/companies/me/jobs/{jobId}/recommendation-profile": {"get":{"tags":["岗位推荐"],"operationId":"companyRecommendationProfile","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationProfileResponse"}}}}}},"put":{"tags":["岗位推荐"],"operationId":"updateCompanyRecommendationProfile","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecommendationProfileRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationProfileResponse"}}}}}}},
"/admin/job-recommendation-profiles": {"get":{"tags":["岗位推荐"],"operationId":"adminRecommendationProfiles","responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponsePageRecommendationProfileResponse"}}}}}}},
"/admin/job-recommendation-profiles/{jobId}/decision": {"post":{"tags":["岗位推荐"],"operationId":"decideRecommendationProfile","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecommendationDecisionRequest"}}}},"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiResponseRecommendationProfileResponse"}}}}}}}
},
"components": {
"schemas": {
"TaxonomyResponse":{"type":"object","properties":{"code":{"type":"string"},"parentCode":{"type":"string"},"type":{"type":"string"},"displayName":{"type":"string"}}},
"RecommendationFeedbackRequest":{"type":"object","required":["feedbackType","idempotencyKey","expectedVersion"],"properties":{"feedbackType":{"type":"string"},"reasonType":{"type":"string"},"idempotencyKey":{"type":"string"},"expectedVersion":{"type":"integer","format":"int64"}}},
"RecommendationProfileRequest":{"type":"object","required":["industryCode","jobFamilyCode","enabled","expectedVersion"],"properties":{"industryCode":{"type":"string"},"jobFamilyCode":{"type":"string"},"riasecWeights":{"type":"object","additionalProperties":{"type":"integer"}},"abilityExpectations":{"type":"object","additionalProperties":{"type":"integer"}},"enabled":{"type":"boolean"},"expectedVersion":{"type":"integer","format":"int64"}}},
"RecommendationDecisionRequest":{"type":"object","required":["decision","expectedVersion"],"properties":{"decision":{"type":"string","enum":["APPROVE","REJECT"]},"reasonCode":{"type":"string"},"expectedVersion":{"type":"integer","format":"int64"}}},
"RecommendationProfileResponse":{"type":"object","properties":{"jobId":{"type":"integer","format":"int64"},"industryCode":{"type":"string"},"jobFamilyCode":{"type":"string"},"riasecWeights":{"type":"object","additionalProperties":{"type":"integer"}},"abilityExpectations":{"type":"object","additionalProperties":{"type":"integer"}},"enabled":{"type":"boolean"},"reviewStatus":{"type":"string"},"reviewReason":{"type":"string"},"version":{"type":"integer","format":"int64"},"updatedAt":{"type":"string","format":"date-time"}}},
"RecommendationFeedbackResponse":{"type":"object","properties":{"jobId":{"type":"integer","format":"int64"},"feedbackType":{"type":"string"},"reasonType":{"type":"string"},"status":{"type":"string"},"version":{"type":"integer","format":"int64"}}},
"JobRecommendationItem":{"type":"object","properties":{"jobId":{"type":"integer","format":"int64"},"title":{"type":"string"},"city":{"type":"string"},"employmentType":{"type":"string"},"salaryMin":{"type":"integer"},"salaryMax":{"type":"integer"},"mode":{"type":"string","enum":["PERSONALIZED","FALLBACK"]},"score":{"type":"number"},"reasons":{"type":"array","items":{"type":"string"}}}},
"ApiResponseListTaxonomyResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/TaxonomyResponse"}}}},
"ApiResponseRecommendationFeedbackResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RecommendationFeedbackResponse"}}},
"ApiResponseRecommendationProfileResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/RecommendationProfileResponse"}}},
"ApiResponsePageJobRecommendationItem":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/JobRecommendationItem"}},"total":{"type":"integer"},"page":{"type":"integer"},"size":{"type":"integer"},"totalPages":{"type":"integer"}}}}},
"ApiResponsePageRecommendationProfileResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RecommendationProfileResponse"}},"total":{"type":"integer"},"page":{"type":"integer"},"size":{"type":"integer"},"totalPages":{"type":"integer"}}}}},
"AccountDeletionResponse": {
"type": "object",
"properties": {

454
docs/v39-job-recommendation-design.md

@ -0,0 +1,454 @@
# V39:测评结果驱动岗位推荐设计
> 状态:**编码完成,真实 MySQL/Docker 关键黑盒已通过,待 Git 检查点。**
> 功能版本:V39。
> 当前基线:Flyway 迁移新增 `V37__job_recommendation_profiles.sql`;静态 OpenAPI 为 `154 paths / 190 operations`。
> 文档用途:V39 第一版规则、接口、安全边界与验收口径的权威设计说明。
## 1. 目标与边界
### 1.1 目标
为学生提供“可解释、可复现、可关闭”的岗位推荐列表。推荐只使用学生已授权产生的偏好、V35 RIASEC 职业兴趣结果和 V36 四维能力结果,并结合企业为岗位维护的结构化推荐画像。
推荐的含义是“当前岗位与学生已知偏好的匹配程度”,不保证录用概率,不构成职业、心理或就业诊断。
### 1.2 第一版范围
- 仅 STUDENT 查看自己的推荐岗位。
- 仅推荐状态为 `PUBLISHED` 且学生尚未投递的岗位。
- 使用学生偏好、最新完成的 RIASEC 结果、最新有效的四维能力结果和岗位推荐画像。
- 企业只能维护自己岗位的推荐画像;不能查询任何学生的测评明细、推荐分或推荐理由。
- 返回有限、白名单化的推荐理由和缺口提示。
- 规则实时计算;不保存学生级推荐历史、点击画像或行为画像。
### 1.3 明确不在本版范围
- 不使用 AI、大模型、简历文本向量或隐式行为画像。
- 不自动投递、不替学生收藏、不发送推荐通知。
- 不增加企业人才推荐或企业查看学生测评详情。
- 不接入 V38 之外的 `GROWTH` 评分维度,不写 `candidate_dimension_scores`。
- 不把 RIASEC 结果当作职业诊断,不根据测评结果拒绝学生投递。
- 实现“我不感兴趣”持久化,以及推荐解释的有帮助/无帮助反馈;首版不将反馈用于模型训练。
## 2. 现状核查结论
| 已有能力 | 当前状态 | 对 V39 的影响 |
|---|---|---|
| 岗位搜索 | `/jobs` 已支持城市、薪资、用工类型、关键词和分页 | 可复用公开岗位视图和发布状态口径 |
| 单岗位匹配诊断 | `GET /jobs/{jobId}/match`,按学生偏好计算 | 只适合单岗位说明;内部会写审计,不应在推荐列表中逐岗位循环调用 |
| 学生偏好 | 城市、薪资、用工类型、行业、岗位类型、远程意愿 | 可作为硬过滤和软匹配数据 |
| V35 RIASEC | 最新完成结果可提供六维兴趣聚合分 | 可作为兴趣匹配输入;不读取答案和题目 |
| V36 四维能力 | 最新有效完成结果可提供四维聚合分和有效期 | 可作为能力适配输入;不依赖 V38 开关 |
| V38 评分接入 | ABILITY 可有限进入候选评分 | V39 直接读 V36 聚合结果,不读取候选评分快照,避免推荐随评分开关改变 |
| `jobs` 表 | 只有标题、描述、要求、城市、薪资、用工类型 | 缺少可靠的 RIASEC/能力要求字段,必须新增结构化岗位推荐画像 |
## 3. 总体方案
### 3.1 方案选择
采用“**规则推荐 + 岗位结构化画像 + 实时只读计算**”方案。
```text
学生偏好 ─────────────┐
V35 RIASEC 聚合结果 ──┼─> RecommendationService ─> 推荐岗位列表 + 理由/缺口
V36 能力聚合结果 ─────┤ ↑
岗位推荐画像 ─────────┘ └─ 仅查询 PUBLISHED、未投递岗位
```
不能使用岗位标题、岗位描述或 `requirements` 进行关键词猜测来代替岗位画像:该做法不可解释、难以测试,也会把同义词、否定语句和描述质量差异误当成职业要求。
### 3.2 推荐画像的维护主体
企业在创建或维护岗位后,为本人岗位维护一份结构化推荐画像。画像只描述岗位,不包含学生数据;它包含:
- 行业代码与岗位族代码,用于与学生偏好精确比较;
- RIASEC 六维权重,用于表达该岗位偏好的兴趣侧重点;
- 四维能力期望值,用于表达完成该岗位所需的基础能力水平;
- 是否启用测评推荐。
企业画像是“岗位推荐参考”,不是招聘门槛。企业仍需通过原有岗位说明、投递、面试与 Offer 流程进行评估。
### 3.3 平台受控分类与审核流
行业代码和岗位族代码采用“**平台维护、企业选择、不得自由输入**”的两级受控字典:
```text
行业大类(industry)
└─ 岗位族(job family)
└─ 企业保留自由填写的岗位标题(jobs.title)
```
该选择参考了主流招聘产品按大类、细分职类组织职位浏览和筛选的做法:分类用于匹配与检索,岗位标题保留企业对具体职位的表达。第一版字典由产品负责人确定编码、后端随 Flyway 种子发布;ADMIN 只审核企业的岗位画像,不可在线任意新增分类。分类变更必须通过代码评审、版本化种子和兼容迁移完成。
建议首批受控岗位族覆盖当前产品最常见的求职方向:互联网技术、产品、设计、运营/市场、销售/客户成功、人力/行政/财务/法务、生产制造/工程、教育/医疗、供应链/物流、零售/生活服务和综合管理。每个岗位族必须隶属唯一行业大类,编码一经发布不得重用。
企业首次保存或修改已审核画像后,画像进入 `PENDING_REVIEW`;只有 `APPROVED` 且 `enabled=true` 的画像可用于个性化推荐。ADMIN 使用固定理由码审批或驳回。企业主动停用画像可立即生效,不需要等待审核。
### 3.4 时间一致性
一次推荐请求在服务入口生成唯一的 `capturedNow`,V36 结果有效性、规则版本和返回时间均使用该时间点。不得在同一请求内多次调用 `now()` 导致结果边界不一致。
## 4. 数据来源与有效性
| 来源 | 选择规则 | 无数据/异常处理 | 禁止读取 |
|---|---|---|---|
| 学生偏好 | 本人 `student_preferences`,至多一条 | 缺字段时该偏好项不计入分母 | 其他学生偏好 |
| RIASEC | `HOLLAND_RIASEC` 类型、`COMPLETED`、按完成时间倒序和答卷 ID 倒序取一条 | JSON 损坏、缺维、分数越界时兴趣信号不可用 | 原始答案、题目、questionCode |
| 四维能力 | `WORKPLACE_ABILITY`、`COMPLETED`、`valid_until > capturedNow`、按完成时间倒序和答卷 ID 倒序取一条 | 过期、缺维、不是 24/24 完成时能力信号不可用 | 原始答案、题目反馈、答案键、评分规则 |
| 岗位画像 | `job_recommendation_profiles.enabled=true`、`review_status=APPROVED` 且关联 `PUBLISHED` 岗位 | 无画像或待审核画像不宣称“测评匹配”;仅进入兜底列表 | 企业内部非公开数据 |
| 投递记录 | 当前学生与岗位的任何已有投递记录 | 已投递岗位从推荐候选中排除 | 其他学生投递数据 |
| 推荐反馈 | 本人对岗位的有效 `NOT_INTERESTED` 反馈 | 仅排除本人明确不感兴趣的岗位;理由反馈不改变排序 | 其他学生反馈 |
RIASEC 只使用六维聚合百分比;四维能力只使用 LOGIC、COMMUNICATION、EXECUTION、LEARNING 四个聚合分。两类结果均须通过服务端结构校验后才可参与排序。
## 5. 推荐候选、过滤与排序
### 5.1 硬过滤
推荐候选必须同时满足:
1. 岗位状态为 `PUBLISHED`;
2. 当前学生不存在该岗位的任何投递记录,避免推荐一个无法再次投递的岗位;
3. 若学生已设置期望城市,岗位城市必须命中;
4. 若学生已设置期望薪资下限且岗位有薪资上限,学生下限不得高于岗位上限;
5. 当前学生没有对该岗位提交有效的 `NOT_INTERESTED` 反馈;
6. 岗位推荐画像存在、已启用且审核状态为 `APPROVED` 时,才进入“个性化推荐”排序。
城市和薪资口径与现有单岗位匹配诊断保持一致。用工类型、行业和岗位族是软信号,不作为拒绝或隐藏条件。
### 5.2 三类软信号
| 信号 | 默认权重 | 计算方式 |
|---|---:|---|
| 偏好适配 `P` | 40% | 城市 30、用工类型 20、薪资 20、行业 15、岗位族 15;只对学生已设置且岗位有对应结构化值的字段纳入分母 |
| 兴趣适配 `I` | 35% | `Σ(RIASEC学生维度分 × 岗位该维权重) / Σ岗位兴趣权重` |
| 能力适配 `A` | 25% | 对每个已配置能力期望值:学生分数大于等于期望值记 100,否则 `学生分 / 期望分 × 100`;再取已配置维度平均值 |
总分公式:
```text
recommendationScore =
(P × 40 + I × 35 + A × 25) / 可用信号权重之和
```
结果保留两位小数,使用 `HALF_UP`。缺失或无效信号不记为 0,而是从分母中移除;这样不会因为学生尚未完成某项测评而被无理由降分。
如果偏好、兴趣、能力三者都不可用,则该岗位不能标为个性化推荐。
### 5.3 排序与兜底
个性化岗位按以下顺序排序:
1. `recommendationScore DESC`;
2. `published_at DESC`;
3. `job_id DESC`。
若没有任何可用个性化结果,接口返回 `mode=FALLBACK` 的公开岗位兜底列表,只按发布时间排序,`recommendationScore=null`,并明确说明“完善偏好或完成测评后可获得个性化推荐”。兜底列表绝不伪装为个性化结果。
### 5.4 理由与缺口
每个个性化岗位最多返回 3 条理由和 2 条建议,均由固定白名单模板生成,例如:
- `PREFERENCE_CITY_MATCH`:工作城市符合你的偏好;
- `PREFERENCE_SALARY_MATCH`:薪资范围满足你的期望;
- `RIASEC_INTEREST_MATCH`:职业兴趣与岗位侧重点较匹配;
- `ABILITY_STRENGTH_MATCH`:你的能力表现满足该岗位的主要侧重点;
- `ABILITY_GAP`:可优先提升与岗位相关的某项能力;
- `PREFERENCE_GAP`:用工类型或岗位族与当前偏好存在差异。
不返回 RIASEC 原始六维向量、能力答卷 ID、结果 ID、题目、答案、答案键、评分规则或企业配置原文。
## 6. 数据模型与 Flyway(建议,待确认)
### 6.1 迁移
当前最新迁移为 V36;建议新增:
```text
V37__job_recommendation_profiles.sql
```
功能版本仍为 V39。该单一迁移创建岗位画像、受控分类和学生反馈三张相关表,不修改 V1–V36 的语义。
### 6.2 `job_recommendation_taxonomies`
平台受控字典表,随 `job-taxonomy-v0.1` 种子初始化。
| 字段 | 说明 |
|---|---|
| `code` | 不可重用的稳定编码,主键 |
| `parent_code` | 岗位族指向所属行业大类;行业大类为 `NULL` |
| `taxonomy_type` | `INDUSTRY` 或 `JOB_FAMILY` |
| `display_name` | 前端展示名 |
| `active` | 是否仍允许企业新选用 |
| `sort_order`、`catalog_version` | 展示顺序与目录版本 |
服务端必须校验 `industry_code` 是有效行业大类、`job_family_code` 是该行业下有效岗位族;企业不得传任意文本或跨层级组合。
### 6.3 `job_recommendation_profiles`
| 字段 | 说明 |
|---|---|
| `id` | 主键 |
| `job_id` | 外键至 `jobs.id`,唯一,一岗一份画像 |
| `enabled` | 是否参与个性化推荐,默认 `false` |
| `review_status` | `PENDING_REVIEW`、`APPROVED`、`REJECTED`、`DISABLED` |
| `industry_code` | 必填、受控行业大类编码 |
| `job_family_code` | 必填、受控岗位族编码 |
| `riasec_r` 至 `riasec_c` | 六维兴趣权重,`0..100`,至少一个非零才可启用兴趣信号 |
| `logic_requirement` 等四列 | 四维能力期望值,`0..100`;`NULL` 表示该维不参与能力适配 |
| `reviewed_by_user_id`、`reviewed_at` | 最近一次审核人和审核时间,仅内部使用 |
| `review_reason_code` | 审核/驳回的固定理由码,不存自由文本 |
| `profile_version` | 乐观锁版本 |
| `created_at`、`updated_at` | 审计时间 |
约束与索引:
- `UNIQUE(job_id)`;
- `FOREIGN KEY(job_id) REFERENCES jobs(id)`;
- 所有权重和能力期望必须在 `0..100`;
- 建立 `(enabled, review_status, job_id)` 索引,支撑可推荐画像的候选岗位查询;
- 使用 JPA `@Version` 和 `expectedVersion` 防止企业并发覆盖配置。
### 6.4 `student_job_recommendation_feedback`
仅保存学生主动提交的最小化反馈,不保存自动点击画像。
| 字段 | 说明 |
|---|---|
| `id` | 主键 |
| `student_user_id`、`job_id` | 反馈所属学生和岗位 |
| `feedback_type` | `NOT_INTERESTED`、`REASON_HELPFUL`、`REASON_NOT_HELPFUL` |
| `reason_code` | 可选固定理由码;不得提交自由文本、测评内容或个人信息 |
| `feedback_key` | `student + job + type + reasonCode` 组成的非空去重键 |
| `status` | `ACTIVE` 或 `REVOKED`;撤销不物理删除 |
| `idempotency_key` | 同一学生请求幂等键唯一;同键异体返回冲突 |
| `created_at`、`updated_at`、`version` | 审计时间与乐观锁 |
`NOT_INTERESTED` 有效时排除该岗位;理由是否“有帮助”只用于后续人工优化规则,不自动改变学生分、岗位分或排序公式。
## 7. API 设计(建议,待确认)
### 7.1 学生推荐列表
```http
GET /profile/me/job-recommendations?page=0&size=20
```
权限:仅 STUDENT 本人。`page` 从 0 开始,`size` 范围 1–50。
响应核心字段:
```json
{
"mode": "PERSONALIZED",
"rulesVersion": "job-recommendation-v0.1",
"items": [
{
"job": { "id": 101, "title": "后端开发工程师", "city": "合肥" },
"recommendationScore": 82.50,
"reasons": ["PREFERENCE_CITY_MATCH", "RIASEC_INTEREST_MATCH"],
"suggestions": ["ABILITY_GAP"],
"availableSignals": ["PREFERENCE", "RIASEC", "ABILITY"]
}
],
"page": 0,
"size": 20,
"total": 1,
"totalPages": 1,
"generatedAt": "2026-09-04T12:00:00"
}
```
岗位详情继续复用既有 `GET /jobs/{jobId}`;如需查看单个岗位的偏好诊断,继续复用既有 `GET /jobs/{jobId}/match`。
### 7.2 企业岗位画像查询与更新
```http
GET /companies/me/jobs/{jobId}/recommendation-profile
PUT /companies/me/jobs/{jobId}/recommendation-profile
```
仅岗位所属 COMPANY 可查询和更新。更新请求需带 `expectedVersion`,并仅接受固定字段:`enabled`、`industryCode`、`jobFamilyCode`、`riasecWeights`、`abilityRequirements`。新建/更新后进入 `PENDING_REVIEW`;只有企业主动 `enabled=false` 时可转为 `DISABLED`。
不提供 DELETE;企业通过 `enabled=false` 停用画像,保留配置和审计链路。
### 7.3 受控分类、学生反馈与 ADMIN 审核
```http
GET /job-recommendation-taxonomies
POST /profile/me/job-recommendations/{jobId}/feedback
DELETE /profile/me/job-recommendations/{jobId}/feedback
GET /admin/job-recommendation-profiles?page=0&size=20&status=PENDING_REVIEW
POST /admin/job-recommendation-profiles/{jobId}/decision
```
分类接口只返回 `code`、`parentCode`、`type`、`displayName`、`sortOrder` 和 `catalogVersion`;无需登录即可让岗位发布和岗位搜索复用同一字典。
反馈创建必须携带 `idempotencyKey`。`NOT_INTERESTED` 的撤销只把对应记录改为 `REVOKED`;不删除审计记录。ADMIN 审核请求只接受 `APPROVE` 或 `REJECT` 与固定 `reasonCode`,不接受自由文本。
### 7.4 OpenAPI 预估
新增 6 个路径、8 个 operation:
```text
148 paths / 182 operations → 154 paths / 190 operations
```
编码时必须以运行时 OpenAPI 为准更新 `docs/openapi-rc.json` 与 `docs/verify_openapi.py`,不允许仅修改静态计数。
## 8. 权限、隐私与审计
| 操作 | STUDENT | COMPANY | ADMIN | 未登录 |
|---|---:|---:|---:|---:|
| 查看本人推荐列表 | 200 | 403/2002 | 403/2002 | 401 |
| 提交/撤销本人推荐反馈 | 200 | 403/2002 | 403/2002 | 401 |
| 查看本人岗位匹配诊断 | 既有行为 | 既有行为 | 既有行为 | 既有行为 |
| 查看自己岗位画像 | 403/2002 | 仅所属岗位 200 | 403/2002 | 401 |
| 更新自己岗位画像 | 403/2002 | 仅所属岗位 200,进入待审核 | 403/2002 | 401 |
| 查询受控分类 | 200 | 200 | 200 | 200 |
| 查看待审核岗位画像 | 403/2002 | 403/2002 | 200 | 401 |
| 审批岗位画像 | 403/2002 | 403/2002 | 200 | 401 |
审计动作建议:
- `JOB_RECOMMENDATION_PROFILE_UPDATED`:仅记录 `jobId`、画像版本、是否启用、请求 ID 和时间;
- `JOB_RECOMMENDATION_PROFILE_DECIDED`:仅记录 `jobId`、决定、固定理由码、画像版本、请求 ID 和时间;
- `JOB_RECOMMENDATION_FEEDBACK_CREATED` / `JOB_RECOMMENDATION_FEEDBACK_REVOKED`:仅记录 `jobId`、反馈类型、固定理由码、请求 ID 和时间;
- `JOB_RECOMMENDATIONS_VIEWED`:可选,仅记录返回数量、规则版本和请求 ID;不得记录完整排序、测评分、题目、答案、手机、邮箱、IP、User-Agent 或 token。
公司端绝不获得学生的 RIASEC、四维能力、候选评分、推荐分、推荐理由或缺口。学生端也不获得企业配置的完整权重和能力期望值。
## 9. 模块边界、性能与并发
### 9.1 建议模块划分
- `JobRecommendationProfile` / Repository:岗位画像配置;
- `JobRecommendationTaxonomy` / Repository:平台受控行业与岗位族字典;
- `StudentJobRecommendationFeedback` / Repository:学生显式反馈及撤销状态;
- `JobRecommendationProfileService`:企业画像读取、所有权校验和乐观锁更新;
- `JobRecommendationAdminService`:待审核画像分页和固定决定流转;
- `StudentRecommendationSource`:一次读取学生偏好、最新有效 V35/V36 聚合结果;
- `JobRecommendationCalculator`:纯函数,执行结构校验、分数公式和理由模板;
- `JobRecommendationService`:过滤、分页、排序和响应白名单;
- `JobRecommendationController`:仅从 `SecurityContext` 获取当前用户 ID。
### 9.2 N+1 防护
推荐列表不得对每个岗位调用现有 `MatchService.diagnose()`:该方法会逐岗位读数据并写审计,既会造成 N+1,也会污染审计。
推荐请求的目标查询上限为固定次数:
1. 学生偏好一次;
2. 最新 RIASEC 结果一次;
3. 最新有效四维能力结果一次;
4. 分页候选岗位及画像一次;
5. 当前学生已投递岗位 ID 集合一次;
6. 当前学生有效 `NOT_INTERESTED` 岗位 ID 集合一次;
7. 页面企业信息批量加载一次。
岗位排序应使用单条参数化 SQL/JPA 查询按推荐表达式完成,或在有明确候选上限时使用固定批量加载;禁止“每岗位再查画像、公司、投递或测评”的循环查询。编码后必须用 Hibernate/JDBC Spy 断言查询次数不随页面 items 数线性增加。
### 9.3 并发
- 推荐查询只读、无幂等键要求;
- 企业更新画像、学生反馈撤销和 ADMIN 审核均使用 `expectedVersion` + `@Version`,冲突返回 409;
- 岗位关闭、画像停用或学生完成投递后,下一次查询自然不再返回该岗位;不缓存旧结果。
## 10. 错误码(建议)
建议使用新段 `10001–10009`:
| 错误码 | HTTP | 含义 |
|---|---:|---|
| `10001` | 404 | `JOB_RECOMMENDATION_PROFILE_NOT_FOUND` |
| `10002` | 400 | `JOB_RECOMMENDATION_PROFILE_INVALID` |
| `10003` | 409 | `JOB_RECOMMENDATION_PROFILE_CONCURRENT_UPDATE` |
| `10004` | 400 | `JOB_RECOMMENDATION_INVALID_QUERY` |
| `10005` | 400 | `JOB_RECOMMENDATION_TAXONOMY_INVALID` |
| `10006` | 409 | `JOB_RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE` |
| `10007` | 409 | `JOB_RECOMMENDATION_FEEDBACK_IDEMPOTENCY_CONFLICT` |
| `10008` | 404 | `JOB_RECOMMENDATION_FEEDBACK_NOT_FOUND` |
| `10009` | 409 | `JOB_RECOMMENDATION_PROFILE_INVALID_REVIEW_STATE` |
现有岗位不存在、岗位归属不匹配、未登录和角色越权继续使用既有错误码,不重复造码。
## 11. 功能开关、兼容与回滚
建议新增默认关闭的开关:
```yaml
recommendation:
assessment:
enabled: ${RECOMMENDATION_ASSESSMENT_ENABLED:false}
```
开关关闭时,学生推荐接口只返回 `FALLBACK` 公开岗位列表,不查询测评结果、不生成个性化分;企业仍可维护岗位画像,为灰度开启做准备。
开关开启时规则版本为 `job-recommendation-v0.1`。回滚只需关闭开关;画像数据保留,不删除岗位、测评、投递、评分或审计数据。
V39 不改变:
- V35/V36 的题库、答题、重测、有效期和审计语义;
- V38 的候选评分开关、ABILITY 快照和 V32 排名口径;
- 既有 `/jobs` 搜索、`/jobs/{jobId}/match`、投递和收藏接口语义。
## 12. 测试与验收计划
### 12.1 单元测试
- RIASEC 与四维能力来源选择:最新优先、过期回退、结构异常不抛 500;
- 公式:单信号、双信号、三信号、缺失重归一化、`HALF_UP`、稳定排序;
- 硬过滤:未发布、已投递、城市不符、薪资不符;
- 理由/建议白名单和最大数量;
- 受控分类父子校验、画像字段边界、启用条件和审核状态机;
- 反馈幂等、撤销、不感兴趣过滤和理由反馈不改变推荐分;
- 画像、反馈和审核的乐观锁冲突。
### 12.2 MockMvc 与持久化测试
- STUDENT/COMPANY/ADMIN/未登录权限矩阵;
- 企业只能读写自己的岗位画像,更新后必须进入待审核状态;
- ADMIN 只能审核、不可修改企业画像内容;非 ADMIN 审核返回 403;
- 分类接口仅返回受控字典;自由行业/岗位族编码返回 400;
- 推荐响应不含测评题目、答案、答案键、结果 ID、原始画像权重;
- 无画像/无测评返回 `FALLBACK`,不伪造个性化分;
- 分页参数、空列表、推荐总数和稳定排序;
- 查询次数固定,禁止推荐列表 N+1;
- 不写 `candidate_dimension_scores`、不改变 V32 排名。
### 12.3 MySQL/Docker 黑盒
1. 执行 V37 迁移,校验三张表、唯一约束、外键、范围约束、分类种子和索引;
2. 企业创建/更新自己岗位画像,其他企业返回 403,ADMIN 审批后才可用于推荐;
3. 学生完成 RIASEC 和能力测评后,推荐分与理由符合固定 fixture;
4. 验证历史高分、最新低分和能力过期三种选择口径;
5. 开关关闭返回 `FALLBACK`,开启后才产生 `PERSONALIZED`;
6. 已投递、明确不感兴趣、关闭、城市/薪资不符岗位不会进入个性化列表;
7. 反馈重复提交、同键重放、撤销重试和审核并发均满足预期业务码;
8. 响应和审计不泄露题目、答案、评分规则、结果 ID 或凭据;
9. RabbitMQ 队列终态正常,并回归 V34–V38 关键接口。
冻结条件:全量 `mvn clean verify`、运行时/静态 OpenAPI 对账、真实 MySQL/Docker 黑盒关键项全部 PASS,且 FAIL=0。
## 13. 已确认决策与编码前最后口径
负责人已确认:
1. 新增岗位推荐画像与 Flyway `V37__job_recommendation_profiles.sql`;
2. 接受默认排序权重:偏好 40%、RIASEC 35%、能力 25%;
3. 无结构化岗位画像时仅返回 `FALLBACK`,不伪造测评推荐;
4. 任何已有投递的岗位均不再推荐;
5. 默认关闭 `recommendation.assessment.enabled`,支持灰度;
6. 第一版加入学生“不感兴趣”持久化和推荐理由反馈;
7. 按负责人第 8 项“是”处理:第一版需要 ADMIN 审核企业岗位画像。
基于第 6、7 项,原先的 3 个 operation 扩展为 8 个 operation,且迁移包含三个相关表。这是已确认产品范围变化,不是无关扩张。
编码开始前只需最终确认以下两项具体口径:
1. 受控分类采用“平台种子维护、企业只能选择、不允许自由输入”,并按 `job-taxonomy-v0.1` 首批目录落库;
2. ADMIN 审核语义确为“企业提交/更新画像后进入 `PENDING_REVIEW`,审核通过才参与推荐”。
若以上两项确认,即可进入 V39 编码;在此之前不得创建迁移、Controller 或前端接口实现。

42
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 148, f"Expected 148 paths, got {len(d['paths'])}"
assert len(d['paths']) == 154, f"Expected 154 paths, got {len(d['paths'])}"
paths = d['paths']
required = [
@ -270,10 +270,46 @@ for f in risk_forbidden:
assert False, f"schema {name} exposes forbidden field {f}"
print(" Risk schemas expose no subjectUserId/operatorUserId/phone/email/ip/userAgent/details/credentials")
# 统计 operation 总数并校验基线 182
# V39 测评驱动岗位推荐:6 path / 8 operation;学生反馈、企业画像、ADMIN 审核与公开受控字典。
v39 = [
'/job-recommendation-taxonomies',
'/profile/me/job-recommendations',
'/profile/me/job-recommendations/{jobId}/feedback',
'/companies/me/jobs/{jobId}/recommendation-profile',
'/admin/job-recommendation-profiles',
'/admin/job-recommendation-profiles/{jobId}/decision',
]
assert all(k in paths for k in v39), "One or more V39 paths are missing"
assert len(v39) == 6, f"Expected exactly 6 V39 paths, got {len(v39)}"
assert sum(len(paths[k]) for k in v39) == 8, "Expected exactly 8 V39 operations"
for pt, method in [
('/job-recommendation-taxonomies', 'get'),
('/profile/me/job-recommendations', 'get'),
('/profile/me/job-recommendations/{jobId}/feedback', 'post'),
('/profile/me/job-recommendations/{jobId}/feedback', 'delete'),
('/companies/me/jobs/{jobId}/recommendation-profile', 'get'),
('/companies/me/jobs/{jobId}/recommendation-profile', 'put'),
('/admin/job-recommendation-profiles', 'get'),
('/admin/job-recommendation-profiles/{jobId}/decision', 'post'),
]:
assert method in paths[pt], f"Missing {method.upper()} {pt}"
for pt, method in [
('/profile/me/job-recommendations/{jobId}/feedback', 'post'),
('/companies/me/jobs/{jobId}/recommendation-profile', 'put'),
('/admin/job-recommendation-profiles/{jobId}/decision', 'post'),
]:
assert paths[pt][method].get('requestBody', {}).get('content', {}).get('application/json', {}).get('schema', {}).get('$ref'), f"{method.upper()} {pt} requestBody schema missing"
recommendation_sensitive = ['answerKey', 'scoringRule', 'questionCode', 'competencyTag', 'resultId', 'attemptId', 'studentUserId']
for f in recommendation_sensitive:
for name, sch in d['components']['schemas'].items():
if name.startswith(('JobRecommendation', 'Recommendation')) and f in json.dumps(sch):
assert False, f"schema {name} exposes forbidden field {f}"
print(" V39 exactly 6 paths / 8 operations; recommendation schemas are redacted")
# 统计 operation 总数并校验基线 190
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 182, f"Expected 182 operations, got {operations}"
assert operations == 190, f"Expected 190 operations, got {operations}"
print(" Offer advance endpoints all present")
print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")

3
mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java

@ -27,6 +27,9 @@ public interface ApplicationRepository extends JpaRepository<Application, Long>
boolean existsByJobIdAndStudentUserId(Long jobId, Long studentUserId);
@org.springframework.data.jpa.repository.Query("select distinct a.jobId from Application a where a.studentUserId = :studentUserId")
java.util.List<Long> findDistinctJobIdsByStudentUserId(@org.springframework.data.repository.query.Param("studentUserId") Long studentUserId);
/**
* 同岗位竞争排名:判断学生是否存在某岗位的「有效投递」(状态不在排除集合内,
* 即非 WITHDRAWN/REJECTED)。与 {@link #findByJobIdAndStatusNotInOrderByCreatedAtAsc}

13
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java

@ -196,7 +196,18 @@ public enum ErrorCode {
RISK_RULE_CONCURRENT_UPDATE(9903, "risk rule was modified concurrently"),
RISK_EVENT_CONCURRENT_UPDATE(9904, "risk event was modified concurrently"),
RISK_INVALID_ACTION(9905, "invalid risk action or reason code"),
RISK_INVALID_FILTER(9906, "invalid risk filter");
RISK_INVALID_FILTER(9906, "invalid risk filter"),
// 岗位推荐 100xx
RECOMMENDATION_PROFILE_NOT_FOUND(10001, "job recommendation profile not found"),
RECOMMENDATION_PROFILE_INVALID(10002, "invalid job recommendation profile"),
RECOMMENDATION_PROFILE_CONCURRENT_UPDATE(10003, "job recommendation profile was modified concurrently"),
RECOMMENDATION_INVALID_QUERY(10004, "invalid recommendation query"),
RECOMMENDATION_TAXONOMY_INVALID(10005, "invalid recommendation taxonomy"),
RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE(10006, "recommendation feedback was modified concurrently"),
RECOMMENDATION_FEEDBACK_IDEMPOTENCY_CONFLICT(10007, "recommendation feedback idempotency conflict"),
RECOMMENDATION_FEEDBACK_NOT_FOUND(10008, "recommendation feedback not found"),
RECOMMENDATION_PROFILE_INVALID_REVIEW_STATE(10009, "invalid recommendation profile review state");
private final int code;
private final String message;

2
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java

@ -68,6 +68,8 @@ public class SecurityConfig {
// 健康检查 API
.requestMatchers("/health/**").permitAll()
.requestMatchers("/health").permitAll()
// 平台维护的岗位推荐分类字典(公开只读)
.requestMatchers(HttpMethod.GET, "/job-recommendation-taxonomies").permitAll()
// 认证相关
.requestMatchers("/auth/sms/send").permitAll()
.requestMatchers("/auth/login").permitAll()

66
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java

@ -0,0 +1,66 @@
package com.mydreamjob.recommendation;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.*;
import com.mydreamjob.recommendation.dto.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.security.core.*;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** V39 学生推荐、企业画像、分类字典与 ADMIN 审核接口。身份仅取 SecurityContext。 */
@RestController
@RequiredArgsConstructor
public class JobRecommendationController {
private final JobRecommendationService service;
@GetMapping("/job-recommendation-taxonomies")
public ApiResponse<List<TaxonomyResponse>> taxonomies(HttpServletRequest request) {
return ApiResponse.ok(service.taxonomies(), requestId(request));
}
@GetMapping("/profile/me/job-recommendations")
public ApiResponse<PageResponse<JobRecommendationItem>> recommendations(@RequestParam(defaultValue="0") int page, @RequestParam(defaultValue="20") int size, HttpServletRequest request) {
Page<JobRecommendationItem> result = service.recommend(userId(), page, size);
return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestId(request));
}
@PostMapping("/profile/me/job-recommendations/{jobId}/feedback")
public ApiResponse<RecommendationFeedbackResponse> feedback(@PathVariable Long jobId,@Valid @RequestBody RecommendationFeedbackRequest body,HttpServletRequest request) {
return ApiResponse.ok(service.feedback(userId(),jobId,body,AuditContext.from(request)),requestId(request));
}
@DeleteMapping("/profile/me/job-recommendations/{jobId}/feedback")
public ApiResponse<Void> revokeFeedback(@PathVariable Long jobId,@RequestParam Long expectedVersion,HttpServletRequest request) {
service.revokeNotInterested(userId(),jobId,expectedVersion,AuditContext.from(request)); return ApiResponse.ok(null,requestId(request));
}
@GetMapping("/companies/me/jobs/{jobId}/recommendation-profile")
public ApiResponse<RecommendationProfileResponse> profile(@PathVariable Long jobId,HttpServletRequest request) {
return ApiResponse.ok(service.companyProfile(userId(),jobId),requestId(request));
}
@PutMapping("/companies/me/jobs/{jobId}/recommendation-profile")
public ApiResponse<RecommendationProfileResponse> updateProfile(@PathVariable Long jobId,@Valid @RequestBody RecommendationProfileRequest body,HttpServletRequest request) {
return ApiResponse.ok(service.upsertCompanyProfile(userId(),jobId,body,AuditContext.from(request)),requestId(request));
}
@GetMapping("/admin/job-recommendation-profiles")
public ApiResponse<PageResponse<RecommendationProfileResponse>> adminProfiles(@RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="20") int size,@RequestParam(required=false) String status,HttpServletRequest request) {
Page<RecommendationProfileResponse> result=service.adminProfiles(userId(),page,size,status); return ApiResponse.ok(PageResponse.of(result.getContent(),result.getTotalElements(),result.getNumber(),result.getSize()),requestId(request));
}
@PostMapping("/admin/job-recommendation-profiles/{jobId}/decision")
public ApiResponse<RecommendationProfileResponse> decide(@PathVariable Long jobId,@Valid @RequestBody RecommendationDecisionRequest body,HttpServletRequest request) {
return ApiResponse.ok(service.decide(userId(),jobId,body,AuditContext.from(request)),requestId(request));
}
private Long userId(){ Authentication a=SecurityContextHolder.getContext().getAuthentication(); if(a==null||!a.isAuthenticated())throw new BusinessException(ErrorCode.UNAUTHORIZED); return Long.valueOf(a.getName()); }
private String requestId(HttpServletRequest r){return r.getHeader("X-Request-Id");}
}

40
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationProfile.java

@ -0,0 +1,40 @@
package com.mydreamjob.recommendation;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "job_recommendation_profiles")
@EntityListeners(AuditingEntityListener.class)
public class JobRecommendationProfile {
public static final String PENDING_REVIEW = "PENDING_REVIEW";
public static final String APPROVED = "APPROVED";
public static final String REJECTED = "REJECTED";
public static final String DISABLED = "DISABLED";
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(name = "job_id", nullable = false, unique = true) private Long jobId;
@Column(name = "industry_code", nullable = false, length = 64) private String industryCode;
@Column(name = "job_family_code", nullable = false, length = 64) private String jobFamilyCode;
@Column(name = "riasec_weights_json", columnDefinition = "TEXT") private String riasecWeightsJson;
@Column(name = "ability_expectations_json", columnDefinition = "TEXT") private String abilityExpectationsJson;
@JdbcTypeCode(SqlTypes.TINYINT)
@Column(nullable = false) @Builder.Default private Boolean enabled = true;
@Column(name = "review_status", nullable = false, length = 20) @Builder.Default private String reviewStatus = PENDING_REVIEW;
@Column(name = "review_reason", length = 40) private String reviewReason;
@Column(name = "reviewed_by_user_id") private Long reviewedByUserId;
@Column(name = "reviewed_at") private LocalDateTime reviewedAt;
@Version @Column(nullable = false) @Builder.Default private Long version = 0L;
@CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt;
@LastModifiedDate @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt;
}

10
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationProfileRepository.java

@ -0,0 +1,10 @@
package com.mydreamjob.recommendation;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.*;
public interface JobRecommendationProfileRepository extends JpaRepository<JobRecommendationProfile, Long> {
Optional<JobRecommendationProfile> findByJobId(Long jobId);
Page<JobRecommendationProfile> findByReviewStatusOrderByUpdatedAtDesc(String reviewStatus, Pageable pageable);
List<JobRecommendationProfile> findByReviewStatusAndEnabledTrue(String reviewStatus);
}

203
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java

@ -0,0 +1,203 @@
package com.mydreamjob.recommendation;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyService;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.job.JobService;
import com.mydreamjob.preference.StudentPreference;
import com.mydreamjob.preference.StudentPreferenceRepository;
import com.mydreamjob.recommendation.dto.*;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.*;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
/** V39 规则型岗位推荐。只读取测评聚合结果,不读取题面、答案或候选评分快照。 */
@Service
@RequiredArgsConstructor
public class JobRecommendationService {
private static final ObjectMapper JSON = new ObjectMapper();
private static final Set<String> RIASEC = Set.of("R","I","A","S","E","C");
private static final Set<String> ABILITY = Set.of("LOGIC","COMMUNICATION","EXECUTION","LEARNING");
private static final Set<String> FEEDBACK = Set.of("NOT_INTERESTED","REASON_HELPFUL","REASON_NOT_HELPFUL");
private static final Set<String> DECISIONS = Set.of("APPROVE", "REJECT");
private static final Set<String> REJECT_REASONS = Set.of("TAXONOMY_MISMATCH", "WEIGHT_INVALID", "CONTENT_INCOMPLETE", "OTHER");
private final JobRecommendationTaxonomyRepository taxonomyRepository;
private final JobRecommendationProfileRepository profileRepository;
private final StudentJobRecommendationFeedbackRepository feedbackRepository;
private final JobRepository jobRepository;
private final JobService jobService;
private final CompanyService companyService;
private final UserRepository userRepository;
private final StudentPreferenceRepository preferenceRepository;
private final ApplicationRepository applicationRepository;
private final AuditService auditService;
private final JdbcTemplate jdbcTemplate;
@Value("${recommendation.assessment.enabled:false}") private boolean assessmentEnabled;
@Transactional(readOnly = true)
public List<TaxonomyResponse> taxonomies() {
return taxonomyRepository.findByEnabledTrueOrderBySortOrderAscCodeAsc().stream().map(t -> TaxonomyResponse.builder()
.code(t.getCode()).parentCode(t.getParentCode()).type(t.getTaxonomyType()).displayName(t.getDisplayName()).build()).toList();
}
@Transactional(readOnly = true)
public RecommendationProfileResponse companyProfile(Long userId, Long jobId) {
assertCompanyOwns(userId, jobId);
return profileRepository.findByJobId(jobId).map(this::profileView)
.orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_NOT_FOUND));
}
@Transactional
public RecommendationProfileResponse upsertCompanyProfile(Long userId, Long jobId, RecommendationProfileRequest req, AuditContext ctx) {
assertCompanyOwns(userId, jobId);
validateTaxonomy(req.getIndustryCode(), req.getJobFamilyCode());
validateMaps(req.getRiasecWeights(), req.getAbilityExpectations());
JobRecommendationProfile p = profileRepository.findByJobId(jobId).orElse(null);
if (p != null && !Objects.equals(p.getVersion(), req.getExpectedVersion())) {
throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_CONCURRENT_UPDATE);
}
if (p == null && req.getExpectedVersion() != 0L) throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_CONCURRENT_UPDATE);
if (p == null) p = JobRecommendationProfile.builder().jobId(jobId).build();
p.setIndustryCode(req.getIndustryCode()); p.setJobFamilyCode(req.getJobFamilyCode());
p.setRiasecWeightsJson(write(req.getRiasecWeights())); p.setAbilityExpectationsJson(write(req.getAbilityExpectations()));
p.setEnabled(req.getEnabled());
if (Boolean.TRUE.equals(req.getEnabled())) { p.setReviewStatus(JobRecommendationProfile.PENDING_REVIEW); p.setReviewReason(null); p.setReviewedAt(null); p.setReviewedByUserId(null); }
else p.setReviewStatus(JobRecommendationProfile.DISABLED);
p = profileRepository.saveAndFlush(p);
auditService.log(userId, "JOB_RECOMMENDATION_PROFILE_UPDATED", "JOB_RECOMMENDATION_PROFILE", p.getId(),
"{\"jobId\":" + jobId + ",\"status\":\"" + p.getReviewStatus() + "\"}", ctx);
return profileView(p);
}
@Transactional(readOnly = true)
public Page<RecommendationProfileResponse> adminProfiles(Long adminId, int page, int size, String status) {
requireAdmin(adminId); validatePage(page, size);
Page<JobRecommendationProfile> result = status == null || status.isBlank()
? profileRepository.findAll(PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "updatedAt")))
: profileRepository.findByReviewStatusOrderByUpdatedAtDesc(status, PageRequest.of(page, size));
return result.map(this::profileView);
}
@Transactional
public RecommendationProfileResponse decide(Long adminId, Long jobId, RecommendationDecisionRequest req, AuditContext ctx) {
requireAdmin(adminId);
if (!DECISIONS.contains(req.getDecision()) || ("REJECT".equals(req.getDecision()) && !REJECT_REASONS.contains(req.getReasonCode())))
throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);
JobRecommendationProfile p = profileRepository.findByJobId(jobId)
.orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_NOT_FOUND));
if (!Objects.equals(p.getVersion(), req.getExpectedVersion())) throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_CONCURRENT_UPDATE);
if (!JobRecommendationProfile.PENDING_REVIEW.equals(p.getReviewStatus())) throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID_REVIEW_STATE);
p.setReviewStatus("APPROVE".equals(req.getDecision()) ? JobRecommendationProfile.APPROVED : JobRecommendationProfile.REJECTED);
p.setReviewReason("REJECT".equals(req.getDecision()) ? req.getReasonCode() : null);
p.setReviewedByUserId(adminId); p.setReviewedAt(LocalDateTime.now()); p = profileRepository.saveAndFlush(p);
auditService.log(adminId, "JOB_RECOMMENDATION_PROFILE_REVIEWED", "JOB_RECOMMENDATION_PROFILE", p.getId(),
"{\"jobId\":" + jobId + ",\"status\":\"" + p.getReviewStatus() + "\"}", ctx);
return profileView(p);
}
@Transactional
public RecommendationFeedbackResponse feedback(Long studentId, Long jobId, RecommendationFeedbackRequest req, AuditContext ctx) {
requireStudent(studentId); jobService.getPublicJob(jobId);
if (!FEEDBACK.contains(req.getFeedbackType()) || req.getIdempotencyKey().length() > 64)
throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);
String key = studentId + ":" + jobId + ":" + req.getFeedbackType() + ":" + Optional.ofNullable(req.getReasonType()).orElse("");
Optional<StudentJobRecommendationFeedback> replay = feedbackRepository.findByStudentUserIdAndIdempotencyKey(studentId, req.getIdempotencyKey());
if (replay.isPresent()) {
StudentJobRecommendationFeedback old = replay.get();
if (!Objects.equals(old.getFeedbackKey(), key)) throw new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_IDEMPOTENCY_CONFLICT);
return feedbackView(old);
}
StudentJobRecommendationFeedback f = feedbackRepository.findByStudentUserIdAndFeedbackKey(studentId, key).orElse(null);
if (f == null) f = StudentJobRecommendationFeedback.builder().studentUserId(studentId).jobId(jobId).feedbackType(req.getFeedbackType()).reasonType(req.getReasonType()).feedbackKey(key).idempotencyKey(req.getIdempotencyKey()).build();
else { f.setStatus(StudentJobRecommendationFeedback.ACTIVE); f.setIdempotencyKey(req.getIdempotencyKey()); }
try { f = feedbackRepository.saveAndFlush(f); } catch (DataIntegrityViolationException e) { throw new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE); }
auditService.log(studentId, "JOB_RECOMMENDATION_FEEDBACK_RECORDED", "JOB", jobId,
"{\"jobId\":" + jobId + ",\"status\":\"ACTIVE\"}", ctx);
return feedbackView(f);
}
@Transactional
public void revokeNotInterested(Long studentId, Long jobId, Long expectedVersion, AuditContext ctx) {
requireStudent(studentId);
StudentJobRecommendationFeedback f = feedbackRepository.findByStudentUserIdAndJobIdAndFeedbackTypeAndStatus(studentId, jobId, "NOT_INTERESTED", StudentJobRecommendationFeedback.ACTIVE)
.orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_NOT_FOUND));
if (!Objects.equals(f.getVersion(), expectedVersion)) throw new BusinessException(ErrorCode.RECOMMENDATION_FEEDBACK_CONCURRENT_UPDATE);
f.setStatus(StudentJobRecommendationFeedback.REVOKED); feedbackRepository.saveAndFlush(f);
auditService.log(studentId, "JOB_RECOMMENDATION_NOT_INTERESTED_REVOKED", "JOB", jobId, "{\"jobId\":" + jobId + ",\"status\":\"REVOKED\"}", ctx);
}
@Transactional(readOnly = true)
public Page<JobRecommendationItem> recommend(Long studentId, int page, int size) {
requireStudent(studentId); validatePage(page, size); LocalDateTime now = LocalDateTime.now();
Set<Long> applied = new HashSet<>(applicationRepository.findDistinctJobIdsByStudentUserId(studentId));
Set<Long> hidden = feedbackRepository.findByStudentUserIdAndStatusAndFeedbackType(studentId, StudentJobRecommendationFeedback.ACTIVE, "NOT_INTERESTED")
.stream().map(StudentJobRecommendationFeedback::getJobId).collect(Collectors.toSet());
StudentPreference pref = preferenceRepository.findByStudentUserId(studentId).orElse(null);
if (!assessmentEnabled) return fallback(page, size, applied, hidden, pref);
Map<String, BigDecimal> riasec = latestRiasec(studentId);
Map<String, BigDecimal> ability = latestAbility(studentId, now);
if (riasec.isEmpty() && ability.isEmpty() && pref == null) return fallback(page, size, applied, hidden, null);
List<Job> jobs = jobRepository.findAllById(profileRepository.findByReviewStatusAndEnabledTrue(JobRecommendationProfile.APPROVED).stream().map(JobRecommendationProfile::getJobId).toList());
Map<Long, Job> jobsById = jobs.stream().collect(Collectors.toMap(Job::getId, j -> j));
List<JobRecommendationItem> items = profileRepository.findByReviewStatusAndEnabledTrue(JobRecommendationProfile.APPROVED).stream()
.map(p -> scored(p, jobsById.get(p.getJobId()), pref, riasec, ability, applied, hidden)).filter(Objects::nonNull)
.sorted(Comparator.comparing(JobRecommendationItem::getScore).reversed().thenComparing(JobRecommendationItem::getJobId)).toList();
return slice(items, page, size);
}
private JobRecommendationItem scored(JobRecommendationProfile p, Job j, StudentPreference pref, Map<String, BigDecimal> riasec, Map<String, BigDecimal> ability, Set<Long> applied, Set<Long> hidden) {
if (j == null || !"PUBLISHED".equals(j.getStatus()) || applied.contains(j.getId()) || hidden.contains(j.getId()) || !hardPreferenceMatch(pref, j)) return null;
List<BigDecimal> components = new ArrayList<>(); List<BigDecimal> weights = new ArrayList<>(); List<String> reasons = new ArrayList<>();
BigDecimal preference = preferenceScore(pref, p, j); if (preference != null) { components.add(preference); weights.add(BigDecimal.valueOf(40)); reasons.add("岗位偏好匹配"); }
BigDecimal r = weighted(riasec, mapDecimal(p.getRiasecWeightsJson()), RIASEC); if (r != null) { components.add(r); weights.add(BigDecimal.valueOf(35)); reasons.add("职业兴趣匹配"); }
BigDecimal a = abilityScore(ability, mapDecimal(p.getAbilityExpectationsJson())); if (a != null) { components.add(a); weights.add(BigDecimal.valueOf(25)); reasons.add("能力要求匹配"); }
if (weights.isEmpty()) return null;
BigDecimal top = BigDecimal.ZERO, bottom = BigDecimal.ZERO;
for (int i=0;i<weights.size();i++) { top=top.add(components.get(i).multiply(weights.get(i))); bottom=bottom.add(weights.get(i)); }
return JobRecommendationItem.builder().jobId(j.getId()).title(j.getTitle()).city(j.getCity()).employmentType(j.getEmploymentType()).salaryMin(j.getSalaryMin()).salaryMax(j.getSalaryMax()).mode("PERSONALIZED").score(top.divide(bottom,2,RoundingMode.HALF_UP)).reasons(reasons).build();
}
private Page<JobRecommendationItem> fallback(int page,int size,Set<Long> applied,Set<Long> hidden,StudentPreference pref) {
List<JobRecommendationItem> all = jobRepository.findByStatusOrderByCreatedAtDesc("PUBLISHED").stream().filter(j -> !applied.contains(j.getId()) && !hidden.contains(j.getId()) && hardPreferenceMatch(pref,j)).map(j -> JobRecommendationItem.builder().jobId(j.getId()).title(j.getTitle()).city(j.getCity()).employmentType(j.getEmploymentType()).salaryMin(j.getSalaryMin()).salaryMax(j.getSalaryMax()).mode("FALLBACK").reasons(List.of("最新公开岗位")).build()).toList();
return slice(all,page,size);
}
private <T> Page<T> slice(List<T> all,int page,int size) { int from=Math.min(page*size,all.size()), to=Math.min(from+size,all.size()); return new PageImpl<>(all.subList(from,to), PageRequest.of(page,size),all.size()); }
private boolean hardPreferenceMatch(StudentPreference p,Job j) { if(p==null)return true; List<String> cities=list(p.getPreferredCities()); if(!cities.isEmpty()&&!cities.contains(j.getCity()))return false; return p.getExpectedSalaryMin()==null || j.getSalaryMax()==null || j.getSalaryMax()>=p.getExpectedSalaryMin(); }
private BigDecimal preferenceScore(StudentPreference p,JobRecommendationProfile profile,Job j) { if(p==null)return null; int got=0,total=0; List<String> cities=list(p.getPreferredCities());if(!cities.isEmpty()){total+=30;if(cities.contains(j.getCity()))got+=30;} List<String> types=list(p.getEmploymentTypes());if(!types.isEmpty()){total+=20;if(types.contains(j.getEmploymentType()))got+=20;} if(p.getExpectedSalaryMin()!=null){total+=20;if(j.getSalaryMax()!=null&&j.getSalaryMax()>=p.getExpectedSalaryMin())got+=20;} List<String> industries=list(p.getPreferredIndustries());if(!industries.isEmpty()){total+=15;if(industries.contains(profile.getIndustryCode()))got+=15;} List<String> families=list(p.getPreferredJobTypes());if(!families.isEmpty()){total+=15;if(families.contains(profile.getJobFamilyCode()))got+=15;} return total==0?null:BigDecimal.valueOf(got*100L).divide(BigDecimal.valueOf(total),2,RoundingMode.HALF_UP); }
private BigDecimal weighted(Map<String,BigDecimal> scores,Map<String,BigDecimal> expected,Set<String> allowed){if(scores.isEmpty()||expected.isEmpty())return null; BigDecimal n=BigDecimal.ZERO,d=BigDecimal.ZERO;for(var e:expected.entrySet())if(allowed.contains(e.getKey())&&scores.containsKey(e.getKey())&&e.getValue().signum()>0){n=n.add(scores.get(e.getKey()).multiply(e.getValue()));d=d.add(e.getValue());}return d.signum()==0?null:n.divide(d,2,RoundingMode.HALF_UP);}
private BigDecimal abilityScore(Map<String,BigDecimal> scores,Map<String,BigDecimal> expected){if(scores.isEmpty()||expected.isEmpty())return null;BigDecimal total=BigDecimal.ZERO;int n=0;for(var e:expected.entrySet())if(ABILITY.contains(e.getKey())&&scores.containsKey(e.getKey())&&e.getValue().compareTo(BigDecimal.ZERO)>0){total=total.add(scores.get(e.getKey()).min(e.getValue()).multiply(BigDecimal.valueOf(100)).divide(e.getValue(),2,RoundingMode.HALF_UP));n++;}return n==0?null:total.divide(BigDecimal.valueOf(n),2,RoundingMode.HALF_UP);}
private Map<String,BigDecimal> latestRiasec(Long student){return latest("SELECT r.dimension_scores_text FROM assessment_results r JOIN assessment_attempts a ON a.id=r.attempt_id JOIN assessment_definitions d ON d.id=a.definition_id WHERE a.student_user_id=? AND a.status='COMPLETED' AND d.type_code='HOLLAND_RIASEC' ORDER BY a.completed_at DESC,a.id DESC LIMIT 1",student,null,RIASEC);}
private Map<String,BigDecimal> latestAbility(Long student,LocalDateTime now){return latest("SELECT r.dimension_scores_json FROM ability_results r JOIN ability_attempts a ON a.id=r.attempt_id JOIN ability_definitions d ON d.id=a.definition_id WHERE a.student_user_id=? AND a.status='COMPLETED' AND d.type_code='WORKPLACE_ABILITY' AND r.valid_until>? ORDER BY a.completed_at DESC,a.id DESC LIMIT 1",student,now,ABILITY);}
private Map<String,BigDecimal> latest(String sql,Long student,LocalDateTime now,Set<String> expected){List<String> rows=now==null?jdbcTemplate.query(sql,(rs,n)->rs.getString(1),student):jdbcTemplate.query(sql,(rs,n)->rs.getString(1),student,now);return rows.isEmpty()?Map.of():scoreEntries(rows.get(0),expected);}
private Map<String,BigDecimal> scoreEntries(String raw,Set<String> expected){try{List<Map<String,Object>> a=JSON.readValue(raw,new TypeReference<>(){});Map<String,BigDecimal>out=new HashMap<>();for(Map<String,Object> e:a){Object key=e.getOrDefault("code",e.get("dimension"));Object score=e.get("score");BigDecimal value=score==null?null:new BigDecimal(String.valueOf(score));if(!(key instanceof String)||value==null||value.compareTo(BigDecimal.ZERO)<0||value.compareTo(BigDecimal.valueOf(100))>0||out.put((String)key,value)!=null)return Map.of();}return out.keySet().equals(expected)?out:Map.of();}catch(Exception e){return Map.of();}}
private Map<String,BigDecimal> mapDecimal(String raw){try{if(raw==null)return Map.of();Map<String,Object>m=JSON.readValue(raw,new TypeReference<>(){});Map<String,BigDecimal>o=new HashMap<>();for(var e:m.entrySet())o.put(e.getKey(),new BigDecimal(String.valueOf(e.getValue())));return o;}catch(Exception e){return Map.of();}}
private List<String> list(String raw){try{return raw==null?List.of():JSON.readValue(raw,new TypeReference<List<String>>(){});}catch(Exception e){return List.of();}}
private String write(Object o){try{return o==null?null:JSON.writeValueAsString(o);}catch(Exception e){throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);}}
private void validateTaxonomy(String industry,String family){JobRecommendationTaxonomy i=taxonomyRepository.findById(industry).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));JobRecommendationTaxonomy f=taxonomyRepository.findById(family).orElseThrow(()->new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID));if(!Boolean.TRUE.equals(i.getEnabled())||!Boolean.TRUE.equals(f.getEnabled())||!"INDUSTRY".equals(i.getTaxonomyType())||!"JOB_FAMILY".equals(f.getTaxonomyType())||!industry.equals(f.getParentCode()))throw new BusinessException(ErrorCode.RECOMMENDATION_TAXONOMY_INVALID);}
private void validateMaps(Map<String,Integer> r,Map<String,Integer>a){if((r==null||r.isEmpty())&&(a==null||a.isEmpty()))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(r!=null&&(!RIASEC.containsAll(r.keySet())||r.values().stream().anyMatch(v->v==null||v<0||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);if(a!=null&&(!ABILITY.containsAll(a.keySet())||a.values().stream().anyMatch(v->v==null||v<1||v>100)))throw new BusinessException(ErrorCode.RECOMMENDATION_PROFILE_INVALID);}
private void assertCompanyOwns(Long user,Long job){Company c=companyService.findCompanyByOwnerUserId(user);if(c==null)throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND);jobService.assertOwnerOrThrow(c.getId(),job);}
private void requireStudent(Long id){User u=userRepository.findById(id).orElseThrow(()->new BusinessException(ErrorCode.USER_NOT_FOUND));if(!u.getRoles().contains(User.Role.STUDENT))throw new BusinessException(ErrorCode.FORBIDDEN);}
private void requireAdmin(Long id){User u=userRepository.findById(id).orElseThrow(()->new BusinessException(ErrorCode.USER_NOT_FOUND));if(!u.getRoles().contains(User.Role.ADMIN))throw new BusinessException(ErrorCode.FORBIDDEN);}
private void validatePage(int p,int s){if(p<0||s<1||s>50)throw new BusinessException(ErrorCode.RECOMMENDATION_INVALID_QUERY);}
private RecommendationProfileResponse profileView(JobRecommendationProfile p){return RecommendationProfileResponse.builder().jobId(p.getJobId()).industryCode(p.getIndustryCode()).jobFamilyCode(p.getJobFamilyCode()).riasecWeights(mapInteger(p.getRiasecWeightsJson())).abilityExpectations(mapInteger(p.getAbilityExpectationsJson())).enabled(p.getEnabled()).reviewStatus(p.getReviewStatus()).reviewReason(p.getReviewReason()).version(p.getVersion()).updatedAt(p.getUpdatedAt()).build();}
private Map<String,Integer> mapInteger(String raw){try{return raw==null?Map.of():JSON.readValue(raw,new TypeReference<Map<String,Integer>>(){});}catch(Exception e){return Map.of();}}
private RecommendationFeedbackResponse feedbackView(StudentJobRecommendationFeedback f){return RecommendationFeedbackResponse.builder().jobId(f.getJobId()).feedbackType(f.getFeedbackType()).reasonType(f.getReasonType()).status(f.getStatus()).version(f.getVersion()).build();}
}

29
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationTaxonomy.java

@ -0,0 +1,29 @@
package com.mydreamjob.recommendation;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Data;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@Data
@Entity
@Table(name = "job_recommendation_taxonomies")
public class JobRecommendationTaxonomy {
@Id
@Column(length = 64)
private String code;
@Column(name = "parent_code", length = 64)
private String parentCode;
@Column(name = "taxonomy_type", nullable = false, length = 20)
private String taxonomyType;
@Column(name = "display_name", nullable = false, length = 80)
private String displayName;
@JdbcTypeCode(SqlTypes.TINYINT)
@Column(nullable = false)
private Boolean enabled;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
}

6
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationTaxonomyRepository.java

@ -0,0 +1,6 @@
package com.mydreamjob.recommendation;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface JobRecommendationTaxonomyRepository extends JpaRepository<JobRecommendationTaxonomy, String> {
List<JobRecommendationTaxonomy> findByEnabledTrueOrderBySortOrderAscCodeAsc();
}

31
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/StudentJobRecommendationFeedback.java

@ -0,0 +1,31 @@
package com.mydreamjob.recommendation;
import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "student_job_recommendation_feedback")
@EntityListeners(AuditingEntityListener.class)
public class StudentJobRecommendationFeedback {
public static final String ACTIVE = "ACTIVE";
public static final String REVOKED = "REVOKED";
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(name = "student_user_id", nullable = false) private Long studentUserId;
@Column(name = "job_id", nullable = false) private Long jobId;
@Column(name = "feedback_type", nullable = false, length = 32) private String feedbackType;
@Column(name = "reason_type", length = 40) private String reasonType;
@Column(name = "feedback_key", nullable = false, unique = true, length = 220) private String feedbackKey;
@Column(name = "idempotency_key", nullable = false, length = 64) private String idempotencyKey;
@Column(nullable = false, length = 20) @Builder.Default private String status = ACTIVE;
@Version @Column(nullable = false) @Builder.Default private Long version = 0L;
@CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt;
@LastModifiedDate @Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt;
}

9
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/StudentJobRecommendationFeedbackRepository.java

@ -0,0 +1,9 @@
package com.mydreamjob.recommendation;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.*;
public interface StudentJobRecommendationFeedbackRepository extends JpaRepository<StudentJobRecommendationFeedback, Long> {
Optional<StudentJobRecommendationFeedback> findByStudentUserIdAndIdempotencyKey(Long userId, String idempotencyKey);
Optional<StudentJobRecommendationFeedback> findByStudentUserIdAndFeedbackKey(Long userId, String feedbackKey);
Optional<StudentJobRecommendationFeedback> findByStudentUserIdAndJobIdAndFeedbackTypeAndStatus(Long userId, Long jobId, String feedbackType, String status);
List<StudentJobRecommendationFeedback> findByStudentUserIdAndStatusAndFeedbackType(Long userId, String status, String feedbackType);
}

6
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/JobRecommendationItem.java

@ -0,0 +1,6 @@
package com.mydreamjob.recommendation.dto;
import lombok.Builder; import lombok.Value; import java.math.BigDecimal; import java.util.List;
@Value @Builder public class JobRecommendationItem {
Long jobId; String title; String city; String employmentType; Integer salaryMin; Integer salaryMax;
String mode; BigDecimal score; List<String> reasons;
}

3
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationDecisionRequest.java

@ -0,0 +1,3 @@
package com.mydreamjob.recommendation.dto;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data;
@Data public class RecommendationDecisionRequest { @NotBlank private String decision; private String reasonCode; @NotNull private Long expectedVersion; }

3
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationFeedbackRequest.java

@ -0,0 +1,3 @@
package com.mydreamjob.recommendation.dto;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data;
@Data public class RecommendationFeedbackRequest { @NotBlank private String feedbackType; private String reasonType; @NotBlank private String idempotencyKey; @NotNull private Long expectedVersion; }

3
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationFeedbackResponse.java

@ -0,0 +1,3 @@
package com.mydreamjob.recommendation.dto;
import lombok.Builder; import lombok.Value;
@Value @Builder public class RecommendationFeedbackResponse { Long jobId; String feedbackType; String reasonType; String status; Long version; }

14
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationProfileRequest.java

@ -0,0 +1,14 @@
package com.mydreamjob.recommendation.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.Map;
@Data
public class RecommendationProfileRequest {
@NotBlank private String industryCode;
@NotBlank private String jobFamilyCode;
private Map<String, Integer> riasecWeights;
private Map<String, Integer> abilityExpectations;
@NotNull private Boolean enabled;
@NotNull private Long expectedVersion;
}

7
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationProfileResponse.java

@ -0,0 +1,7 @@
package com.mydreamjob.recommendation.dto;
import lombok.Builder; import lombok.Value; import java.time.LocalDateTime; import java.util.Map;
@Value @Builder public class RecommendationProfileResponse {
Long jobId; String industryCode; String jobFamilyCode; Map<String,Integer> riasecWeights;
Map<String,Integer> abilityExpectations; Boolean enabled; String reviewStatus; String reviewReason;
Long version; LocalDateTime updatedAt;
}

3
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/TaxonomyResponse.java

@ -0,0 +1,3 @@
package com.mydreamjob.recommendation.dto;
import lombok.Builder; import lombok.Value;
@Value @Builder public class TaxonomyResponse { String code; String parentCode; String type; String displayName; }

4
mydreamjob-server/src/main/resources/application.yml

@ -137,6 +137,10 @@ scoring:
ability-assessment:
enabled: ${SCORING_ABILITY_ASSESSMENT_ENABLED:false}
recommendation:
assessment:
enabled: ${RECOMMENDATION_ASSESSMENT_ENABLED:false}
management:
endpoints:
web:

77
mydreamjob-server/src/main/resources/db/migration/V37__job_recommendation_profiles.sql

@ -0,0 +1,77 @@
-- V39:测评驱动岗位推荐。分类字典由平台维护,企业只可选择已启用条目。
CREATE TABLE job_recommendation_taxonomies (
code VARCHAR(64) PRIMARY KEY,
parent_code VARCHAR(64) NULL,
taxonomy_type VARCHAR(20) NOT NULL,
display_name VARCHAR(80) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_job_rec_taxonomy_type CHECK (taxonomy_type IN ('INDUSTRY','JOB_FAMILY')),
CONSTRAINT fk_job_rec_taxonomy_parent FOREIGN KEY (parent_code)
REFERENCES job_recommendation_taxonomies(code)
);
CREATE TABLE job_recommendation_profiles (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
job_id BIGINT NOT NULL,
industry_code VARCHAR(64) NOT NULL,
job_family_code VARCHAR(64) NOT NULL,
riasec_weights_json TEXT NULL,
ability_expectations_json TEXT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
review_status VARCHAR(20) NOT NULL DEFAULT 'PENDING_REVIEW',
review_reason VARCHAR(40) NULL,
reviewed_by_user_id BIGINT NULL,
reviewed_at DATETIME NULL,
version BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_job_rec_profile_job UNIQUE (job_id),
CONSTRAINT chk_job_rec_profile_status CHECK (review_status IN ('PENDING_REVIEW','APPROVED','REJECTED','DISABLED')),
CONSTRAINT fk_job_rec_profile_job FOREIGN KEY (job_id) REFERENCES jobs(id),
CONSTRAINT fk_job_rec_profile_industry FOREIGN KEY (industry_code) REFERENCES job_recommendation_taxonomies(code),
CONSTRAINT fk_job_rec_profile_family FOREIGN KEY (job_family_code) REFERENCES job_recommendation_taxonomies(code),
INDEX idx_job_rec_profile_eligible (review_status, enabled, updated_at),
INDEX idx_job_rec_profile_industry_family (industry_code, job_family_code)
);
CREATE TABLE student_job_recommendation_feedback (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
student_user_id BIGINT NOT NULL,
job_id BIGINT NOT NULL,
feedback_type VARCHAR(32) NOT NULL,
reason_type VARCHAR(40) NULL,
feedback_key VARCHAR(220) NOT NULL,
idempotency_key VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
version BIGINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT uk_student_job_feedback_key UNIQUE (feedback_key),
CONSTRAINT uk_student_job_feedback_idem UNIQUE (student_user_id, idempotency_key),
CONSTRAINT chk_student_job_feedback_type CHECK (feedback_type IN ('NOT_INTERESTED','REASON_HELPFUL','REASON_NOT_HELPFUL')),
CONSTRAINT chk_student_job_feedback_status CHECK (status IN ('ACTIVE','REVOKED')),
CONSTRAINT fk_student_job_feedback_user FOREIGN KEY (student_user_id) REFERENCES users(id),
CONSTRAINT fk_student_job_feedback_job FOREIGN KEY (job_id) REFERENCES jobs(id),
INDEX idx_student_job_feedback_active (student_user_id, status, feedback_type, job_id)
);
INSERT INTO job_recommendation_taxonomies(code, parent_code, taxonomy_type, display_name, sort_order) VALUES
('INTERNET', NULL, 'INDUSTRY', '互联网/信息技术', 10),
('FINANCE', NULL, 'INDUSTRY', '金融', 20),
('MANUFACTURING', NULL, 'INDUSTRY', '制造业', 30),
('HEALTHCARE', NULL, 'INDUSTRY', '医疗健康', 40),
('EDUCATION', NULL, 'INDUSTRY', '教育', 50),
('RETAIL', NULL, 'INDUSTRY', '零售/消费', 60),
('IT_SOFTWARE', 'INTERNET', 'JOB_FAMILY', '软件研发', 101),
('IT_PRODUCT', 'INTERNET', 'JOB_FAMILY', '产品/运营', 102),
('IT_DESIGN', 'INTERNET', 'JOB_FAMILY', '设计/创意', 103),
('FIN_ANALYSIS', 'FINANCE', 'JOB_FAMILY', '金融分析/风控', 201),
('MFG_ENGINEERING', 'MANUFACTURING', 'JOB_FAMILY', '工程/制造', 301),
('MFG_QUALITY', 'MANUFACTURING', 'JOB_FAMILY', '质量/供应链', 302),
('HEALTH_SERVICE', 'HEALTHCARE', 'JOB_FAMILY', '医疗服务', 401),
('EDU_TEACHING', 'EDUCATION', 'JOB_FAMILY', '教学/培训', 501),
('RETAIL_SALES', 'RETAIL', 'JOB_FAMILY', '销售/客户服务', 601);

53
mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobRecommendationServiceTest.java

@ -0,0 +1,53 @@
package com.mydreamjob.recommendation;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.preference.StudentPreferenceRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class JobRecommendationServiceTest {
@Mock JobRecommendationTaxonomyRepository taxonomyRepository;
@Mock JobRecommendationProfileRepository profileRepository;
@Mock StudentJobRecommendationFeedbackRepository feedbackRepository;
@Mock JobRepository jobRepository;
@Mock com.mydreamjob.job.JobService jobService;
@Mock com.mydreamjob.company.CompanyService companyService;
@Mock UserRepository userRepository;
@Mock StudentPreferenceRepository preferenceRepository;
@Mock ApplicationRepository applicationRepository;
@Mock com.mydreamjob.audit.AuditService auditService;
@Mock JdbcTemplate jdbcTemplate;
@InjectMocks JobRecommendationService service;
@Test
void featureFlagOff_returnsFallbackWithoutReadingProfilesOrAssessments() {
ReflectionTestUtils.setField(service, "assessmentEnabled", false);
User student = User.builder().roles(Set.of(User.Role.STUDENT)).build();
when(userRepository.findById(7L)).thenReturn(Optional.of(student));
when(applicationRepository.findDistinctJobIdsByStudentUserId(7L)).thenReturn(List.of());
when(feedbackRepository.findByStudentUserIdAndStatusAndFeedbackType(7L, "ACTIVE", "NOT_INTERESTED")).thenReturn(List.of());
when(preferenceRepository.findByStudentUserId(7L)).thenReturn(Optional.empty());
when(jobRepository.findByStatusOrderByCreatedAtDesc("PUBLISHED")).thenReturn(List.of(Job.builder().id(9L).title("后端工程师").city("上海").employmentType("FULL_TIME").status("PUBLISHED").build()));
var page = service.recommend(7L, 0, 20);
assertThat(page.getContent()).singleElement().satisfies(item -> {
assertThat(item.getMode()).isEqualTo("FALLBACK");
assertThat(item.getScore()).isNull();
});
verifyNoInteractions(profileRepository, jdbcTemplate);
}
}
Loading…
Cancel
Save