diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 03bac59..0fab9ac 100644 --- a/docs/backend-development-roadmap.md +++ b/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 | 企业人才推荐 | diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index dc1f5b9..a3db91e 100644 --- a/docs/frontend-integration.md +++ b/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 或题库字段。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index bdaa9f6..5cd2467 100644 --- a/docs/openapi-rc.json +++ b/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": { @@ -12537,4 +12555,4 @@ } } } -} \ No newline at end of file +} diff --git a/docs/v39-job-recommendation-design.md b/docs/v39-job-recommendation-design.md new file mode 100644 index 0000000..cd5697c --- /dev/null +++ b/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 或前端接口实现。 diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index dd11e11..0f522f2 100644 --- a/docs/verify_openapi.py +++ b/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)") @@ -283,4 +319,4 @@ assert '"" : "#/components/schemas/' not in data, "Found empty ref key" print("No empty ref keys found") print("Valid JSON: True") -print("All checks passed!") \ No newline at end of file +print("All checks passed!") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java index 20c670c..094f30b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java @@ -27,6 +27,9 @@ public interface ApplicationRepository extends JpaRepository 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 findDistinctJobIdsByStudentUserId(@org.springframework.data.repository.query.Param("studentUserId") Long studentUserId); + /** * 同岗位竞争排名:判断学生是否存在某岗位的「有效投递」(状态不在排除集合内, * 即非 WITHDRAWN/REJECTED)。与 {@link #findByJobIdAndStatusNotInOrderByCreatedAtAsc} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index b2dcf1b..34d573c 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/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; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index 0f50257..1c2c5e5 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/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() diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationController.java new file mode 100644 index 0000000..d182d1c --- /dev/null +++ b/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> taxonomies(HttpServletRequest request) { + return ApiResponse.ok(service.taxonomies(), requestId(request)); + } + + @GetMapping("/profile/me/job-recommendations") + public ApiResponse> recommendations(@RequestParam(defaultValue="0") int page, @RequestParam(defaultValue="20") int size, HttpServletRequest request) { + Page 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 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 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 profile(@PathVariable Long jobId,HttpServletRequest request) { + return ApiResponse.ok(service.companyProfile(userId(),jobId),requestId(request)); + } + + @PutMapping("/companies/me/jobs/{jobId}/recommendation-profile") + public ApiResponse 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> adminProfiles(@RequestParam(defaultValue="0") int page,@RequestParam(defaultValue="20") int size,@RequestParam(required=false) String status,HttpServletRequest request) { + Page 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 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");} +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationProfile.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationProfile.java new file mode 100644 index 0000000..5d8a05f --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationProfileRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationProfileRepository.java new file mode 100644 index 0000000..5f802e0 --- /dev/null +++ b/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 { + Optional findByJobId(Long jobId); + Page findByReviewStatusOrderByUpdatedAtDesc(String reviewStatus, Pageable pageable); + List findByReviewStatusAndEnabledTrue(String reviewStatus); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java new file mode 100644 index 0000000..e997919 --- /dev/null +++ b/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 RIASEC = Set.of("R","I","A","S","E","C"); + private static final Set ABILITY = Set.of("LOGIC","COMMUNICATION","EXECUTION","LEARNING"); + private static final Set FEEDBACK = Set.of("NOT_INTERESTED","REASON_HELPFUL","REASON_NOT_HELPFUL"); + private static final Set DECISIONS = Set.of("APPROVE", "REJECT"); + private static final Set 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 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 adminProfiles(Long adminId, int page, int size, String status) { + requireAdmin(adminId); validatePage(page, size); + Page 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 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 recommend(Long studentId, int page, int size) { + requireStudent(studentId); validatePage(page, size); LocalDateTime now = LocalDateTime.now(); + Set applied = new HashSet<>(applicationRepository.findDistinctJobIdsByStudentUserId(studentId)); + Set 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 riasec = latestRiasec(studentId); + Map ability = latestAbility(studentId, now); + if (riasec.isEmpty() && ability.isEmpty() && pref == null) return fallback(page, size, applied, hidden, null); + List jobs = jobRepository.findAllById(profileRepository.findByReviewStatusAndEnabledTrue(JobRecommendationProfile.APPROVED).stream().map(JobRecommendationProfile::getJobId).toList()); + Map jobsById = jobs.stream().collect(Collectors.toMap(Job::getId, j -> j)); + List 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 riasec, Map ability, Set applied, Set hidden) { + if (j == null || !"PUBLISHED".equals(j.getStatus()) || applied.contains(j.getId()) || hidden.contains(j.getId()) || !hardPreferenceMatch(pref, j)) return null; + List components = new ArrayList<>(); List weights = new ArrayList<>(); List 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 fallback(int page,int size,Set applied,Set hidden,StudentPreference pref) { + List 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 Page slice(List 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 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 cities=list(p.getPreferredCities());if(!cities.isEmpty()){total+=30;if(cities.contains(j.getCity()))got+=30;} List 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 industries=list(p.getPreferredIndustries());if(!industries.isEmpty()){total+=15;if(industries.contains(profile.getIndustryCode()))got+=15;} List 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 scores,Map expected,Set 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 scores,Map 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 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 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 latest(String sql,Long student,LocalDateTime now,Set expected){List 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 scoreEntries(String raw,Set expected){try{List> a=JSON.readValue(raw,new TypeReference<>(){});Mapout=new HashMap<>();for(Map 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 mapDecimal(String raw){try{if(raw==null)return Map.of();Mapm=JSON.readValue(raw,new TypeReference<>(){});Mapo=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 list(String raw){try{return raw==null?List.of():JSON.readValue(raw,new TypeReference>(){});}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 r,Mapa){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 mapInteger(String raw){try{return raw==null?Map.of():JSON.readValue(raw,new TypeReference>(){});}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();} +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationTaxonomy.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationTaxonomy.java new file mode 100644 index 0000000..fb2af0d --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationTaxonomyRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationTaxonomyRepository.java new file mode 100644 index 0000000..a70ea00 --- /dev/null +++ b/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 { + List findByEnabledTrueOrderBySortOrderAscCodeAsc(); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/StudentJobRecommendationFeedback.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/StudentJobRecommendationFeedback.java new file mode 100644 index 0000000..d3d5c24 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/StudentJobRecommendationFeedbackRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/StudentJobRecommendationFeedbackRepository.java new file mode 100644 index 0000000..0c147eb --- /dev/null +++ b/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 { + Optional findByStudentUserIdAndIdempotencyKey(Long userId, String idempotencyKey); + Optional findByStudentUserIdAndFeedbackKey(Long userId, String feedbackKey); + Optional findByStudentUserIdAndJobIdAndFeedbackTypeAndStatus(Long userId, Long jobId, String feedbackType, String status); + List findByStudentUserIdAndStatusAndFeedbackType(Long userId, String status, String feedbackType); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/JobRecommendationItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/JobRecommendationItem.java new file mode 100644 index 0000000..1b0131b --- /dev/null +++ b/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 reasons; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationDecisionRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationDecisionRequest.java new file mode 100644 index 0000000..93dc82e --- /dev/null +++ b/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; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationFeedbackRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationFeedbackRequest.java new file mode 100644 index 0000000..3ff3678 --- /dev/null +++ b/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; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationFeedbackResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationFeedbackResponse.java new file mode 100644 index 0000000..d503f37 --- /dev/null +++ b/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; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationProfileRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationProfileRequest.java new file mode 100644 index 0000000..bdfb640 --- /dev/null +++ b/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 riasecWeights; + private Map abilityExpectations; + @NotNull private Boolean enabled; + @NotNull private Long expectedVersion; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationProfileResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/RecommendationProfileResponse.java new file mode 100644 index 0000000..30cc9ba --- /dev/null +++ b/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 riasecWeights; + Map abilityExpectations; Boolean enabled; String reviewStatus; String reviewReason; + Long version; LocalDateTime updatedAt; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/TaxonomyResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/dto/TaxonomyResponse.java new file mode 100644 index 0000000..e05dc26 --- /dev/null +++ b/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; } diff --git a/mydreamjob-server/src/main/resources/application.yml b/mydreamjob-server/src/main/resources/application.yml index fa944d2..13e8ac7 100644 --- a/mydreamjob-server/src/main/resources/application.yml +++ b/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: diff --git a/mydreamjob-server/src/main/resources/db/migration/V37__job_recommendation_profiles.sql b/mydreamjob-server/src/main/resources/db/migration/V37__job_recommendation_profiles.sql new file mode 100644 index 0000000..9dd4df7 --- /dev/null +++ b/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); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobRecommendationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobRecommendationServiceTest.java new file mode 100644 index 0000000..a2f4916 --- /dev/null +++ b/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); + } +}