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** | | OpenAPI | **154 paths、190 operations**(V39 已完成,待 Git 检查点) |
| Flyway | **V1–V36 已应用**;**V34 为 RIASEC 测评迁移(功能版本 V35)**;**V35 为四维能力测评迁移(功能版本 V36)**;**V36 为防刷风险概览与规则配置迁移(功能版本 V37)** | | Flyway | **V1–V37 已应用**;V37 为岗位推荐分类、画像与学生反馈迁移(功能版本 V39,真实 MySQL/Docker 关键黑盒已通过) |
| 全量测试 | **1427/1427 通过**(`mvn clean verify`,0 Failures / 0 Errors / 0 Skipped) | | 全量测试 | **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、看板等核心闭环 | | 已完成范围 | 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 模块状态分类 ### 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 复验 | — | | 已完成、待真实 MySQL/Docker 复验 | — |
| 已设计未编码 | 公平性检测、SHAP/规则解释、企业评价管理、企业人才推荐 | | 已设计未编码 | 公平性检测、SHAP/规则解释、企业评价管理、企业人才推荐 |
| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 | | 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、入职准备清单、薪资计算器、诚信积分、消息撤回 |
@ -132,7 +132,7 @@
- ~~霍兰德职业兴趣测评~~(已由 V35 实现:测评 + 结果 + 探索方向,Flyway V34); - ~~霍兰德职业兴趣测评~~(已由 V35 实现:测评 + 结果 + 探索方向,Flyway V34);
- ~~四维能力测评~~(已由 V36 实现:限时答题、进度保存、结果、短板和历史,Flyway V35); - ~~四维能力测评~~(已由 V36 实现:限时答题、进度保存、结果、短板和历史,Flyway V35);
- 测评岗位推荐(V35/V36 已提供结果,但不根据结果推荐岗位,仍为缺失); - ~~测评岗位推荐~~(V39 已完成:结构化岗位画像、受控分类、测评驱动推荐、反馈与审核);
- AI 简历诊断和优化; - AI 简历诊断和优化;
- AI 模拟面试和面试题预测; - AI 模拟面试和面试题预测;
- 行业技能词云; - 行业技能词云;
@ -342,7 +342,7 @@ jobs
2. ~~四维能力测评、答题和进度保存~~(V36 已完成); 2. ~~四维能力测评、答题和进度保存~~(V36 已完成);
3. ~~测评结果~~(V35/V36 已完成); 3. ~~测评结果~~(V35/V36 已完成);
4. 测评结果进入 ABILITY/GROWTH(V38 已完成 ABILITY 有限接入;GROWTH 仍需独立评审与后续实现); 4. 测评结果进入 ABILITY/GROWTH(V38 已完成 ABILITY 有限接入;GROWTH 仍需独立评审与后续实现);
5. 根据测评结果推荐岗位。 5. ~~根据测评结果推荐岗位~~(V39 已完成)。
建议接口: 建议接口:
@ -457,7 +457,7 @@ scoring_risk_scan_checkpoints
| P0 | 个人数据导出 | | P0 | 个人数据导出 |
| P0 | 结构化面试评分 | | P0 | 结构化面试评分 |
| P0/P1 | 同岗位竞争力排名 | | P0/P1 | 同岗位竞争力排名 |
| P1 | 测评结果进入评分引擎、测评岗位推荐 | | P1 | 测评结果进入评分引擎、测评岗位推荐(V38/V39 已完成) |
| P1 | 统一求职进度(V34 已完成) | | P1 | 统一求职进度(V34 已完成) |
| P1 | 企业评价管理 | | P1 | 企业评价管理 |
| 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`)。 - 规则/阈值的命中可能不会立刻出现:扫描默认每 60 秒跑一次(`app.risk.scan.fixed-delay-ms`)。
- 概览/趋势/分布由后端 SQL 聚合返回,前端无需额外拉取明细。 - 概览/趋势/分布由后端 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": { "components": {
"schemas": { "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": { "AccountDeletionResponse": {
"type": "object", "type": "object",
"properties": { "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')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) 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'] paths = d['paths']
required = [ required = [
@ -270,10 +270,46 @@ for f in risk_forbidden:
assert False, f"schema {name} exposes forbidden field {f}" assert False, f"schema {name} exposes forbidden field {f}"
print(" Risk schemas expose no subjectUserId/operatorUserId/phone/email/ip/userAgent/details/credentials") 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()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) 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(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") 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); 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} * 即非 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_RULE_CONCURRENT_UPDATE(9903, "risk rule was modified concurrently"),
RISK_EVENT_CONCURRENT_UPDATE(9904, "risk event 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_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 int code;
private final String message; private final String message;

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

@ -68,6 +68,8 @@ public class SecurityConfig {
// 健康检查 API // 健康检查 API
.requestMatchers("/health/**").permitAll() .requestMatchers("/health/**").permitAll()
.requestMatchers("/health").permitAll() .requestMatchers("/health").permitAll()
// 平台维护的岗位推荐分类字典(公开只读)
.requestMatchers(HttpMethod.GET, "/job-recommendation-taxonomies").permitAll()
// 认证相关 // 认证相关
.requestMatchers("/auth/sms/send").permitAll() .requestMatchers("/auth/sms/send").permitAll()
.requestMatchers("/auth/login").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: ability-assessment:
enabled: ${SCORING_ABILITY_ASSESSMENT_ENABLED:false} enabled: ${SCORING_ABILITY_ASSESSMENT_ENABLED:false}
recommendation:
assessment:
enabled: ${RECOMMENDATION_ASSESSMENT_ENABLED:false}
management: management:
endpoints: endpoints:
web: 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