forked from baskbuddy/zhangshun
26 changed files with 1114 additions and 15 deletions
@ -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 或前端接口实现。 |
||||
@ -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");} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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();} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; } |
||||
@ -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; } |
||||
@ -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; } |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; } |
||||
@ -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); |
||||
@ -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…
Reference in new issue