Compare commits
12 Commits
master
...
release/id
| Author | SHA1 | Date |
|---|---|---|
|
|
439c25be9c | 2 weeks ago |
|
|
fa6555d9e5 | 2 weeks ago |
|
|
c3ca89a186 | 3 weeks ago |
|
|
f966dfa291 | 3 weeks ago |
|
|
43739f42ed | 3 weeks ago |
|
|
0609b9422a | 3 weeks ago |
|
|
5f82c125ed | 3 weeks ago |
|
|
b1d9dd2d31 | 4 weeks ago |
|
|
80969c65c3 | 4 weeks ago |
|
|
32fb6470a4 | 4 weeks ago |
|
|
9c14abd498 | 4 weeks ago |
|
|
bcea6b0295 | 4 weeks ago |
267 changed files with 31033 additions and 4242 deletions
@ -0,0 +1,77 @@ |
|||
# N03 行业技能词云来源与覆盖清单 |
|||
|
|||
## 1. 固定来源 |
|||
|
|||
| 来源 | 固定版本 | 用途 | 许可/复用条件 | 官方地址与归属 | |
|||
| --- | --- | --- | --- | --- | |
|||
| ESCO | ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0) | 提供行业/职位映射所需的职业—技能—技能分类主关系、稳定来源 ID 和层级结构 | 依据 Commission Decision 2011/833/EU,可免费下载、使用、复制和复用。文档和产品界面须保留官方署名语句:`This service uses the ESCO classification of the European Commission.` | 数据集下载页:https://esco.ec.europa.eu/en/use-esco/download<br>复用条件说明:https://esco.ec.europa.eu/en/about-esco/faq | |
|||
| O*NET | O*NET 31.0 Database | 为 ESCO 未收录的具体技术提供 `software_skills` 补充来源,并通过职业/工作活动 Importance 提供首版词云热度 `heat` | License 为 CC BY 4.0,归属与许可说明见 https://creativecommons.org/licenses/by/4.0/ | 官方数据库页:https://www.onetcenter.org/database.html | |
|||
|
|||
运行时不得访问 ESCO、O*NET 或任何在线词库服务。所有首版数据必须随 Flyway 迁移进入本地数据库,并保留来源版本与归属信息。 |
|||
|
|||
## 2. 首版覆盖边界 |
|||
|
|||
首版只覆盖互联网、金融、教育三类行业及下列 10 个已列代表性职位。该矩阵定义导入上限和最小覆盖范围,不要求在全量上游数据中导入未列出的职位。 |
|||
|
|||
| 行业 | 代表性职位 | 首批技能分类范围 | |
|||
| --- | --- | --- | |
|||
| 互联网 | 前端开发工程师 | 编程语言、Web 基础、前端框架、工程化工具、浏览器与调试、测试、版本控制与协作 | |
|||
| 互联网 | 后端开发工程师 | 编程语言、后端框架与平台、数据库、缓存与消息、云/DevOps、测试、工程化工具、安全 | |
|||
| 互联网 | 测试工程师 | 编程语言、测试设计、自动化测试框架、接口与数据库测试、性能测试、CI/CD 与工程化工具 | |
|||
| 互联网 | DevOps 工程师 | 编程语言与脚本、云平台、容器与编排、CI/CD、配置管理与 IaC、可观测性、安全、数据库 | |
|||
| 金融 | 金融数据分析师 | 编程语言、数据分析与可视化、数据库与数据仓库、统计与建模、金融计算框架/平台、金融领域知识、工程化工具 | |
|||
| 金融 | 风控建模工程师 | 编程语言、统计与建模、风险模型、建模框架/平台、数据处理与数据库、模型验证、部署与工程化工具 | |
|||
| 金融 | 量化开发工程师 | 编程语言、量化建模框架/平台、市场与金融数据、数据库、低延迟与高性能工程、测试、云/DevOps | |
|||
| 教育 | 教育产品经理 | 教育领域知识、产品分析方法、需求与流程、数据分析、教育平台与工具、协作与工程化工具 | |
|||
| 教育 | 教学设计师 | 教学设计、课程与学习平台、数字化内容工具、评估设计、学习数据分析、无障碍与规范 | |
|||
| 教育 | 教育数据分析师 | 编程语言、数据分析与可视化、数据库与 SQL、统计与建模、教育领域知识、报表与工程化工具 | |
|||
计数说明:任务给出的逐项职位清单为互联网 4 个、金融 3 个、教育 3 个,共 10 个。验收文字中的“11 个职位”与该枚举不一致;本清单严格覆盖逐项列出的 10 个职位,不自行新增未批准职位。 |
|||
|
|||
矩阵中的分类是每个职位首批应覆盖的能力域;T3 只导入这些分类下已确定稳定来源 ID、标准技能和中文标准名的技能,不在此文档中展开具体 `INSERT` 行。 |
|||
|
|||
## 3. 导入规则 |
|||
|
|||
1. **职责分工**:T2 只建立七张词库表、外键、唯一约束和查询索引;T3 只导入上述三类行业、10 个已列代表性职位及其关联技能分类、技能、别名和 O*NET Importance。 |
|||
2. **代表性子集与混合来源**:不导入 ESCO/O*NET 全量数据;ESCO 是职位、通用技能和分类主表;ESCO 无可靠节点的具体技术使用 O*NET `software_skills` 补充;不把平台自由文本岗位或行业直接当作标准词库节点。 |
|||
3. **来源 ID 不变**:ESCO/O*NET 来源 ID 原样保存在稳定来源字段中;项目不得重编号、覆盖或伪造上游 ID。 |
|||
4. **中文标准名必填**:每个入选技能必须有非空 `name_zh` 中文标准名;中文名称由项目维护,但不替代或修改上游来源 ID。 |
|||
5. **热度来源**:每条非空 Importance 必须保存 O*NET SOC、Element ID 和代理标记;技能无法映射 O*NET Importance 时必须保留,写入 `importance=null`;查询时映射为 `heat=null`,并排在有热度技能之后。 |
|||
6. **项目映射责任**:项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。行业/职位映射、细粒度分类和跨源技能对应关系均作为项目适配层记录。 |
|||
7. **别名归一化**:生成 `normalized_alias` 时依次执行 Unicode NFKC、首尾空白移除、大小写折叠、连字符转空格、连续空白折叠为单空格、句点移除;`+` 和 `#` 必须保留,不能删除或替换。 |
|||
8. **来源可追溯**:来源清单写入 `skill_source_versions`,至少记录来源名称、固定版本、许可、归属 URL 和导入时间;O*NET 补充技术的 `source_code` 使用真实 `SOC|Workplace Example|Element ID` 组合,职位技能关系记录 O*NET SOC、Element ID、代理标记及关联的来源版本。 |
|||
9. **迁移不可改写**:T2/T3 计划使用 `V35__skill_cloud_taxonomy.sql` 和 `V36__skill_cloud_seed.sql`。执行前若编号已被占用,必须回退规划并刷新编号;后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或任何已执行迁移。 |
|||
10. **运行时离线**:接口运行时不得调用外部词库、AI、课程平台或同步服务;排名只读取本地 Flyway 数据。 |
|||
|
|||
## 4. 来源版本、许可与归属 URL |
|||
|
|||
| 数据落点 | 来源名称 | 版本字段固定值 | 许可字段固定值 | 归属 URL | |
|||
| --- | --- | --- | --- | --- | |
|||
| 职业、技能、技能分类及关系 | ESCO | `1.2.1 (Release v5.7.0, Last update 10/12/2025)` | `Commission Decision 2011/833/EU` | https://esco.ec.europa.eu/en/use-esco/download | |
|||
| 职位技能 Importance | O*NET | `31.0 Database` | `CC BY 4.0` | https://www.onetcenter.org/database.html | |
|||
|
|||
ESCO 产品署名必须使用:`This service uses the ESCO classification of the European Commission.` |
|||
|
|||
## 5. 映射责任边界 |
|||
|
|||
| 事项 | 上游来源责任 | 项目责任 | |
|||
| --- | --- | --- | |
|||
| ESCO 职业、技能、分类层级和来源 ID | 固定且可追溯 | 原样保存,不修改 ID | |
|||
| O*NET Importance 数值 | 固定且可追溯 | 原样保存;无映射时写 `null`,不造权重 | |
|||
| 中文名称和别名 | 不保证完整中文覆盖 | 补齐中文标准名、别名及归一化值 | |
|||
| 细粒度技能分类 | 不保证符合本项目展示粒度 | 在职位下维护分类和展示名称 | |
|||
| 行业/职位映射 | 不保证覆盖三类项目行业和中文职位名 | 维护行业—职位—分类—技能映射 | |
|||
| 跨源技能与重要性关联 | 不提供项目所需的稳定本地关系 | 维护 ESCO 技能到 O*NET `software_skills` 的补充映射,以及每条 Importance 的 SOC/Element ID/代理标记 | |
|||
|
|||
## 6. 验收检查项 |
|||
|
|||
- AC-1:`changes/n03-skill-cloud/data/source-manifest.md` 与 `docs/skill-taxonomy-sources.md` 均存在且非空,来源版本、许可、归属 URL、覆盖矩阵和导入规则一致。 |
|||
- AC-2:文档明确覆盖互联网、金融、教育三类行业及全部 10 个已列代表性职位,并为每个职位给出首批技能分类范围。 |
|||
- AC-3:文档明确固定 ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0)和 O*NET 31.0 Database。 |
|||
- AC-4:文档明确 ESCO 依据 Commission Decision 2011/833/EU 可免费下载、使用、复制和复用,并原样保留指定署名语句。 |
|||
- AC-5:文档明确 O*NET License 为 CC BY 4.0,并记录官方数据库页与许可 URL。 |
|||
- AC-6:文档明确项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。 |
|||
- AC-7:文档明确 T2 建结构、T3 导数据,只导入代表性职位和关联技能;技能中文标准名必填。 |
|||
- AC-8:文档明确 O*NET 补充技术、每条 Importance 的 SOC/Element ID/代理标记,以及无映射时保留技能并写入 `importance=null`、查询侧映射为 `heat=null`。 |
|||
- AC-9:文档明确别名归一化遵循 NFKC、首尾空白、大小写、连字符、连续空白、句点规则,且保留 `+`、`#`。 |
|||
- AC-10:文档明确后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或已执行迁移。 |
|||
- AC-11:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。 |
|||
@ -0,0 +1,73 @@ |
|||
# 短信登录后首次设置密码:实施与联调指令 |
|||
|
|||
本批面向后端与前端开发人员,实现首次设置引导。设置密码后,短信与密码登录继续并存;不新增必须设置密码才能使用业务的门禁。 |
|||
|
|||
## 1. 登录与密码状态 |
|||
|
|||
在 POST /auth/login、POST /auth/password/login 和 GET /auth/me 中读取 passwordConfigured。它由 password_hash 是否存在派生,不增加 users 状态列。 |
|||
|
|||
无密码账号只有在 login 用途短信验证成功后,才获得 passwordSetupToken 和 passwordSetupExpiresIn=600。凭证不是 JWT;使用安全随机数生成 32 字节,数据库只保存带 password-setup 域前缀的 HMAC 摘要。 |
|||
|
|||
凭证绑定 userId、sessionId 和固定到期时间。密码登录、刷新、身份选择都不会签发设置凭证;刷新不能延长有效期。当前会话退出或被撤销后,凭证不可使用。 |
|||
|
|||
## 2. 请求与响应 |
|||
|
|||
前置地址为既有 /api/v1。新接口 POST /auth/password/setup 必须携带当前 Access Token。 |
|||
|
|||
```json |
|||
{"passwordSetupToken":"短信登录返回的一次性凭证","newPassword":"用户输入的新密码"} |
|||
``` |
|||
|
|||
成功返回现有 ApiResponse 包装,data 含 passwordConfigured=true、reauthenticationRequired=false、identitySelectionRequired、accessToken、refreshToken、expiresIn。有效期沿用既有认证配置与契约,不写死为一小时。 |
|||
|
|||
前端应先替换 Access/Refresh Token 并清除设置凭证,再根据 identitySelectionRequired 进入身份选择或原角色首页。已有密码不重复弹设置页。凭证过期或响应丢失时,重新短信登录并读取密码状态;不得反复提交创建操作。 |
|||
|
|||
参数/密码策略错误为 400,无效会话或设置凭证为 401,已有密码冲突为 409,限流为 429,安全依赖不可用为 503。不得把 SQL、密码、令牌或凭证摘要返回给客户端。 |
|||
|
|||
## 3. 后端事务顺序 |
|||
|
|||
新增 V54 password_setup_challenges,不修改 V1–V53。核查 MySQL 5.7/8.4 和 H2 的兼容性。 |
|||
|
|||
先锁定用户并校验 active/注销状态,再锁定当前服务端会话记录,串行处理并发登出;检查操作限流与尚无密码,复用现有密码策略。数据库条件更新必须同时匹配用户、会话、摘要、未消费、未过期,且更新行数恰好为 1。 |
|||
|
|||
随后 BCrypt 保存密码及 passwordChangedAt,flush 用户,撤销全部旧会话,建立新会话,并写固定、脱敏审计。上述操作使用同一数据库事务;任何步骤失败均回滚。Redis 限流计数不宣称自动回滚。 |
|||
|
|||
未选身份账号只新增放行 POST /auth/password/setup,继续保留其他业务门禁。客户端不传 userId、role 或 sessionId。 |
|||
|
|||
## 4. 兼容与库存 |
|||
|
|||
保留 /auth/password/set 的 password-set 短信用途与成功后重新认证语义;保留修改、找回密码及原有登录、刷新、登出接口。设置成功不能覆盖企业认证或账号角色。 |
|||
|
|||
静态 OpenAPI 与运行时均增加一个路径、一个操作,目标 198 路径、241 操作。接口库存测试随真实新增接口更新;不降低原有身份门禁断言。 |
|||
|
|||
## 5. 验收步骤 |
|||
|
|||
用明确假号段准备无密码账号,经真实验证码消费逻辑短信登录,检查未设置状态和凭证有效期。首次设置后检查 BCrypt 匹配、新会话可用、旧 Access/Refresh Token 被撤销;再次短信登录不发设置凭证,密码登录也应成功。 |
|||
|
|||
测试跨用户、跨会话、匿名、过期、已消费、弱密码和安全依赖异常。两个并发请求同时使用凭证,只允许一个成功;审计或会话创建失败应回滚密码、消费状态、会话变更。 |
|||
|
|||
同一套断言在 H2 和真实 MySQL 8.4 执行,并运行 MySQL 5.7 迁移及认证关联回归。记录真实 F/E/S 与 OpenAPI 结果后再安排独立复验、提交和部署。 |
|||
|
|||
当前文档描述本地实现;测试结果以本轮实际报告为准,不表示已上线或已完成前端接入。 |
|||
|
|||
## 6. 本轮验证结果与交付边界 |
|||
|
|||
2026-10-12:认证关联回归执行 68 项,Failures=0、Errors=0、Skipped=0,覆盖旧密码接口、刷新、会话、统一注册、企业注册,以及真实 MySQL 5.7/8.4 迁移和运行时接口库存。 |
|||
|
|||
最终补充数据库异常安全响应与 DTO 脱敏后,重新编译最新源码并执行 H2 和真实 MySQL 8.4 的首次设置密码测试,共 20 项,Failures=0、Errors=0、Skipped=0;定向 mvn verify 成功并生成最新 JAR。两组测试存在重叠,不合计为 88 个独立用例。 |
|||
|
|||
静态 OpenAPI 检查通过:198 路径、241 操作。历史 V1–V53 无内容修改;新增 V54。git diff --check 通过,暂存区为空。 |
|||
|
|||
2026-10-12 补充全量与最新 JAR 验收:`mvn -o clean verify` 成功,3278 项,Failures=0、Errors=0、Skipped=0。首次全量发现 MySql57MigrationConfigurationTest 仍断言 53 个迁移,与新增 V54 不一致;仅更新计数为 54,定向 4/4 通过后重跑全量成功。全量日志位于本机临时目录 password-setup-full-verify-final.log。 |
|||
|
|||
验收对象为上述 clean verify 生成的 mydreamjob-server-0.0.1-SNAPSHOT.jar,SHA256:`635EB9F268771D266EED8965FCD39F03F9B24E8B7219945980FB54037D303822`。 |
|||
|
|||
使用独立、一次性 MySQL 8.4、Redis 7.4、RabbitMQ 3.13 容器与该 JAR 做真实 HTTP 黑盒,应用及管理健康检查均为 200,Flyway 达到 V54。使用假号段测试账号,短信发送通过 Mock sender,但验证码生成、数据库消费、Redis 限流及认证会话均执行真实实现;验证码从隔离数据库读取,没有调用运营商。 |
|||
|
|||
黑盒通过:无密码账号短信登录返回首次设置凭证;弱密码 400、匿名设置 401;设置成功返回新会话且无需重新登录;旧 Access 与 Refresh 均 401,新 Access 可用;再次使用设置凭证 409;设置后选择 STUDENT 成功;密码登录成功;再次发送验证码并短信登录成功,返回 passwordConfigured=true 且不再返回设置凭证。 |
|||
|
|||
数据库逐项证据:测试用户 1 行、STUDENT 主角色与 user_roles 各 1 条、BCrypt 格式密码与 password_changed_at 均已保存;设置凭证 1 条且已消费;两条短信验证码均已消费。四个 RabbitMQ 队列(短信、通知、审计、通知死信)ready/unacknowledged 全为 0。应用日志扫描未发现测试手机号、明文测试密码或 JWT/Bearer。 |
|||
|
|||
静态及运行时 OpenAPI 均为 198 路径、241 操作,方法/路径集合完全一致。黑盒脚本最初使用 PowerShell 属性集合的 .Count 导致误报,改用数组计数复核后通过。首次 JAR 启动遇到默认管理端口 8081 与前端 Metro 冲突,将本次管理端口设为 18081 后启动成功;应用端口为 18080。 |
|||
|
|||
验收后已停止本次 JAR,并删除三个一次性容器及其测试数据;13307、16379、25672、18080、18081 无本次残留监听。前端 Metro 保持运行。本批未暂存、提交、推送或部署;真实短信与前端设置密码页面联调仍未执行,不代表线上已有此接口。 |
|||
@ -0,0 +1,434 @@ |
|||
# 入职清单与公司知识介绍 API(N04) |
|||
|
|||
> 状态:后端实现完成,待真实 MySQL 8.4 / Docker 黑盒验收。 |
|||
> |
|||
> 数据库迁移:`V50__onboarding_checklist_and_company_knowledge.sql`、`V51__onboarding_standard_checklist_seed.sql`。 |
|||
|
|||
## 1. Base URL 与认证 |
|||
|
|||
本地 Base URL: |
|||
|
|||
```text |
|||
http://127.0.0.1:8080/api/v1 |
|||
``` |
|||
|
|||
所有 `/onboarding/**` 接口都要求登录,并携带: |
|||
|
|||
```http |
|||
Authorization: Bearer <accessToken> |
|||
Content-Type: application/json |
|||
X-Request-Id: <optional-trace-id> |
|||
``` |
|||
|
|||
细粒度角色和所有权在 Service 层校验。除 401 外,接口统一返回 `ApiResponse`: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": {}, |
|||
"requestId": "req-...", |
|||
"timestamp": "2026-09-17T01:30:00Z" |
|||
} |
|||
``` |
|||
|
|||
## 2. 接口与权限总览 |
|||
|
|||
| # | Method | Path | 角色 | 说明 | |
|||
|---|---|---|---|---| |
|||
| 1 | GET | `/onboarding/companies/{companyId}/checklist` | STUDENT | 读取有效清单、完成状态和完成率 | |
|||
| 2 | PUT | `/onboarding/companies/{companyId}/checklist/items/{itemId}/completion` | STUDENT | 设置本人完成或未完成 | |
|||
| 3 | GET | `/onboarding/companies/{companyId}/knowledge` | STUDENT | 读取企业资料、知识内容和公开评价摘要 | |
|||
| 4 | GET | `/onboarding/company/checklist-items` | COMPANY | 查询本企业活动与软删除自定义项 | |
|||
| 5 | POST | `/onboarding/company/checklist-items` | COMPANY | 创建本企业自定义项 | |
|||
| 6 | PUT | `/onboarding/company/checklist-items/{itemId}` | COMPANY | 按版本更新本企业条目 | |
|||
| 7 | DELETE | `/onboarding/company/checklist-items/{itemId}?version={version}` | COMPANY | 按版本软删除本企业条目 | |
|||
| 8 | GET | `/onboarding/company/knowledge` | COMPANY | 查询本企业知识配置 | |
|||
| 9 | PUT | `/onboarding/company/knowledge` | COMPANY | 创建或更新本企业知识配置 | |
|||
|
|||
权限规则: |
|||
|
|||
- STUDENT 接口只允许拥有 STUDENT 角色的账号访问。 |
|||
- COMPANY 接口只允许拥有 COMPANY 角色且 `companies.verified=true` 的账号访问;未认证企业统一返回 `403/5004`,在读取或写入本企业清单、知识内容前拒绝。 |
|||
- COMPANY 接口不接受 `companyId`;服务端从 JWT 用户解析其所属企业,避免跨企业写入。 |
|||
- ADMIN 没有 onboarding 专属管理接口;调用 STUDENT/COMPANY 接口时按非目标角色返回 `403/2002`。 |
|||
- 学生访问企业清单或知识不要求先有 Offer 或投递记录,但目标企业必须存在。 |
|||
- 企业条目不存在或属于其他企业时统一返回 `404/10501`,不泄露其他企业数据。 |
|||
|
|||
## 3. 入职清单语义 |
|||
|
|||
### 3.1 通用项与自定义项合并 |
|||
|
|||
有效清单由以下两部分组成: |
|||
|
|||
1. 所有活动通用项 `source=STANDARD`;V51 固定写入 12 条。 |
|||
2. 当前企业所有活动自定义项 `source=COMPANY`。 |
|||
|
|||
合并结果按 `sortOrder ASC, id ASC` 稳定排序。企业没有活动自定义项时,`customized=false`,仍返回全部通用项。企业存在活动自定义项时,`customized=true`,返回通用项与企业项。 |
|||
|
|||
`category` 固定为: |
|||
|
|||
```text |
|||
DOCUMENT | ADMIN | EQUIPMENT | TRAINING | LOGISTICS | OTHER |
|||
证件材料 | 行政手续 | 办公设备 | 培训学习 | 到岗安排 | 其他 |
|||
``` |
|||
|
|||
### 3.2 学生读取有效清单 |
|||
|
|||
```http |
|||
GET /api/v1/onboarding/companies/10/checklist |
|||
Authorization: Bearer <studentAccessToken> |
|||
``` |
|||
|
|||
响应: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"companyId": 10, |
|||
"companyName": "示例科技", |
|||
"customized": true, |
|||
"totalItems": 13, |
|||
"completedItems": 1, |
|||
"completionRate": 7.7, |
|||
"items": [ |
|||
{ |
|||
"itemId": 101, |
|||
"source": "STANDARD", |
|||
"itemKey": "standard-id-card", |
|||
"title": "准备身份证件", |
|||
"description": "准备身份证原件或有效身份证明,用于入职登记。", |
|||
"category": "DOCUMENT", |
|||
"required": true, |
|||
"sortOrder": 100, |
|||
"active": true, |
|||
"completed": true, |
|||
"completedAt": "2026-09-17T09:00:00", |
|||
"progressVersion": 1 |
|||
}, |
|||
{ |
|||
"itemId": 501, |
|||
"source": "COMPANY", |
|||
"itemKey": "company-10-8f1c", |
|||
"title": "领取门禁卡", |
|||
"description": "到行政前台领取门禁卡并完成权限激活。", |
|||
"category": "ADMIN", |
|||
"required": false, |
|||
"sortOrder": 1000, |
|||
"active": true, |
|||
"completed": false, |
|||
"completedAt": null, |
|||
"progressVersion": 0 |
|||
} |
|||
] |
|||
}, |
|||
"requestId": "req-onboarding-checklist", |
|||
"timestamp": "2026-09-17T01:30:00Z" |
|||
} |
|||
``` |
|||
|
|||
`completionRate = round(completedItems * 100 / totalItems, 1)`;总数为 0 时返回 `0.0`。完成状态只属于当前 STUDENT。 |
|||
|
|||
### 3.3 更新完成状态 |
|||
|
|||
请求使用绝对状态,而不是切换: |
|||
|
|||
```http |
|||
PUT /api/v1/onboarding/companies/10/checklist/items/501/completion |
|||
Authorization: Bearer <studentAccessToken> |
|||
|
|||
{"completed": true, "version": 0} |
|||
``` |
|||
|
|||
响应: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"itemId": 501, |
|||
"completed": true, |
|||
"completedAt": "2026-09-17T01:31:00Z", |
|||
"progressVersion": 1, |
|||
"totalItems": 13, |
|||
"completedItems": 2, |
|||
"completionRate": 15.4 |
|||
}, |
|||
"requestId": "req-onboarding-progress", |
|||
"timestamp": "2026-09-17T01:31:00Z" |
|||
} |
|||
``` |
|||
|
|||
版本规则: |
|||
|
|||
- 首次保存某条目时可省略 `version` 或传 `0`。 |
|||
- 已完成状态变化时必须传当前 `progressVersion`;过期返回 `409/10505`。 |
|||
- 重复提交相同 `completed` 值保持幂等,不新增记录、不递增版本。 |
|||
- 首次提交 `completed=false` 不创建记录。 |
|||
- `completed=false` 会清除 `completedAt`。 |
|||
- 一名学生与一个清单项最多一条进度记录。 |
|||
|
|||
### 3.4 企业创建自定义项 |
|||
|
|||
```http |
|||
POST /api/v1/onboarding/company/checklist-items |
|||
Authorization: Bearer <companyAccessToken> |
|||
|
|||
{ |
|||
"title": "领取门禁卡", |
|||
"description": "到行政前台领取门禁卡并完成权限激活。", |
|||
"category": "ADMIN", |
|||
"sortOrder": 1000, |
|||
"required": false, |
|||
"idempotencyKey": "company-10-access-card-20260917" |
|||
} |
|||
``` |
|||
|
|||
响应 `data`: |
|||
|
|||
```json |
|||
{ |
|||
"itemId": 501, |
|||
"source": "COMPANY", |
|||
"itemKey": "company-10-8f1c", |
|||
"title": "领取门禁卡", |
|||
"description": "到行政前台领取门禁卡并完成权限激活。", |
|||
"category": "ADMIN", |
|||
"required": false, |
|||
"sortOrder": 1000, |
|||
"active": true, |
|||
"version": 0 |
|||
} |
|||
``` |
|||
|
|||
约束: |
|||
|
|||
- `title`:必填,最多 120 字符。 |
|||
- `description`:可选,最多 1000 字符。 |
|||
- `category`:必填,仅允许固定枚举。 |
|||
- `sortOrder`:可选,默认 `1000`,范围 `0..9999`。 |
|||
- `required`:可选,默认 `false`。 |
|||
- `idempotencyKey`:可选,最多 64 字符;服务端会 trim,空串按未提供处理。 |
|||
- 活动自定义项上限为 50 条;达到上限返回 `409/10503`。 |
|||
|
|||
幂等重试: |
|||
|
|||
- 同一企业、同一 `idempotencyKey`、同标题/说明/分类/排序/必填值:返回首次创建的条目。 |
|||
- 同一企业、同一 key、请求语义不同:返回 `409/10504`。 |
|||
- 不同企业可使用相同 key,互不冲突。 |
|||
- 未传 key 时,每次请求创建新条目。 |
|||
|
|||
### 3.5 企业查询、更新与软删除 |
|||
|
|||
查询本企业全部条目: |
|||
|
|||
```http |
|||
GET /api/v1/onboarding/company/checklist-items |
|||
Authorization: Bearer <companyAccessToken> |
|||
``` |
|||
|
|||
响应包含活动与软删除条目,不返回通用项和其他企业项。更新请求: |
|||
|
|||
```http |
|||
PUT /api/v1/onboarding/company/checklist-items/501 |
|||
Authorization: Bearer <companyAccessToken> |
|||
|
|||
{ |
|||
"title": "领取门禁卡并激活", |
|||
"description": "到行政前台领取门禁卡并完成权限激活。", |
|||
"category": "ADMIN", |
|||
"sortOrder": 900, |
|||
"required": true, |
|||
"active": true, |
|||
"version": 0 |
|||
} |
|||
``` |
|||
|
|||
更新使用 `version` 乐观锁,成功递增版本;过期返回 `409/10502`。软删除: |
|||
|
|||
```http |
|||
DELETE /api/v1/onboarding/company/checklist-items/501?version=1 |
|||
Authorization: Bearer <companyAccessToken> |
|||
``` |
|||
|
|||
首次删除必须使用当前版本,系统只将 `active=false`,不物理删除。重复删除已非活动条目保持幂等成功;接口仍要求携带 `version` 查询参数。 |
|||
|
|||
## 4. 公司知识介绍语义 |
|||
|
|||
### 4.1 学生读取与回退 |
|||
|
|||
```http |
|||
GET /api/v1/onboarding/companies/10/knowledge |
|||
Authorization: Bearer <studentAccessToken> |
|||
``` |
|||
|
|||
响应: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"company": { |
|||
"id": 10, |
|||
"name": "示例科技", |
|||
"logoUrl": "https://cdn.example.com/company-10.png", |
|||
"description": "面向企业提供数据基础设施产品。", |
|||
"industry": "互联网", |
|||
"website": "https://example.com", |
|||
"address": "杭州市余杭区", |
|||
"verified": true |
|||
}, |
|||
"knowledgeState": "CONFIGURED", |
|||
"knowledge": { |
|||
"culture": "以用户价值为中心,保持透明和长期主义。", |
|||
"workPolicies": "弹性上班;每周一、三线下办公。", |
|||
"onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", |
|||
"updatedAt": "2026-09-17T01:33:00Z" |
|||
}, |
|||
"reviewSummary": { |
|||
"state": "SUFFICIENT", |
|||
"totalReviews": 4, |
|||
"averageRating": 4.5, |
|||
"ratingDistribution": { |
|||
"rating1": 0, |
|||
"rating2": 0, |
|||
"rating3": 1, |
|||
"rating4": 1, |
|||
"rating5": 2 |
|||
}, |
|||
"topTags": ["发展空间", "同事友好"], |
|||
"dimensionSummary": { |
|||
"AUTHENTICITY": {"averageScore": 4.5, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"BENEFITS": {"averageScore": 4.3, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"SALARY_TRANSPARENCY": {"averageScore": 4.0, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"JD_CLARITY": {"averageScore": 4.6, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"GROWTH": {"averageScore": 4.4, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"TEAM_ATMOSPHERE": {"averageScore": 4.7, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"PROMOTION": {"averageScore": 4.1, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
|||
"WORK_LIFE_BALANCE": {"averageScore": 4.0, "sampleCount": 4, "dataState": "SUFFICIENT"} |
|||
} |
|||
} |
|||
}, |
|||
"requestId": "req-company-overview", |
|||
"timestamp": "2026-09-17T01:34:00Z" |
|||
} |
|||
``` |
|||
|
|||
回退与状态: |
|||
|
|||
- 企业无知识记录:仍返回企业基础资料,`knowledgeState=BASIC_ONLY`,`knowledge` 省略。 |
|||
- 企业有知识记录:`knowledgeState=CONFIGURED`,返回非空知识字段和 `updatedAt`;学生公开读取响应不返回知识配置版本。 |
|||
- 企业不存在:`404/5001`。 |
|||
- 响应白名单不包含 `ownerUserId`、用户 ID、手机号、邮箱、评价哈希或审核原因。 |
|||
|
|||
评价摘要只聚合企业全部岗位中 `PUBLISHED` 且审核状态为 `AUTO_APPROVED/APPROVED` 的评价: |
|||
|
|||
| `reviewSummary.state` | 条件 | 返回内容 | |
|||
|---|---|---| |
|||
| `EMPTY` | 0 条 | `state`、`totalReviews=0` | |
|||
| `INSUFFICIENT` | 1–2 条 | `state`、真实 `totalReviews`;不返回均值、分布或维度汇总 | |
|||
| `SUFFICIENT` | 至少 3 条 | 平均评分、评分分布、热门标签、八维汇总 | |
|||
|
|||
热门标签最多 10 个;八个维度分别返回 `sampleCount` 和 `dataState`。维度的 `dataState` 使用同一 3 条阈值。 |
|||
|
|||
### 4.2 企业查询知识配置 |
|||
|
|||
```http |
|||
GET /api/v1/onboarding/company/knowledge |
|||
Authorization: Bearer <companyAccessToken> |
|||
``` |
|||
|
|||
未配置时: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"configured": false, |
|||
"version": 0 |
|||
}, |
|||
"requestId": "req-company-knowledge", |
|||
"timestamp": "2026-09-17T01:33:00Z" |
|||
} |
|||
``` |
|||
|
|||
已配置时返回 `configured=true`、非空内容、`updatedAt` 和当前 `version`。空字段按 `@JsonInclude(NON_NULL)` 省略。 |
|||
|
|||
### 4.3 企业创建或更新知识配置 |
|||
|
|||
```http |
|||
PUT /api/v1/onboarding/company/knowledge |
|||
Authorization: Bearer <companyAccessToken> |
|||
|
|||
{ |
|||
"culture": "以用户价值为中心,保持透明和长期主义。", |
|||
"workPolicies": "弹性上班;每周一、三线下办公。", |
|||
"onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", |
|||
"version": 1 |
|||
} |
|||
``` |
|||
|
|||
规则: |
|||
|
|||
- 至少一个内容字段必须非空。 |
|||
- `culture` 最多 2000 字符,`workPolicies` 和 `onboardingGuide` 各最多 4000 字符。 |
|||
- 字段按纯文本处理,会 trim;包含 HTML 标签时返回 `400/1001`。 |
|||
- 首次创建可以省略 `version`,创建后版本为 `0`。 |
|||
- 更新已有记录必须提交当前版本;版本过期返回 `409/10506`。 |
|||
- 已有内容与提交内容相同且未传版本时按幂等读取处理;内容不同但未传版本返回 `400/1001`。 |
|||
|
|||
## 5. 错误码 |
|||
|
|||
| HTTP | code | 含义 | 前端处理 | |
|||
|---|---:|---|---| |
|||
| 400 | 1001 | 参数、枚举、长度、HTML、缺少必填字段 | 展示字段错误,不自动重试 | |
|||
| 401 | 401 | 未登录或 Token 无效 | 清理登录态并重新登录 | |
|||
| 403 | 2002 | 角色不匹配、无所属企业或无权限 | 不重试,切换到正确角色 | |
|||
| 404 | 5001 | 企业不存在或 COMPANY 无绑定企业 | 检查 `companyId` 或企业绑定状态 | |
|||
| 404 | 10501 | 清单项不存在、跨企业或不属于有效清单 | 刷新清单后重新选择条目 | |
|||
| 409 | 10502 | 企业清单项版本过期 | 重新 `GET /company/checklist-items`,使用新版本重试 | |
|||
| 409 | 10503 | 活动自定义项达到 50 条上限 | 禁用新增,提示先软删除或复用现有项 | |
|||
| 409 | 10504 | 幂等键已用于不同请求体 | 更换幂等键,或确认原请求后读取原条目 | |
|||
| 409 | 10505 | 学生完成状态版本过期 | 重新读取有效清单并使用新 `progressVersion` | |
|||
| 409 | 10506 | 公司知识版本过期 | 重新读取企业知识配置并使用新 `version` | |
|||
|
|||
401 由安全过滤器返回裸 JSON,没有统一 `ApiResponse.requestId`: |
|||
|
|||
```json |
|||
{"code": 401, "message": "not authenticated", "timestamp": ""} |
|||
``` |
|||
|
|||
其他错误示例: |
|||
|
|||
```json |
|||
{ |
|||
"code": 10504, |
|||
"message": "idempotency key conflict: request body differs", |
|||
"data": null, |
|||
"requestId": "req-10504", |
|||
"timestamp": "2026-09-17T01:30:00Z" |
|||
} |
|||
``` |
|||
|
|||
## 6. 前端推荐流程 |
|||
|
|||
学生端: |
|||
|
|||
1. 登录后调用 `GET /onboarding/companies/{companyId}/checklist`。 |
|||
2. 使用响应的 `itemId` 和 `progressVersion` 调用完成状态接口。 |
|||
3. 收到 `10505` 时重新读取清单,避免覆盖其他设备的新状态。 |
|||
4. 调用 `GET /onboarding/companies/{companyId}/knowledge` 展示企业介绍与评价摘要。 |
|||
5. 当 `reviewSummary.state=EMPTY/INSUFFICIENT` 时,不展示平均值、分布和维度均值。 |
|||
|
|||
企业端: |
|||
|
|||
1. 登录后调用 `GET /onboarding/company/checklist-items` 获取当前版本。 |
|||
2. 新增时生成稳定 `idempotencyKey` 并保存到重试请求中。 |
|||
3. 更新或删除前使用最新 `version`,冲突后重新拉取列表。 |
|||
4. 调用 `GET /onboarding/company/knowledge` 获取配置版本,再使用 `PUT` 保存。 |
|||
5. 收到 `10503` 时停止自动新增;收到 `10504` 时不要复用旧请求体。 |
|||
File diff suppressed because it is too large
@ -0,0 +1,163 @@ |
|||
# Phase B:已认证企业业务门禁实施报告 |
|||
|
|||
日期:2026-09-23 |
|||
范围:仅 Phase B;未暂存、未提交、未推送、未部署。 |
|||
|
|||
## 结论 |
|||
|
|||
Phase B 门禁已在服务层实施并通过全量验证。`mvn test` 与 `mvn clean verify` 均为 **2880 / 0 failures / 0 errors / 0 skipped**;真实 MySQL/Testcontainers 用例实际运行。OpenAPI 检查为 **182 paths / 220 operations,All checks passed**。API 路径和 operation 集合未改,`docs/openapi-rc.json` 无差异。 |
|||
|
|||
本轮没有启动应用部署或连接生产 RabbitMQ。拒绝路径的事件/通知调用通过测试中的 mock `never()` 断言验证;本机 RabbitMQ 容器当时为停止状态,因此不报告未经测量的 broker 队列深度。没有数据库迁移,也没有新增业务状态字段。 |
|||
|
|||
## 1. 开工核查与基线 |
|||
|
|||
- 分支:`master` |
|||
- HEAD:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`(Phase A 已交付基线) |
|||
- 暂存区:空;`git diff --check`:exit 0 |
|||
- 工作区:除本轮明确列出的改动外,原有历史未跟踪文件全部保留;未运行 `reset`、`restore`、`checkout`、`clean` 或任何目录级 `git add`。 |
|||
- `git status` 曾将 `CandidateOverviewStatusPolicy.java` 与 `JobPreferenceScoreCalculator.java` 标成修改;逐文件 `git hash-object` 与 HEAD blob 相同,且 `git diff --quiet`,确认是 Windows stat 缓存假阳性,内容未改。 |
|||
- 迁移库存最高为 **V46**;V1–V46 未改、无 V47。 |
|||
- 上线健康接口可见版本仅为 `0.0.1-SNAPSHOT`,不能据此映射出精确线上 commit。Phase B 未部署,线上版本未改变。 |
|||
- Docker 可用;最终全量验证中的 MySQL/Testcontainers 测试真实执行,Skipped=0。 |
|||
|
|||
## 2. 权威设计和门禁语义 |
|||
|
|||
以 `docs/company-onboarding-admin-user-management-design.md` §7 及独立评审为准:企业认证事实只读 `companies.verified`;`users.is_verified` 不用于企业认证判断。 |
|||
|
|||
检查顺序保持为:现有认证/活动账号校验 → `user_roles` 的 COMPANY 身份 → 当前企业资料存在及 owner 归属 → 仅对受限招聘业务检查 `companies.verified=true`。认证门禁不能取代任何角色、归属、资源可见性判断。资源查询前优先校验调用者所属企业;按资源授权的入口仍保留其原归属检查,避免跨企业数据探测。 |
|||
|
|||
未认证、审核中、已拒绝均按 `companies.verified=false` 统一拒绝受限操作,固定响应 `COMPANY_NOT_VERIFIED` / code **5004** / HTTP **403** / 文案 `company not verified`。该错误码与已有数字码无冲突,HTTP 状态集中登记在 `ErrorHttpStatusRegistry`,没有在 Controller 中复制异常映射。 |
|||
|
|||
## 3. 端点库存与允许/禁止矩阵 |
|||
|
|||
逐个检查了 Controller 映射及其 Service 路径。下表中“COMPANY”表示现有 Controller/服务中的 COMPANY 身份要求仍保留;“own/资源归属”表示原企业 owner、岗位、投递或参与者校验仍执行。受限入口在服务端、候选人/Offer/面试等敏感查询或副作用之前执行认证门禁。 |
|||
|
|||
| 接口范围(HTTP method + path) | Controller → Service | Phase B 结果及既有校验 | |
|||
|---|---|---| |
|||
| `POST /jobs`;`PUT /jobs/{jobId}`;`PATCH /jobs/{jobId}/status` | `JobController` → `JobService` | 仍要求 COMPANY 和本企业岗位归属。创建 DRAFT、编辑自己的 DRAFT 允许;已发布内容编辑以及向 `PUBLISHED` 转换要求认证。将自己已发布岗位转为 `CLOSED` 仍允许,保留安全下架能力和旧状态语义。跨企业岗位仍按旧归属错误拒绝。公开 `GET /jobs`、`GET /jobs/search`、`GET /jobs/public/{jobId}`、`GET /companies/{companyId}/jobs` 不改变。 | |
|||
| `GET /company/applications`;`PATCH /applications/{id}/status`;`GET /company/candidates`;`GET /company/candidates/{applicationId}`;`GET /company/candidates/{applicationId}/resume`;`GET /applications/{id}/timeline`(企业分支) | `ApplicationController` → `ApplicationService` / `CompanyResumeAccessPolicy` | COMPANY、公司/岗位归属与既有可见性校验保留。企业认证先于敏感投递/候选人查询;下载门禁先于简历存储读取。拒绝时不读文件;学生本人的投递/timeline 分支不加企业门禁。 | |
|||
| `GET /company/candidates/{applicationId}/scoring`;`GET /company/scoring/dashboard/{overview,job-rankings,job-rankings/{jobId},candidate-rankings,candidate-rankings/{applicationId},trend}` | `CandidateScoringController`、`CompanyScoringDashboardController` → scoring services | 企业评分/候选人评分属于候选人敏感数据,所有公司入口要求 verified;原公司归属与管理员分支保持不变。 | |
|||
| `GET/POST/PUT/DELETE /applications/{applicationId}/feedback` | `CandidateFeedbackController` → `CandidateFeedbackService` | 企业侧 feedback 先校验公司归属及认证,再访问投递或写反馈;学生自己的反馈路径/权限保持原语义。 | |
|||
| `GET /companies/me/jobs/{jobId}/talent-recommendations`;`POST /companies/me/jobs/{jobId}/talent-invitations`;`GET /companies/me/talent-invitations`;`GET /companies/me/talent-invitations/{invitationId}` | `TalentDiscoveryController`、`TalentInvitationController` → respective services | 企业身份、岗位/邀请归属后要求认证;门禁先于候选人匹配、资料加载及邀请写入/通知。`/profile/me/talent-discovery` 与求职者邀请列表/决定接口仍为求职者自身功能。 | |
|||
| `POST/GET /applications/{applicationId}/interviews`;`GET /interviews/me`;`GET/PUT /interviews/{interviewId}`;`POST /interviews/{interviewId}/cancel`、`/complete`;`GET /interviews/{interviewId}/timeline` | `InterviewController` → `InterviewService` | 企业安排、修改、取消、完成及查看面试时先检查企业认证及原面试/投递归属;求职者查看和回应自己的面试保留原路径与行为。 | |
|||
| `PUT/GET /interviews/{interviewId}/score`;`POST /interviews/{interviewId}/score/submit`;`GET /interviews/{interviewId}/score/history` | `InterviewScoreController` → `InterviewScoreService` | 企业侧评分读写要求认证,并保留面试参与者/归属检查;求职者侧不被企业门禁误伤。 | |
|||
| `POST/GET /offers`;`GET/PUT /offers/{id}`;`PATCH /offers/{id}/status`;`GET /offers/{id}/timeline`;`POST /offers/{id}/remind`;`GET/POST /offers/{id}/counter-offers`;`PATCH /offers/{id}/counter-offers/{coId}/accept`、`/reject`;`GET /offers/statistics` | `OfferController` → `OfferService` | 对公司发起、读取、修改、发送/流转 Offer 及统计入口,在查询/写入/提醒副作用之前验证公司。原 application/offer/counter-offer 所有权检查保留。`GET /offers/compare` 为求职者接口,不改。 | |
|||
| `GET/PUT /companies/me/jobs/{jobId}/recommendation-profile`;`GET/PUT /companies/me/match-rule` | recommendation、`MatchRuleController` → services | 公司侧候选人推荐/匹配配置要求 verified,并保留 owner 校验;`/profile/me/match-rule` 等求职者配置不改。 | |
|||
| `PUT /applications/{id}/hr-suitability-score`;`GET /applications/{id}/match`;`POST /applications/{id}/conversation`;`GET/POST /conversations`;`GET /conversations/{id}`、`/{id}/messages`;`POST /conversations/messages`、`/{id}/read`、`/{conversationId}/messages/{messageId}/recall` | match、conversation、`MessagingController` → services | 企业候选人匹配、发起/读取招聘会话和消息均需 verified。会话成员、投递和消息归属校验保留;求职者自己的候选兴趣分/匹配路径不变。消息列表读取还使用明确加载 `user_roles` 的查询,避免 detached lazy roles 导致角色分支错误。 | |
|||
| `GET /notifications`、`/unread-count`;`POST /notifications/{id}/read`、`/read-all`(企业主体) | `NotificationController` → `NotificationService` | 企业用户侧通知查看/读状态操作要求认证,拒绝先于通知列表读取或读状态写入;其他主体语义保留。系统 worker/事件生产路径不套用户发起的企业门禁。 | |
|||
| `GET/POST/PUT /companies/me`;`GET /companies/me/onboarding`;`POST/GET /companies/me/verification` | `CompanyController`、`CompanyVerificationController` → company/onboarding/verification services | 未认证企业仍可查改自己的资料、查询聚合状态、提交和查询自己的认证申请。身份及 owner 关系照旧验证;认证审核状态机未改。 | |
|||
|
|||
### 明确未加门禁的入口 |
|||
|
|||
- 公开岗位、公司资料和公开评价查询没有候选人隐私或企业招聘副作用,保持公开行为。 |
|||
- `CompanyReviewManagementController` 的本企业公开岗位评价/回复用于企业声誉运营,不读取候选人敏感资料,也不是招聘动作;按 Phase B 设计范围不设认证门禁,既有公司归属照旧。 |
|||
- `ApplicationMatchController` 中求职者的 candidate-interest-score、求职者岗位匹配与个人设置仍按原 STUDENT 路径处理。 |
|||
- `OfferExpiryScheduler`/expiry service、匹配通知重试、候选评分 refresh worker、邀请过期清理和通知消费是系统维护/既有任务,不是未认证企业发起的招聘请求;逐一检查没有从这些后台入口调用受限用户写 API,因此不加企业认证门禁,避免误伤既有系统处理。 |
|||
|
|||
## 4. 实施内容和精确文件范围 |
|||
|
|||
新增服务层共用规则 `mydreamjob-server/src/main/java/com/mydreamjob/company/VerifiedCompanyGuard.java`。它只依据传入的 `Company.verified` 判定;角色、活动状态、公司归属和资源归属仍由现有 service/guard 流程负责。 |
|||
|
|||
修改的生产文件(20 个): |
|||
|
|||
- `application/ApplicationService.java`、`application/CompanyResumeAccessPolicy.java` |
|||
- `auth/UserRepository.java` |
|||
- `bidirectionalmatch/BidirectionalMatchService.java`、`MatchConversationService.java`、`MatchRuleService.java` |
|||
- `candidatescore/CandidateScoringEngineService.java`、`candidatescore/dashboard/CompanyScoringDashboardService.java` |
|||
- `common/exception/ErrorCode.java`、`ErrorHttpStatusRegistry.java` |
|||
- `feedback/CandidateFeedbackService.java` |
|||
- `interview/InterviewService.java`、`interviewscore/InterviewScoreService.java` |
|||
- `job/JobService.java` |
|||
- `messaging/MessagingService.java`、`notification/NotificationService.java` |
|||
- `offer/OfferService.java` |
|||
- `recommendation/JobRecommendationService.java` |
|||
- `talentdiscovery/TalentDiscoveryService.java`、`talentinvitation/TalentInvitationService.java` |
|||
|
|||
新增测试类(3 个): |
|||
|
|||
- `phaseb/PhaseBVerifiedCompanyGateContractTest.java`:静态端点—Service 覆盖映射,并锁定认证只看 `companies.verified`。 |
|||
- `phaseb/VerifiedCompanyGuardTest.java`:verified / false / null / company missing 与固定业务码和 403 映射。 |
|||
- `phaseb/VerifiedCompanyGatePersistenceIntegrationTest.java`:真实 HTTP `PATCH /jobs/{id}/status` 对 UNVERIFIED、PENDING、REJECTED 均返回 403/code 5004,并确认 DRAFT 仍未被发布;verified 企业发布成功。 |
|||
|
|||
另有既有 service/controller/persistence 测试被扩展:求职者、企业、管理员认证角色及旧行为回归;简历读取计数与租户边界;Job 发布;Offer、Interview、TalentInvitation 的拒绝副作用;feedback、matching、messaging、notification、scoring、resume、interview-score 的企业测试夹具现在明确使用已认证 Company,避免旧测试把默认 `verified=false` fixture 误当已认证企业。R01-B/R01-C/R01-E 错误映射和消息查询预算测试只调整了 Phase B 新码映射/现有 eager-role 读取所增加的固定查询基线。 |
|||
|
|||
被修改的既有测试文件(27 个): |
|||
|
|||
- `application/ApplicationServiceTest.java`、`CompanyResumeAccessPolicyTest.java`、`CompanyResumeDownloadIntegrationTest.java` |
|||
- `bidirectionalmatch/BidirectionalMatchPersistenceIntegrationTest.java`、`BidirectionalMatchServiceTest.java`、`MatchConversationServiceTest.java`、`MatchRuleServiceTest.java` |
|||
- `candidatescore/CandidateScoringClockConsistencyTest.java`、`CandidateScoringEngineServiceTest.java` |
|||
- `common/exception/GlobalExceptionHandlerStatusContractTest.java` |
|||
- `common/r01b/ErrorHttpStatusRegistryTest.java`、`GlobalExceptionHandlerMappingParityTest.java`、`R01BIndependentHttpMappingParityTest.java` |
|||
- `common/r01e/R01EIndependentCrossDatabaseVerificationTest.java`、`R01EQueryBudgetAcceptanceTest.java` |
|||
- `feedback/CandidateFeedbackRefreshRegistrationTest.java`、`CandidateFeedbackServiceTest.java` |
|||
- `interview/InterviewServiceTest.java` |
|||
- `interviewscore/InterviewScoreControllerTest.java`、`InterviewScorePersistenceIntegrationTest.java`、`InterviewScoreServiceTest.java` |
|||
- `job/JobServiceTest.java` |
|||
- `messaging/MessageRecallHttpIntegrationTest.java`、`MessagingServiceTest.java` |
|||
- `notification/NotificationServiceTest.java` |
|||
- `offer/OfferServiceTest.java` |
|||
- `talentinvitation/TalentInvitationServiceTest.java` |
|||
|
|||
没有修改 Controller mappings、DTO、`pom.xml`、Flyway、V1–V46、OpenAPI 文件、短信服务、Phase A 注册/验证码/Token/企业认证审核流程,也没有任何 Phase C 用户/角色管理代码。 |
|||
|
|||
## 5. TDD 失败证据与测试覆盖 |
|||
|
|||
- 先增加 `JobServiceTest.transitionStatus_unverifiedCompanyCannotPublish`,在尚无门禁时失败:期望 `BusinessException`,旧实现没有抛出。修复前失败日志保存在系统临时目录,不加入仓库。 |
|||
- 对简历策略做单点变异(移除认证判断)后,`CompanyResumeAccessPolicyTest` 新增负向用例失败:未认证时仍访问 `applicationRepository.findById`,与“门禁先于候选人/简历数据读取”相违。源码恢复后,变异目标测试转绿;变异失败日志保存在系统临时目录。 |
|||
- HTTP 集成覆盖 UNVERIFIED/PENDING/REJECTED 统一 403 及 verified 可发布。跨企业资源仍由原 service ownership tests 和既有集成回归覆盖。 |
|||
- 简历测试包含 storage read probe,拒绝路径 `READ_CALLS=0`。Job 拒绝后状态仍 DRAFT。Offer、Interview、TalentInvitation 的未认证路径对投递/岗位/候选人/offer/interview/invitation/history/audit/notification/刷新等依赖均断言 `never()`,无业务写入或通知触发。 |
|||
- 无效 Token、匿名、STUDENT、ADMIN 的 401/403 使用现有 SecurityFilterChain/Controller 回归;Phase B 没有放开匿名路由,也没有改 SecurityConfig。 |
|||
|
|||
本轮测试驱动过程中的非产品问题:部分旧单测严格 Mockito stubbing 仍 stub 旧的 `findById`;用户/角色集合在 service 离开事务后为 lazy,导致企业身份分支不能可靠判定。通过更新这些测试的 stub,并新增 `UserRepository.findByIdWithRoles` 明确加载 `user_roles` 修复;没有降低断言,也没有删角色检查。OpenAPI 初次调用时使用了不存在的 `docs/verify/_openapi.py` 路径;依据仓库文件清单改用 `docs/verify_openapi.py` 后全部通过,未改脚本。 |
|||
|
|||
## 6. 验证结果 |
|||
|
|||
| 检查 | 结果 | |
|||
|---|---| |
|||
| Phase B 定向(Contract + Guard + persistence + Job/Application/Resume/Offer/Interview/Invitation 及关联服务) | **189/189,F=0 E=0 S=0**;另外最新版 persistence HTTP 类 **2/2** 单独通过 | |
|||
| 全量 `mvn test` | **2880/2880,F=0 E=0 S=0,BUILD SUCCESS** | |
|||
| 最终 `mvn clean verify` | **2880/2880,F=0 E=0 S=0,BUILD SUCCESS**;MySQL/Testcontainers 实测执行 | |
|||
| `python ..\docs\verify_openapi.py`(从 server 模块目录) | **182 paths / 220 operations,All checks passed** | |
|||
| 数据库 schema/migration | V1–V46 无差异;无迁移需求;无 V47 | |
|||
| OpenAPI/API surface | Controller 路径和 operation 集合未改变;`docs/openapi-rc.json` 无差异 | |
|||
|
|||
Phase B 新增用例共 **12**,全套从 2868 增至 2880。Docker 中 MySQL 8.4/Testcontainers 用例真实执行,Skipped=0。Hikari 在 Testcontainers 销毁/重建连接时输出连接已关闭的校验 WARN;两轮全量均以 0 errors 成功完成。 |
|||
|
|||
## 7. 副作用、安全与已知观测 |
|||
|
|||
- 认证事实静态断言确认 Guard 只读取 `Company.verified`,不读取 `User.isVerified`。 |
|||
- 租户 A 即使已认证仍须通过原 owner/资源关系校验;新门禁不构成跨企业授权。服务先验证企业/权限,再查候选人敏感资源;简历 policy 在 `read(storagePath)` 之前失败关闭。 |
|||
- 拒绝请求不会改变 DRAFT/Offer/Interview/Invitation 等业务记录;未调用 audit、notification、message/event publisher 或 scoring refresh 依赖。没有真实 RabbitMQ 运行,因此不声称 broker queue depth 被独立读取;拒绝路径的 publisher 无交互由测试逐项锁定。 |
|||
- 新增代码和报告未包含凭据、JWT、密码、真实手机号、数据库连接秘密、容器动态口令或本机路径;测试数据使用非真实 fixture。修复前日志留在临时目录,未加入 Git。 |
|||
- 没有通过设置 `verified=true` 绕过生产流程;“verified”测试场景仅在测试 fixture 中按显式场景构造,以验证认证企业原行为。 |
|||
|
|||
## 8. Git 结束状态 |
|||
|
|||
- HEAD 仍为 `8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`,分支仍为 `master`。 |
|||
- 暂存区为空,`git diff --check` exit 0;没有提交、推送、部署或合并。 |
|||
- 新增生产组件和 Phase B 测试及本报告仍未跟踪;所有此前历史未跟踪文件原样保留。 |
|||
- 工作区中 20 个已跟踪生产文件有 Phase B 改动;额外两个显示为修改的既有文件已通过 blob hash 证实内容与 HEAD 完全一致。 |
|||
- 最终核查无 Java/Maven 残留进程,8080/8081 无监听;未停止或删除 Docker 容器/镜像。 |
|||
|
|||
Phase C 管理员用户与角色管理未进入;Phase B 未部署。下一步是独立代码复验,不是提交或部署。 |
|||
|
|||
## 9. 两项独立复验阻断修复(2026-09-23) |
|||
|
|||
独立复验发现岗位发布门禁发生在岗位 ID/归属查询之后,可能通过业务码与 HTTP 状态差异探测跨企业岗位;另发现 `VerifiedCompanyGatePersistenceIntegrationTest` 使用全局 `TestAuthTokens` 数据源注册表,在组合运行时偶发无法签发会话。 |
|||
|
|||
- `JobService.transitionStatus` 对目标 `PUBLISHED` 先调用 `requireVerifiedCompany`,再查询岗位;未认证公司的有效 ID、其他公司 ID 和不存在 ID 均先得到固定认证拒绝,不触发岗位查询。 |
|||
- 新增 `JobRepository.findByIdAndCompanyId`,并用于企业岗位读取、修改和状态操作。跨企业岗位和不存在岗位都按 `JOB_NOT_FOUND`/404 处理;`JobController.getJob` 改为单次归属限定查询,避免泄露资源存在性。 |
|||
- 保留设计允许的未认证企业自有 DRAFT 岗位查看与编辑;已认证企业仍必须通过原企业归属条件,不获得跨企业访问权限。 |
|||
- `VerifiedCompanyGatePersistenceIntegrationTest` 不再调用静态全局 `TestAuthTokens`,改由当前 Spring 测试上下文的真实 `AuthSessionService` 创建会话,Token 和会话记录落在当前事务数据源中,事务结束回滚,不依赖其他测试上下文的注册/销毁顺序。 |
|||
- 先加 HTTP 回归再改生产代码时,新测试复现了原差异:未认证发布外企业岗位得到 400、读取/修改外企业岗位得到 400,而目标契约为认证门禁 403 或与不存在资源一致的 404。更新后的 HTTP 集成类 4/4 通过;岗位与认证边界组合 33/33 通过;原 10 类定向组合现为 **191/191**,F=0/E=0/S=0。 |
|||
- 两条既有跨企业岗位 HTTP 断言改为校验 404/`JOB_NOT_FOUND`;夹具明确设成 VERIFIED,确保发布测试实际走到资源归属门禁而不是被未认证门禁短路。 |
|||
|
|||
修复后验证: |
|||
|
|||
| 检查 | 结果 | |
|||
|---|---| |
|||
| `mvn test` | **2882/2882,F=0/E=0/S=0,BUILD SUCCESS** | |
|||
| `mvn clean verify` | **2882/2882,F=0/E=0/S=0,BUILD SUCCESS**;Testcontainers 真实执行 | |
|||
| `python ..\docs\verify_openapi.py` | **182 paths / 220 operations,All checks passed** | |
|||
|
|||
两项阻断已修复并验证。本轮仍未暂存、提交、推送或部署;Phase B 后续仍需独立复验结论确认后才能进入相应检查点。 |
|||
@ -0,0 +1,136 @@ |
|||
# Phase B「已认证企业业务门禁」独立代码复验与真实 MySQL 黑盒验收 |
|||
|
|||
日期:2026-09-23 |
|||
范围:Phase B 独立代码复验、缺陷修复后回归、MySQL 8.4 + HTTP 黑盒 |
|||
结论:**通过;未暂存、未提交、未推送、未部署** |
|||
|
|||
## 1. 结论摘要 |
|||
|
|||
独立复核确认,招聘类受限操作由 Service 层的共用企业认证门禁保护,判定依据为 `companies.verified`;角色、公司归属和资源归属仍分别执行。补充修复两处复验阻断后,新增真实 MySQL 8.4 + 随机端口 HTTP 黑盒测试通过;Phase B 定向回归 **208/208**,全量 `mvn test` 与 `mvn clean verify` 各 **2884/2884,Failures=0、Errors=0、Skipped=0**。OpenAPI 检查 **182 paths / 220 operations,All checks passed**。 |
|||
|
|||
本轮没有连接或修改服务器生产数据库,没有部署服务,没有修改迁移文件或 `docs/openapi-rc.json`。测试中 Redis 使用 mock,RabbitMQ listener 被关闭,因此本文不把本轮结论扩展为真实 Redis/RabbitMQ 黑盒验收。 |
|||
|
|||
## 2. Git 与工作区边界 |
|||
|
|||
- 分支:`master` |
|||
- HEAD:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3` |
|||
- upstream:`8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`;ahead/behind `0/0` |
|||
- 暂存区:空;`git diff --check`:exit 0(仅有 Git 的 LF/CRLF 提示) |
|||
- 本地工作区存在既有未暂存改动与历史未跟踪材料。没有清理、覆盖、暂存或删除这些文件。 |
|||
- Phase B 现有差异包含 **22 个已跟踪生产文件 + 新增 `VerifiedCompanyGuard.java`**,以及 **28 个已跟踪测试文件 + 4 个 Phase B 新测试类**。本报告和 Phase B 实施报告均未跟踪;本轮没有执行任何 Git 写入操作。 |
|||
- V1–V46 未修改;未新增 V47;`docs/openapi-rc.json` 未修改。 |
|||
- 未进入 Phase C。 |
|||
|
|||
## 3. 独立审查范围与规则 |
|||
|
|||
复核了 Phase B 实施报告、企业入驻设计及复审文档、`VerifiedCompanyGuard`、服务层调用点、岗位资源归属查询、错误码映射、认证边界测试,以及候选人/简历下载入口。 |
|||
|
|||
规则核对结果: |
|||
|
|||
1. 未认证、审核中、已拒绝企业均以 `companies.verified=false` 拒绝受限招聘业务;未使用 `users.is_verified` 代替企业认证。 |
|||
2. 认证门禁在 Service 业务路径执行,不只依赖 Controller;角色/企业归属/资源可见性仍单独保留。 |
|||
3. 企业资料与认证申请、入驻状态等允许操作未被本轮认证门禁一并封锁。 |
|||
4. 错误使用固定 `COMPANY_NOT_VERIFIED`(HTTP 403),经统一 `ErrorHttpStatusRegistry` 映射,不在 Controller 复制转换逻辑。 |
|||
5. 岗位访问必须按企业范围查找;对外企业岗位记录不存在与不属于当前企业均按相同 `JOB_NOT_FOUND` / 404 处理。 |
|||
|
|||
## 4. 复验阻断及修复 |
|||
|
|||
### 4.1 岗位资源边界与检查顺序 |
|||
|
|||
复验期间确认岗位接口需要同时满足两个条件:未认证企业发布岗位时应被认证门禁拒绝;已认证企业也不能借岗位 ID 探测或操作其他企业的岗位。已调整为: |
|||
|
|||
- 发布状态转换在读取目标岗位前先执行认证门禁,使未认证企业对目标 ID 不获得差异化响应; |
|||
- 已认证企业的岗位读取/更新使用 `findByIdAndCompanyId` 范围查询;跨企业与不存在资源统一返回 `JOB_NOT_FOUND` / 404; |
|||
- 企业自己的草稿维护及既有关闭行为按设计保留。 |
|||
|
|||
覆盖测试:`JobServiceTest`、`AuthenticationBoundaryIntegrationTest`,以及本报告新增的真实 MySQL + HTTP 黑盒。 |
|||
|
|||
### 4.2 持久化认证会话测试接线 |
|||
|
|||
原持久化测试通过静态 Token 测试助手建会话,不能确保会话写入当前 Testcontainers 数据源。测试现改为从 Spring 上下文调用 `AuthSessionService.createSession`,令令牌与当前测试数据库一致,避免测试仅凭伪造/错库 Token 得出授权结论。 |
|||
|
|||
## 5. 新增真实 MySQL + HTTP 黑盒 |
|||
|
|||
新增测试:`mydreamjob-server/src/test/java/com/mydreamjob/phaseb/VerifiedCompanyGateMySqlBlackboxTest.java`。 |
|||
|
|||
- Testcontainers 使用真实 `mysql:8.4`,创建隔离数据库 `phase_b_gate_blackbox`,正式执行 Flyway,并使用 Hibernate `ddl-auto=validate`。 |
|||
- Spring Boot 完整应用在随机端口启动;通过 HTTP socket 请求 `/api/v1/...`,覆盖 Spring Security、Controller、Service、Repository 和真实 MySQL。 |
|||
- 使用精确假号段夹具,测试结束按本类登记的主键清理;容器随后由 Testcontainers 回收。 |
|||
|
|||
结果 **2/2,F=0、E=0、S=0**: |
|||
|
|||
1. UNVERIFIED、PENDING、REJECTED 三种 `companies.verified=false` 企业对岗位发布均返回 403 与 `COMPANY_NOT_VERIFIED`。每个拒绝前后比较 15 张表的稳定行快照,并核对岗位仍为 DRAFT。 |
|||
2. 未认证企业读取候选人列表、候选人详情与简历下载均被拒绝。简历存储 `read()` 计数为 0;响应非 PDF;15 张表拒绝前后逐行快照相同。 |
|||
3. VERIFIED 企业能够发布自己的岗位;不能读取、更新或发布另一企业岗位。外企业资源与不存在资源均返回相同 404/`JOB_NOT_FOUND`,跨企业拒绝前后数据库快照无变化。 |
|||
4. 用户夹具的 `users.is_verified` 被有意设为 true,而公司认证状态分别按测试构造。这证明被测门禁取决于 `companies.verified`,而不是用户通用 verified 字段。 |
|||
|
|||
此黑盒未启动真实 Redis 或 RabbitMQ:RedisTemplate 为 mock,RabbitMQ listener 显式禁用。拒绝请求的数据库快照覆盖 `notifications`、`audit_logs`、`messages` 等表;但这不能替代 broker 队列深度检查,也不能证明真实 Redis 可用性。 |
|||
|
|||
## 6. 测试结果 |
|||
|
|||
| 验证 | 结果 | |
|||
|---|---:| |
|||
| Phase B 定向(门禁/角色/岗位/投递/简历/Offer/面试/邀约/消息等 12 类) | **208/208,F=0、E=0、S=0** | |
|||
| 真实 MySQL + HTTP 黑盒 | **2/2,F=0、E=0、S=0**(包含在定向结果内) | |
|||
| `mvn test` | **2884/2884,F=0、E=0、S=0** | |
|||
| `mvn clean verify` | **2884/2884,F=0、E=0、S=0** | |
|||
| 全量 Surefire XML | 277 个测试类;2884 项;F/E/S 均为 0 | |
|||
| `python ..\docs\verify_openapi.py` | **182 paths / 220 operations,All checks passed** | |
|||
|
|||
MySQL/Testcontainers 在本机 Docker 上真实运行;新增黑盒类标记 `disabledWithoutDocker=false`,不会在 Docker 缺失时静默跳过。全量测试 `Skipped=0`。 |
|||
|
|||
## 7. 测试驱动器自身问题记录 |
|||
|
|||
初次黑盒执行出现 HTTP 404,是 `TestRestTemplate` 测试 URL 使用相对路径造成的 harness 错误;随后改为以 `@LocalServerPort` 构造 `127.0.0.1` 绝对 URL,黑盒 **2/2** 通过。测试初次编译还因 `DirtiesContext` 导入包路径错误失败,改为 Spring Test 正确包后通过。两项均为测试代码接线问题,不是产品缺陷;修正后定向与全量测试均已重跑通过。 |
|||
|
|||
## 8. 运行环境收尾 |
|||
|
|||
检查时 Docker 容器列表为空;8080/8081 无监听;无残留 `java` 或 `mvn` 进程。黑盒使用随机端口,并未占用上述端口。临时 MySQL 为 Testcontainers 隔离容器,没有触及本机开发库或服务器库。 |
|||
|
|||
## 9. 未执行/不在本轮结论内 |
|||
|
|||
- 未做生产服务器部署或真实公网 MySQL/Redis/RabbitMQ 验收。 |
|||
- 未做运行时 OpenAPI 路径集合与静态 OpenAPI 的双集合比较;本轮运行的是仓库 OpenAPI 检查器,且 `docs/openapi-rc.json` 保持未改。 |
|||
- 未覆盖到的每一个招聘入口仍以既有 Phase B 结构/服务测试清单和全量回归为依据;本轮新增真实黑盒聚焦岗位发布、候选人列表/详情/简历,以及跨企业岗位访问,不能解读为每个业务端点都由真实 HTTP 重放。 |
|||
- 未实现 Phase C 管理员用户与角色管理;未修改短信服务;未伪造任何真实企业认证状态。 |
|||
|
|||
## 10. 最终判定 |
|||
|
|||
**Phase B 独立代码复验及真实 MySQL + HTTP 黑盒验收通过。** 两处复验阻断已修复;定向、全量测试及 OpenAPI 检查通过。当前仍是未暂存的本地工作区状态,后续如需提交,必须另行进行提交前精确文件范围审查;本轮没有暂存、提交、推送或部署。 |
|||
|
|||
## 11. 2026-09-29 精确暂存与隔离验证检查点 |
|||
|
|||
本节追加记录最新检查点,不覆盖第 1–10 节的历史执行结果与当时仓库状态。 |
|||
|
|||
### 11.1 批次边界与验证来源 |
|||
|
|||
- 当前分支为 `feat/phase-b-company-verification-gate`;基线 HEAD 为 `bcea6b02955f20f933572e8c8f2c85b1ad05843c`,包含此前单独本地提交的短信 P0 修复。 |
|||
- Phase B 暂存范围为 **57 个文件:23 个生产文件、32 个测试文件、2 份报告**,没有删除文件。未纳入迁移、`pom.xml`、OpenAPI 文件、基础设施脚本或历史验收输出。 |
|||
- 共用错误码与映射测试只暂存 Phase B 的 `COMPANY_NOT_VERIFIED` 及其 403 映射,未纳入统一注册/身份选择的新增错误码和接口。 |
|||
- 验证目录由 HEAD 归档加暂存补丁生成,不读取原工作区未暂存的统一注册/身份选择实现。57 个暂存文件与验证快照按 Git 换行规范化后的 blob 逐项比较,**不一致数为 0**。原工作区其他批次改动与历史未跟踪文件均保留。 |
|||
|
|||
### 11.2 本次失败记录及纠正 |
|||
|
|||
1. 初次 32 类组合运行 **928 项,F=1、E=0、S=0**。简历下载测试的 OpenAPI 数量期望误混入未提交身份选择批次的 183/221,而当前隔离快照实际为 182/220。已从暂存区移除这两行无关 hunk,使用 HEAD 的 182/220 契约;原工作区 183/221 改动仍保留。没有修改生产实现或放宽授权断言。随后 12 类定向及全量验证通过。 |
|||
2. 9 月 28 日首次全量尝试未完成,跨库测试报告 1 项容器启动错误,根因链包含 Docker 命名管道不存在(`docker_engine`)。该次没有 Maven 最终汇总,不记为通过,也不推算其全量测试总数。日志保留在系统临时验证目录,不纳入提交。Docker 恢复后确认服务版本 29.6.2,以原暂存代码快照重新执行完整 `clean verify`。 |
|||
|
|||
### 11.3 最新验证结果 |
|||
|
|||
| 验证 | 实测结果 | |
|||
|---|---:| |
|||
| Phase B 12 类定向测试 | **208 项,F=0、E=0、S=0,BUILD SUCCESS** | |
|||
| Phase B 真实 MySQL + HTTP 黑盒 | **2 项,F=0、E=0、S=0**(包含在定向与全量结果内) | |
|||
| 隔离暂存快照 `mvn clean verify` | **2889 项,F=0、E=0、S=0,BUILD SUCCESS** | |
|||
| 全量 Surefire XML 独立汇总 | **281 个测试类,2889 项,F/E/S 均为 0** | |
|||
| 短信错误次数真实 MySQL 并发测试 | **1 项,F=0、E=0、S=0**(基线短信批次回归) | |
|||
| 短信限流真实 Redis 测试 | **1 项,F=0、E=0、S=0**(基线短信批次回归) | |
|||
| 静态 OpenAPI 检查器 | **182 paths / 220 operations,All checks passed** | |
|||
| Spring 测试上下文运行时 OpenAPI 与静态集合比较 | **双方均 182/220,路径差集 0,路径/HTTP 方法 operation 差集 0** | |
|||
|
|||
本次全量命令使用 `-Ds03.openapi.snapshot=<临时输出目录>` 留存既有简历测试通过 MockMvc 获取的运行时文档;比较以解析后的路径与 HTTP 方法集合为准,不只比较数量。该证据来自完整 Spring 测试上下文,**不是独立 JAR 或公网部署版本的 OpenAPI 验收**。此次没有另行宣称一次新的 `mvn test` 结果;`clean verify` 已实际执行全量测试并打包成功。 |
|||
|
|||
### 11.4 暂存安全扫描与结论边界 |
|||
|
|||
暂存新增行扫描发现 2 处手机号形状的既有测试夹具字面量、1 处明确仅用于临时 Testcontainers 数据库的测试口令;均不是实际用户或服务器凭据。疑似绝对路径命中均为路由数字正则或 loopback 随机端口 HTTP URL,不是本机文件路径。未发现真实 JWT、Bearer、邮箱、BCrypt 摘要、私钥、AWS key 或服务器连接密钥;原始运行日志和容器输出不暂存。 |
|||
|
|||
**Phase B 精确暂存与隔离验证检查点通过。** 本节完成后仅更新并暂存本报告,尚未创建 Phase B 本地提交,未推送、未部署,未进入 Phase C。真实 MySQL HTTP 黑盒的 Redis mock 与禁用 RabbitMQ listener 边界仍如第 5 节所述;本次全量中的真实 Redis 测试不能替代 Phase B 全链路三中间件或公网验收。 |
|||
@ -0,0 +1,198 @@ |
|||
# 薪资计算器 API(N05) |
|||
|
|||
> Base URL:`/api/v1` |
|||
> 认证:`Authorization: Bearer <access-token>` |
|||
> 角色:当前登录用户必须具备 `STUDENT` 角色。 |
|||
|
|||
> 发布状态(2026-10-08):政策核验白名单默认为空;种子数据已入库不代表已获准发布。默认不提供城市参数或新计算,本人历史查询/删除仍保持原有角色及归属限制。 |
|||
|
|||
## 1. 能力边界 |
|||
|
|||
- 输入税前月薪,估算个人五险一金、大病固定费和当月个税后的到手工资。 |
|||
- 采用居民个人综合所得累计预扣法;`monthIndex` 表示当前工资口径下的累计计税月份数,而不是自然月编号。 |
|||
- 支持用户覆盖社保基数、公积金基数和个人比例。 |
|||
- 城市政策以地级行政区、直辖市和省直辖县级市为最小单元,不拆分到区县。 |
|||
- `dataLevel=CITY` 表示城市级参数;`dataLevel=PROVINCE_FALLBACK` 表示由于公开城市级资料不足而使用省级回退参数,结果精度较低。 |
|||
- 所有结果均为估算值,实际以工资单、当地政策和税务申报为准。 |
|||
|
|||
## 2. 查询城市列表 |
|||
|
|||
`GET /salary-calculator/cities` |
|||
|
|||
仅返回启用且“城市编码 + 当前政策版本”均命中发布白名单的摘要,按省份编码和城市编码排序。默认返回 `200`,`data=[]`。以下非空响应仅为契约示例,不代表这些政策已上线。 |
|||
|
|||
响应示例: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": [ |
|||
{ |
|||
"cityCode": "110000", |
|||
"provinceCode": "110000", |
|||
"provinceName": "北京市", |
|||
"cityName": "北京市", |
|||
"policyVersion": "2026-09-20-v1", |
|||
"policyEffectiveDate": "2026-07-01", |
|||
"dataLevel": "CITY" |
|||
} |
|||
] |
|||
} |
|||
``` |
|||
|
|||
## 3. 查询城市默认参数 |
|||
|
|||
`GET /salary-calculator/cities/{cityCode}` |
|||
|
|||
返回城市级或省级回退的当前默认参数。计算请求必须原样携带响应中的 `policyVersion` 作为 `expectedPolicyVersion`。 |
|||
|
|||
## 4. 计算并保存 |
|||
|
|||
`POST /salary-calculator/calculations` |
|||
|
|||
请求示例: |
|||
|
|||
```json |
|||
{ |
|||
"cityCode": "110000", |
|||
"grossMonthlySalary": 10000.00, |
|||
"monthIndex": 1, |
|||
"specialAdditionalDeduction": 0.00, |
|||
"expectedPolicyVersion": "2026-09-20-v1", |
|||
"idempotencyKey": "optional-client-key", |
|||
"overrides": { |
|||
"socialSecurityBase": 10000.00, |
|||
"housingFundBase": 10000.00, |
|||
"pensionRate": 0.08, |
|||
"medicalRate": 0.02, |
|||
"unemploymentRate": 0.005, |
|||
"housingFundRate": 0.12, |
|||
"seriousIllnessMonthlyFee": 0.00 |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- `cityCode`、`grossMonthlySalary`、`expectedPolicyVersion` 必填。 |
|||
- `monthIndex` 默认 1,范围 1–12。 |
|||
- `specialAdditionalDeduction` 默认 0。 |
|||
- `idempotencyKey` 可选,最长 64 字符;同用户同键同规范化请求返回原历史,同键不同请求返回 `409/10604`。 |
|||
- 覆盖基数必须位于对应城市范围内;比例为 0 时允许基数为 0。 |
|||
|
|||
成功响应: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"historyId": 123, |
|||
"createdAt": "2026-09-20T16:00:00", |
|||
"result": { |
|||
"cityCode": "110000", |
|||
"cityName": "北京市", |
|||
"dataLevel": "CITY", |
|||
"policyVersion": "2026-09-20-v1", |
|||
"policyEffectiveDate": "2026-07-01", |
|||
"taxRulesVersion": "CN_IIT_2019_V1", |
|||
"estimate": true, |
|||
"monthIndex": 1, |
|||
"grossMonthlySalary": 10000.00, |
|||
"socialSecurityBase": 10000.00, |
|||
"housingFundBase": 10000.00, |
|||
"pension": 800.00, |
|||
"medical": 200.00, |
|||
"unemployment": 50.00, |
|||
"housingFund": 1200.00, |
|||
"currentMonthTax": 82.50, |
|||
"netSalary": 7667.50 |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
## 5. 查询历史 |
|||
|
|||
`GET /salary-calculator/calculations?page=0&size=20` |
|||
|
|||
- 仅返回当前用户的记录。 |
|||
- 按创建时间倒序、历史 ID 倒序。 |
|||
- `size` 最大 100。 |
|||
|
|||
`GET /salary-calculator/calculations/{id}` |
|||
|
|||
返回输入、政策、覆盖项和结果三组完整 JSON 快照。访问他人历史统一返回 `404/10603`。 |
|||
|
|||
## 6. 删除历史 |
|||
|
|||
`DELETE /salary-calculator/calculations/{id}` |
|||
|
|||
物理删除本人历史;成功后再次查询返回 `404/10603`。删除后原幂等键可重新使用。 |
|||
|
|||
## 7. 错误码 |
|||
|
|||
| HTTP | code | 含义 | |
|||
|---|---|---| |
|||
| 400 | 10602 | 工资、比例、基数或分页参数非法 | |
|||
| 401 | 401 | 未登录或 Token 无效 | |
|||
| 403 | 2002 | 已登录但不是 `STUDENT` | |
|||
| 404 | 10601 | 城市不存在、停用、未配置或政策未发布 | |
|||
| 404 | 10603 | 历史不存在或不属于当前用户 | |
|||
| 409 | 10604 | 幂等键冲突 | |
|||
| 409 | 10605 | 城市政策版本已变化 | |
|||
|
|||
`10605` 响应会在 `data` 中返回最新 `policyVersion` 和 `policyEffectiveDate`,客户端应重新加载城市详情后再试算。 |
|||
|
|||
## 8. 计算口径 |
|||
|
|||
```text |
|||
社保基数 = 用户覆盖值,否则 min(max(税前月薪, 社保下限), 社保上限) |
|||
公积金基数 = 用户覆盖值,否则 min(max(税前月薪, 公积金下限), 公积金上限) |
|||
|
|||
累计收入 = 税前月薪 × monthIndex |
|||
累计基本减除费用 = 5000 × monthIndex |
|||
累计专项附加扣除 = 月度专项附加扣除 × monthIndex |
|||
累计应纳税所得额 = max(累计收入 - 累计个人社保 - 累计公积金 - 累计大病费 |
|||
- 累计基本减除费用 - 累计专项附加扣除, 0) |
|||
当月个税 = max(累计应纳税额 - 累计已预扣税额, 0) |
|||
到手工资 = max(税前月薪 - 个人养老 - 个人医疗 - 个人失业 - 个人公积金 |
|||
- 大病固定费 - 当月个税, 0) |
|||
``` |
|||
|
|||
## 9. 政策核验与发布门禁 |
|||
|
|||
### 默认关闭 |
|||
|
|||
`app.salary-publication.approved-policies` 是城市编码到已核验政策版本的精确映射,默认空映射。`enabled=true`、`dataLevel=CITY`、种子数量或功能测试通过,均不等于政策已核验。V53 保留原字节,不修改种子或已有历史快照,也不新增迁移。 |
|||
|
|||
### 白名单配置 |
|||
|
|||
仅在完成核验和发布审批后,由部署人员通过外部 Spring 配置提供映射。例如下列内容只展示配置格式,不可直接照抄到生产: |
|||
|
|||
```yaml |
|||
app: |
|||
salary-publication: |
|||
approved-policies: |
|||
"110000": "已核验记录对应的实际政策版本" |
|||
``` |
|||
|
|||
城市编码必须为六位数字,版本不能为空或包含首尾空白;非法配置启动失败。城市和数据库当前版本都必须精确匹配,版本变更后旧白名单不会自动批准新版本。不提供全量开放或按 CITY/PROVINCE_FALLBACK 批量开放的开关。 |
|||
|
|||
测试类显式配置的种子版本只用于算法、响应契约、并发与回滚验证,不可作为上线授权或政策准确性证据。生产默认配置没有任何批准项。 |
|||
|
|||
### 前端行为 |
|||
|
|||
- 城市列表默认 `HTTP 200 / code=200 / data=[]`;前端显示“薪资估算暂未开放”,不要自行回退到未经核验的本地参数。 |
|||
- 未发布城市的详情和新计算返回 `HTTP 404 / code=10601`,使用既有固定错误文案,不泄露政策参数。 |
|||
- 相同幂等键的历史重放也经过发布门禁;不能利用 POST 重放绕过关闭状态。 |
|||
- 只有已发布政策才会进入原有版本冲突检查;未发布时不返回最新政策版本信息。 |
|||
|
|||
### 历史兼容 |
|||
|
|||
本人历史列表、详情和删除接口不受发布白名单影响,继续执行 STUDENT 角色与记录归属检查。历史快照仅表示当时的估算结果,不证明其政策准确,不能将其当成当前政策使用。 |
|||
|
|||
### 核验记录要求 |
|||
|
|||
每个拟开放城市必须补齐官方来源地址、发布主体、适用地区、有效期、社保和公积金基数与比例、固定费用、核验人和核验日期,并记录与数据库 `policyVersion` 的对应关系。省级回退数据还需核实适用性并明确展示估算限制。更正已提交政策应使用后续新增迁移,不改写 V1–V53。 |
|||
|
|||
当前 359 条种子的政策准确性尚未由本次测试证明,因此本轮不添加任何生产批准项;N03/N04 不受此门禁影响。合并、部署及部署后三中间件黑盒验收另行执行。 |
|||
@ -0,0 +1,275 @@ |
|||
# 行业技能词云、个人匹配度与学习路径 API |
|||
|
|||
> 版本:N03 设计锁定版 |
|||
> 状态:已编码实现 |
|||
> 适用范围:学生端只读接口。本文描述 `GET /api/v1/skills/word-cloud`、`GET /api/v1/skills/match`、`GET /api/v1/skills/learning-path`。 |
|||
> Base URL:`http://127.0.0.1:8080/api/v1`(生产环境替换域名,路径不变)。 |
|||
|
|||
## 1. 权限与共同参数 |
|||
|
|||
三个接口均: |
|||
|
|||
- 仅允许 `STUDENT` 访问。 |
|||
- 必须携带 `Authorization: Bearer <accessToken>`,身份只从 JWT/SecurityContext 获取,不接受 `userId`、`studentId` 等身份注入参数。 |
|||
- 成功、`400` 和 `403` 响应使用统一 `ApiResponse`;匿名或无效 JWT 的 `401` 由全局 Security 入口直接返回裸 JSON,不包含 `data` 和 `requestId`。 |
|||
- 只读,不写入学习进度、学生资料或词库数据。 |
|||
|
|||
查询参数: |
|||
|
|||
| 参数 | 类型 | 必填 | 说明 | |
|||
|---|---|---|---| |
|||
| `industryId` | long | 是 | 词库行业 ID,必须为正整数,且必须是真实存在的行业。 | |
|||
| `occupationId` | long | 是 | 词库职位 ID,必须为正整数,且必须属于该行业。 | |
|||
| `categoryId` | long | 否 | 技能分类 ID,必须为正整数;省略时使用该职位下全部技能分类。传入时必须属于目标职位。 | |
|||
|
|||
任一必填参数缺失、非正整数,或行业—职位—分类关系非法时返回 `400`。如果未登录返回 `401`,非 STUDENT 用户返回 `403`。 |
|||
|
|||
## 2. 通用响应与排序 |
|||
|
|||
三个接口的 `data` 都使用 camelCase 字段。词云和 TOP10 使用同一份服务端排序结果: |
|||
|
|||
1. `heat` 从高到低;`heat` 为 `null` 的技能排在所有非空热度之后。 |
|||
2. 热度相同时按 `nameZh` 升序。 |
|||
3. 其余情况按 `skillId` 升序,保证重复请求顺序稳定。 |
|||
4. `rank` 从 1 连续递增。 |
|||
|
|||
`heat` 来自 O*NET Importance。无可靠 O*NET Importance 映射的 ESCO 技能仍返回,但 `heat` 为 `null`。TOP10 不在后端单独存储或单独排序;前端直接取词云 `data.items` 的前 `min(10, data.total)` 项。 |
|||
|
|||
技能身份来源: |
|||
|
|||
- `sourceType`:`ESCO` 或 `O*NET`,表示技能条目的主来源。 |
|||
- `sourceCode`:可追溯来源标识。ESCO 为技能 URI;O*NET 补充技能为 `SOC|Workplace Example|Element ID`。 |
|||
- `heatSourceCode`:热度来源 SOC;没有 O*NET 热度时为 `null`。 |
|||
- `heatElementId`:O*NET Importance 的来源 Element ID;没有时为 `null`。 |
|||
- `heatProxy`:该条热度是否为代理映射。代理值不改变排序规则,但前端可据此展示来源说明。 |
|||
|
|||
## 3. 查询词云 |
|||
|
|||
```http |
|||
GET /api/v1/skills/word-cloud?industryId=1&occupationId=2&categoryId=101 |
|||
Authorization: Bearer <accessToken> |
|||
``` |
|||
|
|||
`categoryId` 可省略: |
|||
|
|||
```http |
|||
GET /api/v1/skills/word-cloud?industryId=1&occupationId=2 |
|||
``` |
|||
|
|||
成功响应为 `ApiResponse<SkillCloudResponse>`。`SkillCloudResponse` 返回筛选节点、总数和当前范围内的完整技能列表: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"industry": { "id": 1, "code": "INTERNET", "name": "互联网" }, |
|||
"occupation": { "id": 1, "code": "FRONTEND_DEVELOPER", "name": "前端开发工程师" }, |
|||
"category": { "id": 103, "code": "FRONTEND_FRAMEWORK", "name": "前端框架" }, |
|||
"total": 1, |
|||
"items": [ |
|||
{ |
|||
"rank": 1, |
|||
"skillId": 26, |
|||
"skillCode": "REACT", |
|||
"nameZh": "React", |
|||
"nameEn": "React", |
|||
"sourceType": "O*NET", |
|||
"sourceCode": "15-1254.00|React|2.E.7.h", |
|||
"categoryId": 103, |
|||
"categoryName": "前端框架", |
|||
"heat": 4.38, |
|||
"heatSourceCode": "15-1254.00", |
|||
"heatElementId": "2.E.7.h", |
|||
"heatProxy": true |
|||
} |
|||
] |
|||
}, |
|||
"requestId": "req-123", |
|||
"timestamp": "2026-09-15T10:00:00Z" |
|||
} |
|||
``` |
|||
|
|||
`category` 在未传 `categoryId` 时为 `null`。合法范围但暂时没有技能时返回成功、`total=0`、`items=[]`。前端根据 `items` 渲染词云,并截取前十条作为 TOP10。 |
|||
|
|||
## 4. 查询个人技能匹配度 |
|||
|
|||
```http |
|||
GET /api/v1/skills/match?industryId=1&occupationId=2&categoryId=101 |
|||
Authorization: Bearer <accessToken> |
|||
``` |
|||
|
|||
匹配集合严格取当前筛选结果的 TOP10。系统从当前 JWT 学生的 `student_profiles.skills_json` 读取技能,使用标准技能名、英文名、技能代码和别名归一化匹配。大小为 `total` 的筛选范围使用 `min(10, total)` 作为分母: |
|||
|
|||
```text |
|||
matchScore = round(matchedCount * 100 / min(10, total)) |
|||
``` |
|||
|
|||
- `total=0` 时 `matchScore=0`,命中列表和缺失列表为空。 |
|||
- 范围少于 10 项时按实际技能数计算。 |
|||
- 重复技能或指向同一标准技能的多个别名只计一次。 |
|||
- 示例:范围 15 条、命中 5 条,`matchScore=33`。 |
|||
- 原始 `skills_json`、手机号、邮箱和简历内容不会出现在响应中。 |
|||
|
|||
成功响应为 `ApiResponse<SkillMatchResponse>`: |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"matchScore": 50, |
|||
"matchedCount": 1, |
|||
"totalCount": 2, |
|||
"matchedSkills": [ |
|||
{ |
|||
"rank": 1, |
|||
"skillId": 26, |
|||
"skillCode": "REACT", |
|||
"nameZh": "React", |
|||
"nameEn": "React", |
|||
"sourceType": "O*NET", |
|||
"sourceCode": "15-1254.00|React|2.E.7.h", |
|||
"categoryId": 103, |
|||
"categoryName": "前端框架", |
|||
"heat": 4.38, |
|||
"heatSourceCode": "15-1254.00", |
|||
"heatElementId": "2.E.7.h", |
|||
"heatProxy": true |
|||
} |
|||
], |
|||
"missingSkills": [ |
|||
{ |
|||
"rank": 2, |
|||
"skillId": 31, |
|||
"skillCode": "DOCKER", |
|||
"nameZh": "Docker", |
|||
"nameEn": "Docker", |
|||
"sourceType": "O*NET", |
|||
"sourceCode": "15-1299.08|Docker|2.E.7.h", |
|||
"categoryId": 105, |
|||
"categoryName": "工程化工具", |
|||
"heat": 4.12, |
|||
"heatSourceCode": "15-1299.08", |
|||
"heatElementId": "2.E.7.h", |
|||
"heatProxy": true |
|||
} |
|||
] |
|||
}, |
|||
"requestId": "req-456", |
|||
"timestamp": "2026-09-15T10:01:00Z" |
|||
} |
|||
``` |
|||
|
|||
`matchedSkills` 与 `missingSkills` 的并集等于当前 TOP10,两个列表各自保持词云排序;`totalCount` 是实际匹配集合大小。 |
|||
|
|||
## 5. 生成学习路径 |
|||
|
|||
```http |
|||
GET /api/v1/skills/learning-path?industryId=1&occupationId=2&categoryId=101 |
|||
Authorization: Bearer <accessToken> |
|||
``` |
|||
|
|||
学习路径只消费个人匹配结果中的 `missingSkills`: |
|||
|
|||
- 只包含当前 TOP10 中未命中的技能,不包含已命中技能或 TOP10 之外的技能。 |
|||
- 顺序与 `missingSkills` 完全一致,`priority` 从 1 连续递增。 |
|||
- 每项只返回技能 ID、名称、分类、优先级和简化学习建议。 |
|||
- 后端不调用 AI、课程平台或外部知识库;不会返回课程链接、讲师、价格、学习时长或完成度。 |
|||
- 系统不持久化学习进度或完成状态。 |
|||
- 全部命中或合法范围没有技能时返回 `total=0`、`items=[]`。 |
|||
|
|||
建议模板按技能分类确定性生成: |
|||
|
|||
| 模板类别 | 建议方向 | |
|||
|---|---| |
|||
| 编程语言 | 基础语法、常见数据结构、至少一个编码练习。 | |
|||
| 框架 | 核心概念与生命周期、小型项目集成实践。 | |
|||
| 工具 | 安装与基础配置、结合工程完成实际操作。 | |
|||
| 通用 | 核心概念和常见应用场景、简短实践巩固。 | |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"total": 1, |
|||
"items": [ |
|||
{ |
|||
"priority": 1, |
|||
"skillId": 26, |
|||
"skillName": "React", |
|||
"categoryId": 103, |
|||
"categoryName": "前端框架", |
|||
"suggestion": "先理解 React 的核心概念与生命周期,再通过一个小型项目练习集成。" |
|||
} |
|||
] |
|||
}, |
|||
"requestId": "req-789", |
|||
"timestamp": "2026-09-15T10:02:00Z" |
|||
} |
|||
``` |
|||
|
|||
## 6. 错误响应示例 |
|||
|
|||
`400` 和 `403` 使用统一 `ApiResponse`;`401` 由全局 Security 入口返回裸 JSON。以下示例与运行时契约一致。 |
|||
|
|||
```json |
|||
{ |
|||
"code": 1001, |
|||
"message": "bad request", |
|||
"data": null, |
|||
"requestId": "req-400", |
|||
"timestamp": "2026-09-15T10:03:00Z" |
|||
} |
|||
``` |
|||
|
|||
| HTTP | 场景 | 典型 `code` | |
|||
|---|---|---| |
|||
| `400` | `industryId`/`occupationId` 缺失、非正整数,或节点关系非法 | `1001` | |
|||
| `401` | 未携带有效 JWT 或令牌无效/过期 | `401` | |
|||
| `403` | 已登录但不是 STUDENT | `2002` | |
|||
|
|||
示例 401: |
|||
|
|||
```json |
|||
{ |
|||
"code": 401, |
|||
"message": "not authenticated", |
|||
"timestamp": "" |
|||
} |
|||
``` |
|||
|
|||
`401` 响应不包含 `data` 和 `requestId`。 |
|||
|
|||
示例 403: |
|||
|
|||
```json |
|||
{ |
|||
"code": 2002, |
|||
"message": "forbidden", |
|||
"data": null, |
|||
"requestId": "req-403", |
|||
"timestamp": "2026-09-15T10:05:00Z" |
|||
} |
|||
``` |
|||
|
|||
## 7. 字段参考 |
|||
|
|||
### SkillCloudNode |
|||
|
|||
`id`、`code`、`name`。`category` 节点在未指定分类时为 `null`。 |
|||
|
|||
### SkillCloudItem |
|||
|
|||
`rank`、`skillId`、`skillCode`、`nameZh`、`nameEn`、`sourceType`、`sourceCode`、`categoryId`、`categoryName`、`heat`、`heatSourceCode`、`heatElementId`、`heatProxy`。 |
|||
|
|||
### SkillMatchResponse |
|||
|
|||
`matchScore`、`matchedCount`、`totalCount`、`matchedSkills`、`missingSkills`。 |
|||
|
|||
### SkillLearningPathItem / SkillLearningPathResponse |
|||
|
|||
`SkillLearningPathItem`:`priority`、`skillId`、`skillName`、`categoryId`、`categoryName`、`suggestion`。 |
|||
|
|||
`SkillLearningPathResponse`:`total`、`items`。 |
|||
@ -0,0 +1,79 @@ |
|||
# 行业技能词库来源与覆盖说明 |
|||
|
|||
本说明是仓库面向实现和评审的技能词库来源基线,内容与 `changes/n03-skill-cloud/data/source-manifest.md` 保持同一事实集合。 |
|||
|
|||
## 1. 固定来源 |
|||
|
|||
| 来源 | 固定版本 | 用途 | 许可/复用条件 | 官方地址与归属 | |
|||
| --- | --- | --- | --- | --- | |
|||
| ESCO | ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0) | 提供行业/职位映射所需的职业—技能—技能分类主关系、稳定来源 ID 和层级结构 | 依据 Commission Decision 2011/833/EU,可免费下载、使用、复制和复用。文档和产品界面须保留官方署名语句:`This service uses the ESCO classification of the European Commission.` | 数据集下载页:https://esco.ec.europa.eu/en/use-esco/download<br>复用条件说明:https://esco.ec.europa.eu/en/about-esco/faq | |
|||
| O*NET | O*NET 31.0 Database | 为 ESCO 未收录的具体技术提供 `software_skills` 补充来源,并通过职业/工作活动 Importance 提供首版词云热度 `heat` | License 为 CC BY 4.0,归属与许可说明见 https://creativecommons.org/licenses/by/4.0/ | 官方数据库页:https://www.onetcenter.org/database.html | |
|||
|
|||
运行时不得访问 ESCO、O*NET 或任何在线词库服务。所有首版数据必须随 Flyway 迁移进入本地数据库,并保留来源版本与归属信息。 |
|||
|
|||
## 2. 首版覆盖边界 |
|||
|
|||
首版只覆盖互联网、金融、教育三类行业及下列 10 个已列代表性职位。该矩阵定义导入上限和最小覆盖范围,不要求在全量上游数据中导入未列出的职位。 |
|||
|
|||
| 行业 | 代表性职位 | 首批技能分类范围 | |
|||
| --- | --- | --- | |
|||
| 互联网 | 前端开发工程师 | 编程语言、Web 基础、前端框架、工程化工具、浏览器与调试、测试、版本控制与协作 | |
|||
| 互联网 | 后端开发工程师 | 编程语言、后端框架与平台、数据库、缓存与消息、云/DevOps、测试、工程化工具、安全 | |
|||
| 互联网 | 测试工程师 | 编程语言、测试设计、自动化测试框架、接口与数据库测试、性能测试、CI/CD 与工程化工具 | |
|||
| 互联网 | DevOps 工程师 | 编程语言与脚本、云平台、容器与编排、CI/CD、配置管理与 IaC、可观测性、安全、数据库 | |
|||
| 金融 | 金融数据分析师 | 编程语言、数据分析与可视化、数据库与数据仓库、统计与建模、金融计算框架/平台、金融领域知识、工程化工具 | |
|||
| 金融 | 风控建模工程师 | 编程语言、统计与建模、风险模型、建模框架/平台、数据处理与数据库、模型验证、部署与工程化工具 | |
|||
| 金融 | 量化开发工程师 | 编程语言、量化建模框架/平台、市场与金融数据、数据库、低延迟与高性能工程、测试、云/DevOps | |
|||
| 教育 | 教育产品经理 | 教育领域知识、产品分析方法、需求与流程、数据分析、教育平台与工具、协作与工程化工具 | |
|||
| 教育 | 教学设计师 | 教学设计、课程与学习平台、数字化内容工具、评估设计、学习数据分析、无障碍与规范 | |
|||
| 教育 | 教育数据分析师 | 编程语言、数据分析与可视化、数据库与 SQL、统计与建模、教育领域知识、报表与工程化工具 | |
|||
计数说明:任务给出的逐项职位清单为互联网 4 个、金融 3 个、教育 3 个,共 10 个。验收文字中的“11 个职位”与该枚举不一致;本清单严格覆盖逐项列出的 10 个职位,不自行新增未批准职位。 |
|||
|
|||
矩阵中的分类是每个职位首批应覆盖的能力域;T3 只导入这些分类下已确定稳定来源 ID、标准技能和中文标准名的技能,不在此文档中展开具体 `INSERT` 行。 |
|||
|
|||
## 3. 导入规则 |
|||
|
|||
1. **职责分工**:T2 只建立七张词库表、外键、唯一约束和查询索引;T3 只导入上述三类行业、10 个已列代表性职位及其关联技能分类、技能、别名和 O*NET Importance。 |
|||
2. **代表性子集与混合来源**:不导入 ESCO/O*NET 全量数据;ESCO 是职位、通用技能和分类主表;ESCO 无可靠节点的具体技术使用 O*NET `software_skills` 补充;不把平台自由文本岗位或行业直接当作标准词库节点。 |
|||
3. **来源 ID 不变**:ESCO/O*NET 来源 ID 原样保存在稳定来源字段中;项目不得重编号、覆盖或伪造上游 ID。 |
|||
4. **中文标准名必填**:每个入选技能必须有非空 `name_zh` 中文标准名;中文名称由项目维护,但不替代或修改上游来源 ID。 |
|||
5. **热度来源**:每条非空 Importance 必须保存 O*NET SOC、Element ID 和代理标记;技能无法映射 O*NET Importance 时必须保留,写入 `importance=null`;查询时映射为 `heat=null`,并排在有热度技能之后。 |
|||
6. **项目映射责任**:项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。行业/职位映射、细粒度分类和跨源技能对应关系均作为项目适配层记录。 |
|||
7. **别名归一化**:生成 `normalized_alias` 时依次执行 Unicode NFKC、首尾空白移除、大小写折叠、连字符转空格、连续空白折叠为单空格、句点移除;`+` 和 `#` 必须保留,不能删除或替换。 |
|||
8. **来源可追溯**:来源清单写入 `skill_source_versions`,至少记录来源名称、固定版本、许可、归属 URL 和导入时间;O*NET 补充技术的 `source_code` 使用真实 `SOC|Workplace Example|Element ID` 组合,职位技能关系记录 O*NET SOC、Element ID、代理标记及关联的来源版本。 |
|||
9. **迁移不可改写**:T2/T3 计划使用 `V47__skill_cloud_taxonomy.sql` 和 `V48__skill_cloud_seed.sql`。执行前若编号已被占用,必须回退规划并刷新编号;后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或任何已执行迁移。 |
|||
10. **运行时离线**:接口运行时不得调用外部词库、AI、课程平台或同步服务;排名只读取本地 Flyway 数据。 |
|||
|
|||
## 4. 来源版本、许可与归属 URL |
|||
|
|||
| 数据落点 | 来源名称 | 版本字段固定值 | 许可字段固定值 | 归属 URL | |
|||
| --- | --- | --- | --- | --- | |
|||
| 职业、技能、技能分类及关系 | ESCO | `1.2.1 (Release v5.7.0, Last update 10/12/2025)` | `Commission Decision 2011/833/EU` | https://esco.ec.europa.eu/en/use-esco/download | |
|||
| 职位技能 Importance | O*NET | `31.0 Database` | `CC BY 4.0` | https://www.onetcenter.org/database.html | |
|||
|
|||
ESCO 产品署名必须使用:`This service uses the ESCO classification of the European Commission.` |
|||
|
|||
## 5. 映射责任边界 |
|||
|
|||
| 事项 | 上游来源责任 | 项目责任 | |
|||
| --- | --- | --- | |
|||
| ESCO 职业、技能、分类层级和来源 ID | 固定且可追溯 | 原样保存,不修改 ID | |
|||
| O*NET Importance 数值 | 固定且可追溯 | 原样保存;无映射时写 `null`,不造权重 | |
|||
| 中文名称和别名 | 不保证完整中文覆盖 | 补齐中文标准名、别名及归一化值 | |
|||
| 细粒度技能分类 | 不保证符合本项目展示粒度 | 在职位下维护分类和展示名称 | |
|||
| 行业/职位映射 | 不保证覆盖三类项目行业和中文职位名 | 维护行业—职位—分类—技能映射 | |
|||
| 跨源技能与重要性关联 | 不提供项目所需的稳定本地关系 | 维护 ESCO 技能到 O*NET `software_skills` 的补充映射,以及每条 Importance 的 SOC/Element ID/代理标记 | |
|||
|
|||
## 6. 验收检查项 |
|||
|
|||
- AC-1:`changes/n03-skill-cloud/data/source-manifest.md` 与 `docs/skill-taxonomy-sources.md` 均存在且非空,来源版本、许可、归属 URL、覆盖矩阵和导入规则一致。 |
|||
- AC-2:文档明确覆盖互联网、金融、教育三类行业及全部 10 个已列代表性职位,并为每个职位给出首批技能分类范围。 |
|||
- AC-3:文档明确固定 ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0)和 O*NET 31.0 Database。 |
|||
- AC-4:文档明确 ESCO 依据 Commission Decision 2011/833/EU 可免费下载、使用、复制和复用,并原样保留指定署名语句。 |
|||
- AC-5:文档明确 O*NET License 为 CC BY 4.0,并记录官方数据库页与许可 URL。 |
|||
- AC-6:文档明确项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。 |
|||
- AC-7:文档明确 T2 建结构、T3 导数据,只导入代表性职位和关联技能;技能中文标准名必填。 |
|||
- AC-8:文档明确 O*NET 补充技术、每条 Importance 的 SOC/Element ID/代理标记,以及无映射时保留技能并写入 `importance=null`、查询侧映射为 `heat=null`。 |
|||
- AC-9:文档明确别名归一化遵循 NFKC、首尾空白、大小写、连字符、连续空白、句点规则,且保留 `+`、`#`。 |
|||
- AC-10:文档明确后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或已执行迁移。 |
|||
- AC-11:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。 |
|||
@ -0,0 +1,49 @@ |
|||
# 统一手机号注册与首次身份选择设计 |
|||
|
|||
## 范围与基线 |
|||
|
|||
本设计面向后端实施、独立复验和前端联调。采用一次性身份选择,不实现双身份切换、管理员公开注册、历史账号转身份或线上部署。 |
|||
开工分支 master,HEAD 8c9429108cbd5d85e5245f6c404b2172bf6dd7f3。暂存区空;已有 Phase B、验证码 P0、岗位等未提交变更及 290 个历史未跟踪条目须保留。 |
|||
|
|||
## 权威库存 |
|||
|
|||
- AuthService.mockLogin 对新手机号显式创建 STUDENT;密码登录只登录既有账号。 |
|||
- User 主角色为 VARCHAR(30),user_roles 为关联表,无数据库枚举限制。最新迁移 V46,无新增字段需求。 |
|||
- JwtAuthenticationFilter 只建立已认证主体,业务角色由 Service 判定;存在仅要求认证的业务接口,需要入口白名单。 |
|||
- AuthSessionService.createSession/revokeAllUserSessions 使用 REQUIRED;刷新持锁顺序 users → auth_tokens,身份选择沿用该顺序。 |
|||
- AuthTokenRepository 的 modifying 操作 flush 并清空 persistence context;必须先 flush 角色变化,再撤销/创建会话。 |
|||
- 企业创建资料约束复用 CompanyRegistrationRequest.CompanyDetails;企业注册开关只接受字面量 true。 |
|||
- 现有 /auth/company/register 保留原契约,不新增第二套角色或企业认证状态。 |
|||
|
|||
## 状态机和兼容 |
|||
|
|||
新手机号通过 login 用途短信验证 → UNASSIGNED 主角色、空业务 roles → 一次选择 STUDENT 或 COMPANY。 |
|||
COMPANY 选择同时创建 verified=false 的企业资料,后续沿用 Phase A 入驻与 Phase B 招聘认证门禁。 |
|||
旧三种角色不迁移。实体/数据库 STUDENT 默认值暂保留用于既有内部创建点兼容,统一注册路径显式赋值,不依赖默认授权。 |
|||
未知/矛盾主角色和 roles 组合不得通过身份选择;UNASSIGNED 即使被误写业务 roles 也应被公共角色 guard 拒绝。 |
|||
|
|||
## 接口 |
|||
|
|||
- /auth/login、/auth/password/login 和 /auth/me 增加 identitySelectionRequired(主角色派生),保留其他字段和成功码。 |
|||
- POST /auth/identity/select:identity 仅 STUDENT/COMPANY;COMPANY 必须提供 company,STUDENT 禁止提供 company;身份来自当前会话,客户端不能指定 userId/role/verified。 |
|||
- 首次选择返回强类型响应:userId、role、identitySelectionRequired=false、companyId、onboardingStatus、新 Token 对及 expiresIn。 |
|||
- 有效会话重复相同身份不修改资料或审计,返回状态和 reauthenticationRequired=true,不再次颁发凭证。失效旧会话返回 401,重新登录可恢复。 |
|||
- 已选其他身份 409;企业开关关闭 503;未选择身份访问受限业务 403;非法请求 400。 |
|||
|
|||
## 白名单 |
|||
|
|||
待选身份仅允许 GET /auth/me、POST /auth/identity/select、POST /auth/logout、POST /auth/password/set/change,及既有匿名认证入口 /auth/login、/auth/password/login、/auth/password/reset、/auth/refresh、/auth/sms/send、/auth/company/register。 |
|||
公开只读保持允许:GET /、/health(/子路径)、Swagger/OpenAPI、岗位公开列表/搜索/筛选/对比/评价、企业公开主页/岗位、岗位推荐分类字典。业务端口 actuator 路径保留原 404。 |
|||
其余已登录业务统一拒绝(含资料、认证、投递、候选人、Offer、面试、消息、测评、通知、导出和管理员)。方法与路径同时匹配,不用 /auth/** 放行。 |
|||
白名单检查由 IdentityAccessFilter 置于 JWT 验证之后的安全过滤链,在 MVC 路径/请求格式匹配之前执行,读取数据库当前身份;未知接口默认拒绝。通过禁用容器自动注册避免重复执行。公共角色 guard 同时拒绝 UNASSIGNED,避免服务调用用矛盾 roles 绕过。 |
|||
|
|||
## 原子事务与竞争 |
|||
|
|||
users 行锁 → 重新验证当前会话与账号注销状态 → 校验身份/资料/开关 → 更新角色并 flush → 建企业并 flush → 撤销全部旧会话 → 创建新会话 → 必需审计。任一失败回滚全部数据库更改,不返回 Token。 |
|||
验证码错误计数独立事务及 Redis 限流不纳入该回滚范围,不改变既有 P0。 |
|||
并发不同选择只允许一种提交,使用行锁后重新验证会话,防止认证过滤器校验与事务执行间的竞争。与刷新采用同一锁顺序;选择之前的会话在提交后失效。 |
|||
|
|||
## 验收 |
|||
|
|||
先保存生产修改前的失败证据,覆盖统一登录、身份选择、角色组合、企业开关、会话撤销、事务回滚和真实 MySQL 竞争。旧角色测试保留;仅更新明确依赖新手机号默认 STUDENT 的契约断言,不放宽授权断言。 |
|||
H2 定向、认证关联、真实 MySQL、全量 test/clean verify、静态与运行时 OpenAPI 对齐后进入独立复验。真实短信未接通时明确受控 Mock,不冒充短信送达。 |
|||
@ -0,0 +1,159 @@ |
|||
# 统一手机号账号注册与首次身份选择实施报告 |
|||
|
|||
## 当前状态 |
|||
|
|||
统一手机号账号注册与首次身份选择本地实施完成,待独立代码复验。 |
|||
最终全量验证与独立 JVM 最新 JAR 黑盒均通过。没有暂存、提交、推送或部署。 |
|||
|
|||
## 范围与基线 |
|||
|
|||
项目为职来后端(项目目录 `zhilai`)。分支 master;开工 HEAD 为 `8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`。 |
|||
开工已存在 Phase B、验证码 P0、岗位等未提交改动,以及 290 个历史未跟踪条目。本功能叠加于工作区,不能把全部 diff 当成本轮改动。 |
|||
最新迁移 V46;角色字段是 VARCHAR,无需新增迁移。不实现双身份切换、历史账号转角色、公开管理员注册或 Phase C。 |
|||
|
|||
## 权威库存 |
|||
|
|||
原 `AuthService.mockLogin` 为新手机号自动创建 STUDENT;密码登录不负责注册。 |
|||
`users.role` 与 `user_roles` 都被生产代码读取;JWT 认证以服务端有效会话为准,业务角色由 Service 校验。 |
|||
`AuthSessionService` 的会话创建/撤销参与调用方事务;刷新按 users → auth_tokens 顺序加锁。 |
|||
撤销 Repository modifying 操作会清空 persistence context,身份更新必须先 flush。 |
|||
企业资料复用既有 `CompanyDetails` 的 Validation 约束,开关复用只接受字面量 true 的企业注册配置。 |
|||
|
|||
## 新注册与旧账号兼容 |
|||
|
|||
新手机号仍通过 `POST /auth/login` 验证 login 用途短信,创建 UNASSIGNED 主角色和空业务 roles。 |
|||
登录、密码登录和 `/auth/me` 都新增派生字段 `identitySelectionRequired`,成功业务码仍为 200;旧字段不删除。 |
|||
既有 STUDENT、COMPANY、ADMIN 不重置、不迁移。实体和数据库的 STUDENT 默认值保留以兼容其他明确的内部创建点,统一注册路径显式覆盖为 UNASSIGNED。 |
|||
原 `/auth/company/register` 保留为兼容入口,并不强迫旧客户端立即改造。 |
|||
|
|||
## 首次身份选择接口 |
|||
|
|||
`POST /auth/identity/select` 必须使用有效 Access Token;身份和 sessionId 取自当前已验证会话。 |
|||
|
|||
```json |
|||
{"identity":"STUDENT"} |
|||
``` |
|||
|
|||
```json |
|||
{"identity":"COMPANY","company":{"name":"示例企业","industry":"互联网"}} |
|||
``` |
|||
|
|||
identity 仅允许 STUDENT/COMPANY;COMPANY 必须提供 company,STUDENT 禁止提供 company。 |
|||
不接受客户端 userId、role、verified 或其他未知字段;嵌套企业资料也不允许非法授权字段。 |
|||
首次成功返回新 Token 对、userId、role、identitySelectionRequired=false;企业额外返回 companyId 和 UNVERIFIED。 |
|||
同身份重复请求只返回当前状态、reauthenticationRequired=true,不重复发 Token、修改资料或追加审计。 |
|||
原会话失效后重复使用旧 Token 返回 401;客户端应采用新凭证,丢失响应时通过原登录方式重新登录。 |
|||
已经选择另一种身份的账号返回 409,不能用该接口切换身份。 |
|||
|
|||
## 待选身份访问门禁 |
|||
|
|||
`IdentityAccessFilter` 只作用于 JWT 过滤器建立的已验证会话主体,在 JWT 之后、MVC 路由和参数解析之前执行。 |
|||
它读取数据库当前身份,使用精确的 HTTP 方法/路径白名单,未知业务入口默认拒绝。 |
|||
白名单包含身份查询/选择、登出、密码维护和原匿名认证入口,以及既有公开只读页面。 |
|||
企业资料、认证申请、测评、消息、投递、候选人、岗位写操作和管理员接口不对 UNASSIGNED 放行。 |
|||
容器自动注册被禁用,避免过滤器在 JWT 之前执行或重复执行。 |
|||
公共 `UserRoleGuard.requireRole` 额外拒绝 UNASSIGNED,即使 user_roles 被误写成业务角色也不能通过该 guard。 |
|||
旧 Controller 接线探针使用的非 JWT 测试主体不新增解析调用;生产仍只有 JWT 认证方式,未启用 Basic/Form 登录。 |
|||
|
|||
## 事务与锁顺序 |
|||
|
|||
1. Service 层再次验证 DTO。 |
|||
2. 锁定 users,重新核验 active、当前会话和注销状态。 |
|||
3. 确认主角色/roles 无矛盾、没有孤立企业资料;校验企业开关。 |
|||
4. 更新 users.role 与唯一业务 role,saveAndFlush。 |
|||
5. COMPANY 创建唯一 verified=false 企业资料并 flush。 |
|||
6. 撤销全部旧会话,创建带新角色的新会话。 |
|||
7. 同事务执行 MANDATORY 必需审计,固定事件 IDENTITY_SELECTED,明细只含结果。 |
|||
|
|||
任何异常回滚角色、企业和 Token 更改;不返回未提交的凭证。锁顺序与刷新一致,不增加跨请求缓存。 |
|||
注销 PENDING/COMPLETED 拒绝选择,取消后的 NONE 可继续;禁用账号在锁内再次拒绝。 |
|||
原验证码原子消费、独立错误计数事务和 Redis 限流不被修改,也不虚称这些 Redis 状态会自动回滚。 |
|||
|
|||
## 错误响应 |
|||
|
|||
新增唯一数字码:10401 身份待选/403,10402 身份冲突/409,10403 身份选择依赖不可用/503。 |
|||
非法 JSON/字段/参数为原 400;匿名、失效会话和非法 Token 为原 401;企业注册开关关闭沿用原 503。 |
|||
没有修改任何历史错误码数字;历史 9103 重码事实仍保留。 |
|||
历史映射快照资源未改,只在契约测试中单独登记三个新增映射和相应数量。 |
|||
|
|||
## 修复前证据 |
|||
|
|||
生产修改前新增 6 个 HTTP 测试:6 run / 6 failures / 0 errors / 0 skipped。 |
|||
明确复现:新账号实际为 STUDENT;待选业务请求实际 200;身份选择路径不存在。 |
|||
失败证据在忽略目录 `docs/acceptance/unified-identity/`,不会进入提交。 |
|||
|
|||
## 实施中发现与修正 |
|||
|
|||
- Jackson 全局忽略未知字段,初版 userId 注入请求被接受;改为新 DTO/企业嵌套 DTO 安全拒绝未知字段。 |
|||
- 初版 MVC 拦截器晚于 multipart/Content-Type 路由匹配;全端点测试定位到格式不符请求先出错,改为安全过滤链门禁。 |
|||
- 新过滤器初版增加历史 Controller 接线探针的 Accessor 调用;限定为 JWT 已验证会话主体后保留旧精确调用次数断言。 |
|||
- MySQL 回滚测试初版在事务代理外 stub MANDATORY 审计,导致测试装配失败;改为对代理内部 spy 注入故障,实际调用仍经过真实事务代理。 |
|||
- Repository 会将故障转换为 DataAccessException,回滚测试明确验证安全业务码 10403,不要求错误穿透为原异常类型。 |
|||
- 密码 HTTP 测试初版遗漏密码模块开关和 mock Redis 原子脚本返回值;补齐测试前置。真实 Redis 证据由最新 JAR 验收单独提供。 |
|||
- 新增接口使旧 OpenAPI 数量断言失败;只将数量改为 183/221,保留原简历安全字段和接口断言。 |
|||
- MySQL Service 并发只产生一份身份,但首次最新 JAR HTTP 并发出现 200/500:失败请求在锁内抛出 InvalidTokenException,新 Controller 未转换。补确定性测试先复现 1 run / 1 failure(expected 401 but was 500),再复用原认证安全响应返回 401;没有改变锁、角色或数据断言。 |
|||
- JAR 验收脚本初版把 `/auth/sms/send` 的查询参数放进 JSON body,实测缺少 phone 返回 400;按现有 RequestParam 契约修正脚本,未修改生产短信接口。Maven exec 参数需整体引号;RabbitMQ 改用本机已缓存的官方 3.13 management 镜像,未通过反复网络拉取干扰其他服务。 |
|||
|
|||
## 精确本轮文件范围 |
|||
|
|||
新增生产文件(4): |
|||
|
|||
- `auth/identity/IdentitySelectionRequest.java` |
|||
- `auth/identity/IdentitySelectionResponse.java` |
|||
- `auth/identity/IdentitySelectionService.java` |
|||
- `auth/identity/IdentityAccessFilter.java` |
|||
|
|||
修改生产文件(9):`AuthService`、`User`、`PasswordAuthenticationService`、`UserRoleGuard`、`ErrorCode`、`ErrorHttpStatusRegistry`、`CompanyRegistrationRequest`、`SecurityConfig`、`AuthController`。 |
|||
其中异常码文件已有 Phase B 改动,本轮只叠加 10401–10403;其他原有 P0/B/岗位文件不被本轮覆盖。 |
|||
|
|||
新增测试文件(4):`IdentityPolicyTest`、`UnifiedRegistrationIntegrationTest`、`IdentitySelectionMySqlTest`、`UnifiedIdentityJarAcceptanceProbe`。最后一项为手工最新 JAR 验收 main,不冒充 JUnit 数量。 |
|||
修改既有测试(8):`AuthServiceTest`、`CompanyControllerMvcTest`、`CompanyOnboardingControllerTest`、`GlobalExceptionHandlerStatusContractTest`、`ErrorHttpStatusRegistryTest`、`GlobalExceptionHandlerMappingParityTest`、`R01BIndependentHttpMappingParityTest`、`CompanyResumeDownloadIntegrationTest`。 |
|||
修改仅涉及新账号默认身份、新依赖装配、新枚举映射计数和新增接口计数;原角色、归属、隐私及历史映射断言保留。 |
|||
|
|||
新增设计与本报告;修改 `openapi-rc.json` 与 `verify_openapi.py`。没有修改迁移、依赖、短信服务商、Jenkins 或线上配置。 |
|||
|
|||
## 测试及真实基础设施 |
|||
|
|||
最终定向命令 `mvn -B -Dtest=UnifiedRegistrationIntegrationTest,IdentityPolicyTest,IdentitySelectionMySqlTest test`:55 / F=0 / E=0 / S=0。 |
|||
其中策略测试 27、HTTP 集成 13、真实 MySQL 15;HTTP 库存测试独立列出公开与账号操作清单,不调用生产白名单生成期望,196 个受限 operation 全部拒绝。 |
|||
首次全量 test 曾有 7 个失败(旧接口计数 + 旧接线精确次数),修正测试计数与过滤器主体边界后 `mvn test` 为 2952 / F=0 / E=0 / S=0。 |
|||
之后新增失效会话 HTTP 反证并修复 500,最终 `mvn -B clean verify`:2953 / F=0 / E=0 / S=0 / BUILD SUCCESS,耗时 13:13。 |
|||
独立读取 284 份 Surefire XML 汇总同为 2953 / 0 / 0 / 0。相对采用的 2889 基线,新增 55 个 JUnit 执行项,既有枚举参数化测试增加 9 项,数量自洽。 |
|||
真实 MySQL 15 项及既有 Testcontainers 测试均实际执行;没有 Docker 不可用静默跳过。 |
|||
MySQL/Testcontainers 不设置静默跳过 Docker 的开关。 |
|||
并发使用 CountDownLatch,不用 sleep 调节竞争。 |
|||
最新 JAR 验收使用隔离 MySQL 8.4、Redis 7.4、RabbitMQ 3;所有夹具仅在隔离库,不使用真实手机号或线上密钥。 |
|||
短信 sender 使用受控 Mock,数据库获取测试验证码;不是实际运营商投递,也没有为前端增加 mockCode 旁路。 |
|||
|
|||
最新 JAR 三中间件黑盒场景包括:企业开关关闭安全拒绝;UNASSIGNED 空业务 roles;13 张表逐字段快照零写;求职者/企业身份选择;密码登录身份标记;全部旧 Access/Refresh 失效;同身份重试只读;跨身份冲突;未认证企业候选人访问被拒;真实 HTTP 双请求只一个 200、另一个 401;四个 RabbitMQ 队列消息数、ready、unacked 均为 0;精确删除三账号夹具及验证码,审计 append-only。 |
|||
首次以 Maven exec:java 同 JVM 运行时,场景已产生 66 checks / 0 failures,但 Testcontainers Ryuk 守护线程在插件清理阶段被中断,导致外层 Maven 返回非零;保留该失败事实,不写成 BUILD SUCCESS。 |
|||
最终改为独立 `java -cp` JVM 执行同一验收 main:再次 66 checks / 0 failures,进程正常退出 0,三容器按生命周期关闭,无插件线程清理异常。 |
|||
最终结果在被忽略的 `target/identity-probe-result.json`,日志在 `docs/acceptance/unified-identity/jar-probe-standalone.log`;不新增未跟踪 JSON 验收产物。 |
|||
被验收的最终 JAR SHA-256:`77d0125ba1d20007966be9fa7a7b3f82ef09fb5628b83bb5190262d318720cc9`。最终全量打包后仅重编译手工验收 main 的输出路径调整,没有再修改生产代码。 |
|||
|
|||
## OpenAPI 与前端切换 |
|||
|
|||
新增一个 path/operation,静态验证为 183 paths / 221 operations,`verify_openapi.py` All checks passed。 |
|||
最新 JAR `/v3/api-docs` 的完整 path/method operation 集合与静态文件完全一致,不只比较数量。 |
|||
前端登录成功后查看 identitySelectionRequired:true 进入身份选择页;false 根据旧 role 进入原工作台。 |
|||
首次选择成功必须同时替换 Access/Refresh Token,不能继续使用旧 Token。 |
|||
COMPANY 仍进入原 UNVERIFIED 入驻流程;不能直接进入已认证招聘能力。刷新后如需身份展示,继续查询 `/auth/me`。 |
|||
新账号已不自动成为求职者,因此部署必须与前端身份选择页协调,不能宣称旧前端零改动兼容。 |
|||
|
|||
## 敏感信息分类 |
|||
|
|||
本轮新增源码、测试与两份文档扫描:完整 JWT、字面 BCrypt 摘要、邮箱、PRIVATE KEY、AWS key、本机用户绝对路径均为 0 命中。 |
|||
手机号命中 22 行、15 个唯一值,全部来自独占的 `199000094xx/199000095xx/199000096xx` 假号段夹具;没有使用用户提供的真实手机号发送短信。 |
|||
手工验收 main 中的测试密码、JWT 测试密钥和 Testcontainers 口令为显式 test-only 常量,只在隔离库/容器使用,不是生产凭据。 |
|||
成功 JAR 日志通过自动断言确认不含三条测试手机号、测试明文密码、对应 BCrypt 摘要及指定签发 Token;拒绝响应不含异常类、SQL 连接串或存储字段。 |
|||
失败的 MockMvc 证据可能包含已失效的本地测试 Token,日志由既有 `**/*.log` 规则忽略,不进入提交;不把这类失败证据宣称为“所有日志零 Token”。 |
|||
历史启动日志中的本机目录与 Spring generated security password 警告仍属于既有环境输出,本轮未顺手重构该功能,也不声称这些观察项已消失。 |
|||
|
|||
## 边界与结束检查 |
|||
|
|||
最终分支 master,HEAD 仍为 `8c9429108cbd5d85e5245f6c404b2172bf6dd7f3`,暂存区空,`git diff --check` 为 0。 |
|||
V1–V46 与 pom.xml 无改动、无新迁移;WebMvcConfig 内容与 HEAD 一致,没有保留初版拦截器配置。 |
|||
历史 290 个未跟踪条目未删除,当前 300 = 历史 290 + 本轮 10 个新增文件。原有未提交 Phase B/P0/岗位改动保留,不把它们记成本轮产出。 |
|||
最新 JAR 两次启动按各自 Process 对象精确停止;最终无本轮 Java 残留,8080/8081 空闲。验收容器自动关闭,Docker 守护进程可保持运行。 |
|||
三账号及验证码夹具精确清理,清理过程中不删除审计行;审计随隔离容器自然生命周期结束,不触及线上审计数据。四个消息队列在关闭前 messages/ready/unacked 均为 0。 |
|||
未进入 Phase C;本轮不暂存、不提交、不推送、不部署。上线开关和已有生产账号由后续独立验收与发布检查点另行处理。 |
|||
@ -0,0 +1,23 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Propagation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
/** |
|||
* Persist an invalid-code attempt even when the surrounding login or registration rolls back. |
|||
* A conditional database update prevents concurrent guesses from losing increments. |
|||
*/ |
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class SmsFailedAttemptRecorder { |
|||
private final SmsVerificationCodeRepository codes; |
|||
|
|||
@Transactional(propagation = Propagation.REQUIRES_NEW) |
|||
public void record(Long codeId, LocalDateTime now) { |
|||
codes.incrementFailedAttemptsIfValid(codeId, now); |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
package com.mydreamjob.auth.dto; |
|||
|
|||
import jakarta.validation.constraints.NotBlank; |
|||
import jakarta.validation.constraints.Size; |
|||
|
|||
public record PasswordSetupRequest(@NotBlank @Size(max = 512) |
|||
@io.swagger.v3.oas.annotations.media.Schema(accessMode = io.swagger.v3.oas.annotations.media.Schema.AccessMode.WRITE_ONLY) String passwordSetupToken, |
|||
@NotBlank @Size(max = 256) |
|||
@io.swagger.v3.oas.annotations.media.Schema(accessMode = io.swagger.v3.oas.annotations.media.Schema.AccessMode.WRITE_ONLY) String newPassword) { |
|||
@Override public String toString() { return "PasswordSetupRequest[redacted]"; } |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
package com.mydreamjob.auth.dto; |
|||
|
|||
public record PasswordSetupResponse(boolean passwordConfigured, boolean reauthenticationRequired, |
|||
boolean identitySelectionRequired, String accessToken, String refreshToken, long expiresIn) { |
|||
@Override public String toString() { return "PasswordSetupResponse[redacted]"; } |
|||
} |
|||
@ -0,0 +1,76 @@ |
|||
package com.mydreamjob.auth.identity; |
|||
|
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.common.logging.RequestIdAccessor; |
|||
import com.mydreamjob.security.CurrentUserAccessor; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import jakarta.servlet.http.HttpServletResponse; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.security.authentication.InsufficientAuthenticationException; |
|||
import org.springframework.stereotype.Component; |
|||
import org.springframework.util.AntPathMatcher; |
|||
import org.springframework.web.filter.OncePerRequestFilter; |
|||
import jakarta.servlet.FilterChain; |
|||
import jakarta.servlet.ServletException; |
|||
import java.io.IOException; |
|||
import java.util.Set; |
|||
|
|||
/** 已认证但未选择身份的账号:默认拒绝业务,只允许精确的方法/路径白名单。 */ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class IdentityAccessFilter extends OncePerRequestFilter { |
|||
private static final Set<String> AUTH_POST = Set.of("/auth/identity/select", "/auth/logout", |
|||
"/auth/password/set", "/auth/password/setup", "/auth/password/change", "/auth/login", "/auth/password/login", |
|||
"/auth/password/reset", "/auth/refresh", "/auth/sms/send", "/auth/company/register"); |
|||
private static final Set<String> PUBLIC_GET = Set.of("/", "/health", "/health/**", "/jobs", |
|||
"/jobs/search", "/jobs/filters", "/jobs/compare", "/jobs/public/**", "/jobs/*/reviews", |
|||
"/jobs/*/reviews/*", "/jobs/*/reviews/summary", "/companies/*", "/companies/*/jobs", |
|||
"/job-recommendation-taxonomies", "/swagger-ui/**", "/v3/api-docs/**", "/api-docs/**"); |
|||
private final UserRepository users; |
|||
private final CurrentUserAccessor currentUser; |
|||
private final ObjectMapper mapper; |
|||
private final RequestIdAccessor requestIds; |
|||
private final AntPathMatcher paths = new AntPathMatcher(); |
|||
|
|||
@Override |
|||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) |
|||
throws ServletException, IOException { |
|||
// 当前生产认证仅由 JwtAuthenticationFilter 建立;只使用其已校验的会话主体。
|
|||
// 匿名请求继续交给既有 Security 入口,测试注入的非 JWT 主体不新增解析行为。
|
|||
var authentication=org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); |
|||
if(authentication==null || !(authentication.getDetails() instanceof com.mydreamjob.auth.SessionAuthenticationDetails)) { |
|||
chain.doFilter(request,response); return; |
|||
} |
|||
Long id; |
|||
try { id = currentUser.requireUserId(); } |
|||
catch (InsufficientAuthenticationException e) { chain.doFilter(request,response); return; } |
|||
User user; |
|||
try { user = users.findById(id).orElse(null); } |
|||
catch (org.springframework.dao.DataAccessException e) { |
|||
reject(request,response,503,ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE); return; |
|||
} |
|||
if (user == null || user.getRole() != User.Role.UNASSIGNED) {chain.doFilter(request,response); return;} |
|||
String uri = request.getRequestURI().substring(request.getContextPath().length()); |
|||
if (allowed(request.getMethod(), uri)) {chain.doFilter(request,response); return;} |
|||
reject(request,response,403,ErrorCode.IDENTITY_SELECTION_REQUIRED); |
|||
} |
|||
|
|||
private void reject(HttpServletRequest request, HttpServletResponse response, int status, ErrorCode error) throws IOException { |
|||
response.setStatus(status); |
|||
response.setContentType("application/json;charset=UTF-8"); |
|||
mapper.writeValue(response.getWriter(), ApiResponse.error(error.getCode(),error.getMessage(), requestIds.get(request))); |
|||
} |
|||
|
|||
boolean allowed(String method, String uri) { |
|||
if ("OPTIONS".equals(method) || paths.match("/actuator/**", uri)) return true; |
|||
if ("POST".equals(method)) return AUTH_POST.contains(uri); |
|||
if (!"GET".equals(method) && !"HEAD".equals(method)) return false; |
|||
// /companies/* 是公开企业 ID 路径,不能误把保留字 me 当作公开主页。
|
|||
if ("/companies/me".equals(uri) || uri.startsWith("/companies/me/")) return false; |
|||
return "/auth/me".equals(uri) || PUBLIC_GET.stream().anyMatch(pattern -> paths.match(pattern, uri)); |
|||
} |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
package com.mydreamjob.auth.identity; |
|||
|
|||
import com.mydreamjob.auth.company.CompanyRegistrationRequest.CompanyDetails; |
|||
import jakarta.validation.Valid; |
|||
import jakarta.validation.constraints.AssertTrue; |
|||
import jakarta.validation.constraints.NotNull; |
|||
import lombok.Getter; |
|||
import lombok.Setter; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
|
|||
@Getter |
|||
@Setter |
|||
public class IdentitySelectionRequest { |
|||
public enum Identity { STUDENT, COMPANY } |
|||
|
|||
@NotNull |
|||
private Identity identity; |
|||
@Valid |
|||
private CompanyDetails company; |
|||
|
|||
@com.fasterxml.jackson.annotation.JsonAnySetter |
|||
public void rejectUnknown(String name, Object value) { |
|||
throw new IllegalArgumentException("request contains unsupported fields"); |
|||
} |
|||
|
|||
@AssertTrue(message = "company details are required only for company identity") |
|||
@Schema(hidden = true) |
|||
public boolean isCompanyShapeValid() { |
|||
return identity == null || (identity == Identity.COMPANY ? company != null : company == null); |
|||
} |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
package com.mydreamjob.auth.identity; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonInclude; |
|||
|
|||
@JsonInclude(JsonInclude.Include.NON_NULL) |
|||
public record IdentitySelectionResponse(Long userId, String role, boolean identitySelectionRequired, |
|||
Long companyId, String onboardingStatus, String accessToken, String refreshToken, |
|||
Long expiresIn, boolean reauthenticationRequired) { } |
|||
@ -0,0 +1,104 @@ |
|||
package com.mydreamjob.auth.identity; |
|||
|
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.auth.*; |
|||
import com.mydreamjob.auth.company.CompanyRegistrationProperties; |
|||
import com.mydreamjob.auth.company.CompanyRegistrationRequest.CompanyDetails; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.company.CompanyOnboardingService; |
|||
import com.mydreamjob.deletion.DeletionStatus; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.deletion.AccountDeletionRepository; |
|||
import jakarta.validation.Validator; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.dao.DataAccessException; |
|||
import org.springframework.dao.DataIntegrityViolationException; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
import java.time.Clock; |
|||
import java.time.Instant; |
|||
import java.time.LocalDateTime; |
|||
import java.util.HashSet; |
|||
import java.util.Set; |
|||
|
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class IdentitySelectionService { |
|||
private final UserRepository users; |
|||
private final CompanyRepository companies; |
|||
private final AuthSessionService sessions; |
|||
private final AuditService audit; |
|||
private final AccountDeletionRepository deletions; |
|||
private final CompanyRegistrationProperties properties; |
|||
private final CompanyOnboardingService onboarding; |
|||
private final Validator validator; |
|||
private final Clock clock; |
|||
|
|||
@Transactional |
|||
public IdentitySelectionResponse select(Long userId, String sessionId, IdentitySelectionRequest request, |
|||
AuditContext context) { |
|||
if (request == null || !validator.validate(request).isEmpty()) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
try { |
|||
// 与刷新/密码变更相同:users -> auth_tokens;锁内重新确认会话,关闭 TOCTOU。
|
|||
User user = users.findByIdForUpdate(userId).orElseThrow(InvalidTokenException::new); |
|||
Instant now = clock.instant(); |
|||
if (!Boolean.TRUE.equals(user.getActive()) || !sessions.isSessionActive(userId, sessionId, now)) { |
|||
throw new InvalidTokenException(); |
|||
} |
|||
if (deletions.findByUserId(userId).filter(d -> d.getStatus() != DeletionStatus.NONE).isPresent()) { |
|||
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT); |
|||
} |
|||
User.Role target = request.getIdentity() == IdentitySelectionRequest.Identity.STUDENT |
|||
? User.Role.STUDENT : User.Role.COMPANY; |
|||
if (user.getRole() != User.Role.UNASSIGNED) { |
|||
if (user.getRole() != target || user.getRoles() == null || !user.getRoles().equals(Set.of(target))) { |
|||
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT); |
|||
} |
|||
Company company = target == User.Role.COMPANY ? companies.findByOwnerUserId(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT)) : null; |
|||
// 不重复更改资料/颁发会话/审计;客户端可使用已有凭证或重新登录。
|
|||
return response(user, company, null, true); |
|||
} |
|||
if (user.getRoles() == null || !user.getRoles().isEmpty() || companies.existsByOwnerUserId(userId)) { |
|||
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT); |
|||
} |
|||
if (target == User.Role.COMPANY && !properties.isEnabled()) { |
|||
throw new BusinessException(ErrorCode.COMPANY_REGISTRATION_DISABLED); |
|||
} |
|||
user.setRole(target); |
|||
user.setRoles(new HashSet<>(Set.of(target))); |
|||
users.saveAndFlush(user); |
|||
Company company = null; |
|||
if (target == User.Role.COMPANY) { |
|||
CompanyDetails d = request.getCompany(); |
|||
company = companies.saveAndFlush(Company.builder().ownerUserId(userId).name(d.getName()) |
|||
.logoUrl(d.getLogoUrl()).industry(d.getIndustry()).description(d.getDescription()) |
|||
.website(d.getWebsite()).address(d.getAddress()).verified(false).build()); |
|||
} |
|||
// modifying 会清理 persistence context;上面的角色与资料已 flush,锁仍持有。
|
|||
sessions.revokeAllUserSessions(userId, "IDENTITY_SELECTED", now); |
|||
AuthSessionService.SessionTokens tokens = sessions.createSession(user, now, context); |
|||
audit.logRequired(userId, "IDENTITY_SELECTED", "USER", userId, |
|||
"{\"result\":\"SUCCESS\"}", context, LocalDateTime.ofInstant(now, AuthSessionService.BUSINESS_ZONE)); |
|||
return response(user, company, tokens, false); |
|||
} catch (DataIntegrityViolationException e) { |
|||
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_CONFLICT); |
|||
} catch (DataAccessException e) { |
|||
throw new BusinessException(ErrorCode.IDENTITY_SELECTION_DEPENDENCY_UNAVAILABLE); |
|||
} |
|||
} |
|||
|
|||
private IdentitySelectionResponse response(User user, Company company, AuthSessionService.SessionTokens tokens, |
|||
boolean reauthenticate) { |
|||
return new IdentitySelectionResponse(user.getId(), user.getRole().name(), false, |
|||
company == null ? null : company.getId(), company == null ? null : |
|||
(tokens == null ? onboarding.get(user.getId()).onboardingStatus() : "UNVERIFIED"), |
|||
tokens == null ? null : tokens.accessToken(), tokens == null ? null : tokens.refreshToken(), |
|||
tokens == null ? null : AuthService.EXPIRES_IN_SECONDS, reauthenticate); |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
package com.mydreamjob.auth.password; |
|||
|
|||
import jakarta.persistence.*; |
|||
import java.time.LocalDateTime; |
|||
import lombok.*; |
|||
|
|||
@Entity |
|||
@Table(name = "password_setup_challenges") |
|||
@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor |
|||
public class PasswordSetupChallenge { |
|||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; |
|||
@Column(nullable = false) private Long userId; |
|||
@Column(nullable = false, length = 36) private String sessionId; |
|||
@Column(nullable = false, unique = true, length = 64) private String tokenHash; |
|||
@Column(nullable = false) private LocalDateTime createdAt; |
|||
@Column(nullable = false) private LocalDateTime expiresAt; |
|||
private LocalDateTime consumedAt; |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
package com.mydreamjob.auth.password; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import org.springframework.data.jpa.repository.*; |
|||
import org.springframework.data.repository.query.Param; |
|||
|
|||
public interface PasswordSetupChallengeRepository extends JpaRepository<PasswordSetupChallenge, Long> { |
|||
@Modifying(flushAutomatically = true) |
|||
@Query(""" |
|||
update PasswordSetupChallenge c set c.consumedAt = :now |
|||
where c.userId = :user and c.sessionId = :session and c.tokenHash = :hash |
|||
and c.consumedAt is null and c.expiresAt > :now |
|||
""") |
|||
int consume(@Param("user") Long userId, @Param("session") String sessionId, |
|||
@Param("hash") String hash, @Param("now") LocalDateTime now); |
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
package com.mydreamjob.auth.password; |
|||
|
|||
import com.mydreamjob.auth.*; |
|||
import com.mydreamjob.auth.dto.PasswordSetupResponse; |
|||
import com.mydreamjob.audit.*; |
|||
import com.mydreamjob.common.exception.*; |
|||
import java.security.SecureRandom; |
|||
import java.time.*; |
|||
import java.util.Base64; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.security.crypto.password.PasswordEncoder; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
/** 短信登录后的首次密码设置;凭证只绑定当前会话,不用会话创建时间推断认证来源。 */ |
|||
@Service @RequiredArgsConstructor |
|||
public class PasswordSetupService { |
|||
private static final SecureRandom RANDOM = new SecureRandom(); |
|||
private final PasswordSetupChallengeRepository challenges; |
|||
private final AuthTokenHasher hasher; |
|||
private final AuthTokenRepository tokens; |
|||
private final PasswordAuthenticationService passwords; |
|||
private final PasswordRateLimiter limiter; |
|||
private final PasswordPolicy policy; |
|||
private final PasswordEncoder encoder; |
|||
private final UserRepository users; |
|||
private final AuthSessionService sessions; |
|||
private final AuditService audit; |
|||
private final Clock clock; |
|||
private final PasswordAuthenticationProperties properties; |
|||
|
|||
@Transactional |
|||
public String issue(User user, String sessionId, Instant now) { |
|||
if (!properties.isEnabled() || user.getPasswordHash() != null) return null; |
|||
byte[] random = new byte[32]; |
|||
RANDOM.nextBytes(random); |
|||
String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(random); |
|||
challenges.save(PasswordSetupChallenge.builder().userId(user.getId()).sessionId(sessionId) |
|||
.tokenHash(hash(raw)).createdAt(AuthSessionService.toLocal(now)) |
|||
.expiresAt(AuthSessionService.toLocal(now.plusSeconds(600))).build()); |
|||
return raw; |
|||
} |
|||
|
|||
@Transactional |
|||
public PasswordSetupResponse setup(Long userId, String sessionId, String raw, String newPassword, AuditContext ctx) { |
|||
User user = passwords.lockedUsableUser(userId); |
|||
Instant now = clock.instant(); |
|||
boolean active = tokens.lockSession(userId, sessionId).stream().anyMatch(t -> |
|||
Boolean.FALSE.equals(t.getRevoked()) && t.getConsumedAt() == null |
|||
&& t.getExpiredAt().isAfter(AuthSessionService.toLocal(now))); |
|||
if (!active) |
|||
throw new InvalidTokenException(); |
|||
limiter.checkOperationAllowed(user.getPhone()); |
|||
if (user.getPasswordHash() != null) throw new BusinessException(ErrorCode.PASSWORD_STATE_CONFLICT); |
|||
policy.validateNewPassword(newPassword, user.getPhone(), null, encoder); |
|||
if (raw == null || raw.length() != 43 || challenges.consume(userId, sessionId, hash(raw), |
|||
AuthSessionService.toLocal(now)) != 1) |
|||
throw new BusinessException(ErrorCode.CREDENTIALS_INVALID, "invalid password setup proof"); |
|||
user.setPasswordHash(encoder.encode(policy.normalize(newPassword))); |
|||
user.setPasswordChangedAt(AuthSessionService.toLocal(now)); |
|||
users.saveAndFlush(user); |
|||
sessions.revokeAllUserSessions(userId, "PASSWORD_SET", now); |
|||
var pair = sessions.createSession(user, now, ctx); |
|||
audit.log(userId, "PASSWORD_SET", "AUTH", null, "First password configured", ctx, |
|||
AuthSessionService.toLocal(now)); |
|||
audit.log(userId, "AUTH_SESSIONS_REVOKED", "AUTH", null, "PASSWORD_SET", ctx, |
|||
AuthSessionService.toLocal(now)); |
|||
return new PasswordSetupResponse(true, false, user.getRole() == User.Role.UNASSIGNED, |
|||
pair.accessToken(), pair.refreshToken(), AuthService.EXPIRES_IN_SECONDS); |
|||
} |
|||
|
|||
private String hash(String raw) { return hasher.hash("password-setup:" + raw); } |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import com.aliyun.dysmsapi20170525.Client; |
|||
import com.aliyun.dysmsapi20170525.models.SendSmsRequest; |
|||
import com.aliyun.dysmsapi20170525.models.SendSmsResponse; |
|||
import com.aliyun.teautil.models.RuntimeOptions; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.slf4j.Logger; |
|||
import org.slf4j.LoggerFactory; |
|||
import java.util.Set; |
|||
|
|||
/** SDK 返回 OK 只代表网关受理,不代表手机已收到;禁止自动重试非幂等发送。 */ |
|||
public final class AliyunSmsCodeSender implements SmsCodeSender { |
|||
private static final Logger LOG = LoggerFactory.getLogger(AliyunSmsCodeSender.class); |
|||
private static final Set<String> PURPOSES = Set.of("login", "password-set", "password-reset", "company-register"); |
|||
private final Client client; |
|||
private final String signName; |
|||
private final String templateCode; |
|||
private final int connectTimeoutMs; |
|||
private final int readTimeoutMs; |
|||
|
|||
public AliyunSmsCodeSender(Client client, AliyunSmsProperties properties) { |
|||
properties.validateForSending(); |
|||
this.client = client; |
|||
this.signName = properties.getSignName(); |
|||
this.templateCode = properties.getTemplateCode(); |
|||
this.connectTimeoutMs = properties.getConnectTimeoutMs(); |
|||
this.readTimeoutMs = properties.getReadTimeoutMs(); |
|||
} |
|||
|
|||
@Override |
|||
public void sendVerificationCode(String phone, String purpose, String code) { |
|||
if (phone == null || !phone.matches("[0-9]{11}") || purpose == null || !PURPOSES.contains(purpose) |
|||
|| code == null || !code.matches("[0-9]{6}")) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid SMS request"); |
|||
} |
|||
SendSmsRequest request = new SendSmsRequest().setPhoneNumbers(phone).setSignName(signName) |
|||
.setTemplateCode(templateCode).setTemplateParam("{\"code\":\"" + code + "\"}"); |
|||
RuntimeOptions runtime = new RuntimeOptions().setAutoretry(false) |
|||
.setConnectTimeout(connectTimeoutMs).setReadTimeout(readTimeoutMs); |
|||
SendSmsResponse response; |
|||
try { |
|||
response = client.sendSmsWithOptions(request, runtime); |
|||
} catch (Exception ignored) { |
|||
if (ignored instanceof InterruptedException) Thread.currentThread().interrupt(); |
|||
// SDK 异常可能包含请求绑定值,既不透传原因也不输出堆栈。
|
|||
LOG.warn("Aliyun SMS gateway call failed"); |
|||
throw new BusinessException(ErrorCode.SMS_SEND_FAILED); |
|||
} |
|||
if (response == null || response.getBody() == null || response.getStatusCode() == null |
|||
|| response.getStatusCode() != 200 || !"OK".equals(response.getBody().getCode())) { |
|||
LOG.warn("Aliyun SMS gateway did not accept request"); |
|||
throw new BusinessException(ErrorCode.SMS_SEND_FAILED); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import lombok.Getter; |
|||
import lombok.Setter; |
|||
import org.springframework.boot.context.properties.ConfigurationProperties; |
|||
|
|||
/** 不生成 toString,避免配置对象输出凭据。只有显式 true 才启用。 */ |
|||
@Getter |
|||
@Setter |
|||
@ConfigurationProperties(prefix = "app.sms.aliyun") |
|||
public class AliyunSmsProperties { |
|||
private String enabled = "false"; |
|||
private String accessKeyId; |
|||
private String accessKeySecret; |
|||
private String signName; |
|||
private String templateCode; |
|||
private int connectTimeoutMs = 3000; |
|||
private int readTimeoutMs = 5000; |
|||
|
|||
public boolean explicitlyEnabled() { |
|||
return enabled != null && "true".equalsIgnoreCase(enabled.trim()); |
|||
} |
|||
|
|||
public void validateForSending() { |
|||
if (blank(accessKeyId) || blank(accessKeySecret) || blank(signName) || blank(templateCode) |
|||
|| !templateCode.matches("SMS_[A-Za-z0-9_]+") |
|||
|| connectTimeoutMs < 1000 || connectTimeoutMs > 30000 |
|||
|| readTimeoutMs < 1000 || readTimeoutMs > 30000) { |
|||
// 不使用包含 rejected value 的校验异常,避免绑定密钥进入启动日志。
|
|||
throw new IllegalStateException("SMS configuration incomplete or invalid"); |
|||
} |
|||
} |
|||
|
|||
private static boolean blank(String value) { return value == null || value.isBlank(); } |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
package com.mydreamjob.company; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
|
|||
import java.util.function.Supplier; |
|||
|
|||
/** |
|||
* 企业已认证门禁。角色与企业/资源归属仍由调用 Service 校验;该类只以 |
|||
* {@code companies.verified} 为准判定企业招聘业务是否可继续。 |
|||
*/ |
|||
public final class VerifiedCompanyGuard { |
|||
|
|||
private VerifiedCompanyGuard() { |
|||
} |
|||
|
|||
/** 认证前的公司身份检查已通过后,调用本方法执行唯一的认证状态判定。 */ |
|||
public static Company requireVerified(Company company) { |
|||
if (company == null) { |
|||
throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); |
|||
} |
|||
if (!Boolean.TRUE.equals(company.getVerified())) { |
|||
throw new BusinessException(ErrorCode.COMPANY_NOT_VERIFIED); |
|||
} |
|||
return company; |
|||
} |
|||
|
|||
/** 公司资料缺失、尚无审核申请或被拒绝,都统一按未认证企业拒绝。 */ |
|||
public static boolean isVerified(Company company) { |
|||
return company != null && Boolean.TRUE.equals(company.getVerified()); |
|||
} |
|||
|
|||
/** 对学生等非企业调用方保持原语义;企业身份则失败关闭。 */ |
|||
public static void requireVerifiedIfCompany(User actor, Supplier<Company> companySupplier) { |
|||
if (actor != null && actor.getRoles() != null |
|||
&& actor.getRoles().contains(User.Role.COMPANY)) { |
|||
requireVerified(companySupplier.get()); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,90 @@ |
|||
package com.mydreamjob.config; |
|||
|
|||
import java.io.IOException; |
|||
import java.io.Reader; |
|||
import java.io.StringReader; |
|||
import java.nio.charset.Charset; |
|||
import java.sql.Connection; |
|||
import java.sql.DatabaseMetaData; |
|||
import java.sql.SQLException; |
|||
import java.util.Arrays; |
|||
import java.util.Collection; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.Map; |
|||
import org.flywaydb.core.api.ResourceProvider; |
|||
import org.flywaydb.core.api.configuration.FluentConfiguration; |
|||
import org.flywaydb.core.api.resource.LoadableResource; |
|||
import org.springframework.boot.autoconfigure.flyway.FlywayConfigurationCustomizer; |
|||
import org.springframework.context.annotation.Bean; |
|||
import org.springframework.context.annotation.Configuration; |
|||
import org.springframework.core.io.ClassPathResource; |
|||
import org.springframework.core.io.Resource; |
|||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver; |
|||
|
|||
/** 仅替换 MySQL 5.7 的 V49 资源;既有 SQL、其他数据库及历史 checksum 不改写。 */ |
|||
@Configuration(proxyBeanMethods = false) |
|||
public class MySql57MigrationConfiguration { |
|||
private static final String V49 = "V49__skill_cloud_boolean_type.sql"; |
|||
|
|||
@Bean |
|||
FlywayConfigurationCustomizer mysql57MigrationResources() { |
|||
return MySql57MigrationConfiguration::configure; |
|||
} |
|||
|
|||
public static void configure(FluentConfiguration configuration) { |
|||
try (Connection connection = configuration.getDataSource().getConnection()) { |
|||
DatabaseMetaData metadata = connection.getMetaData(); |
|||
if (!"MySQL".equals(metadata.getDatabaseProductName()) |
|||
|| metadata.getDatabaseMajorVersion() != 5 |
|||
|| metadata.getDatabaseMinorVersion() != 7) { |
|||
return; |
|||
} |
|||
// 不静默忽略调用者的自定义迁移路径或资源提供器。
|
|||
if (configuration.getResourceProvider() != null |
|||
|| configuration.getLocations().length != 1 |
|||
|| !"classpath:db/migration".equals(configuration.getLocations()[0].getDescriptor())) { |
|||
throw new IllegalStateException("MySQL 5.7 migration configuration requires the standard migration location"); |
|||
} |
|||
configuration.resourceProvider(resources(configuration.getEncoding())); |
|||
} catch (SQLException | IOException ex) { |
|||
// 不把连接 URL、账号、驱动异常原文带进启动日志。
|
|||
throw new IllegalStateException("Database migration compatibility initialization failed"); |
|||
} |
|||
} |
|||
|
|||
private static ResourceProvider resources(Charset encoding) throws IOException { |
|||
Map<String, LoadableResource> resources = new LinkedHashMap<>(); |
|||
Resource[] originals = new PathMatchingResourcePatternResolver() |
|||
.getResources("classpath*:db/migration/*"); |
|||
for (Resource original : originals) { |
|||
String filename = original.getFilename(); |
|||
if (filename == null || !original.isReadable()) continue; |
|||
Resource selected = V49.equals(filename) |
|||
? new ClassPathResource("db/mysql57/" + V49) : original; |
|||
String text = selected.getContentAsString(encoding); |
|||
String path = selected.getURL().toExternalForm(); |
|||
LoadableResource resource = new LoadableResource() { |
|||
@Override public Reader read() { return new StringReader(text); } |
|||
@Override public String getAbsolutePath() { return path; } |
|||
@Override public String getAbsolutePathOnDisk() { return path; } |
|||
@Override public String getFilename() { return filename; } |
|||
@Override public String getRelativePath() { return "db/migration/" + filename; } |
|||
}; |
|||
if (resources.putIfAbsent(filename, resource) != null) { |
|||
throw new IllegalStateException("Duplicate database migration resource"); |
|||
} |
|||
} |
|||
if (!resources.containsKey(V49)) throw new IllegalStateException("Required database migration resource missing"); |
|||
return new ResourceProvider() { |
|||
@Override public LoadableResource getResource(String name) { |
|||
return resources.values().stream().filter(resource -> name.equals(resource.getFilename()) |
|||
|| name.equals(resource.getRelativePath()) || name.equals(resource.getAbsolutePath())) |
|||
.findFirst().orElse(null); |
|||
} |
|||
@Override public Collection<LoadableResource> getResources(String prefix, String[] suffixes) { |
|||
return resources.values().stream().filter(resource -> resource.getFilename().startsWith(prefix) |
|||
&& Arrays.stream(suffixes).anyMatch(resource.getFilename()::endsWith)).toList(); |
|||
} |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
package com.mydreamjob.job; |
|||
|
|||
import jakarta.persistence.criteria.Predicate; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Locale; |
|||
import org.springframework.data.jpa.domain.Specification; |
|||
|
|||
/** 企业私有岗位列表查询;租户条件始终是必选条件。 */ |
|||
public final class CompanyJobManagementSpecification { |
|||
private CompanyJobManagementSpecification() {} |
|||
|
|||
public static Specification<Job> forCompany(long companyId, String status, String keyword, String city) { |
|||
return (root, query, cb) -> { |
|||
List<Predicate> predicates = new ArrayList<>(); |
|||
predicates.add(cb.equal(root.get("companyId"), companyId)); |
|||
if (status != null) predicates.add(cb.equal(root.get("status"), status)); |
|||
if (keyword != null) { |
|||
// LOCATE treats % and _ literally and is supported by both H2 and MySQL.
|
|||
predicates.add(cb.greaterThan(cb.locate( |
|||
cb.lower(root.get("title")), keyword.toLowerCase(Locale.ROOT)), 0)); |
|||
} |
|||
if (city != null) { |
|||
predicates.add(cb.equal(cb.lower(root.get("city")), city.toLowerCase(Locale.ROOT))); |
|||
} |
|||
return cb.and(predicates.toArray(Predicate[]::new)); |
|||
}; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
package com.mydreamjob.job.dto; |
|||
|
|||
import com.mydreamjob.job.Job; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import java.time.LocalDateTime; |
|||
|
|||
/** 企业自身岗位管理列表项,不包含招聘统计、候选人或内部审核数据。 */ |
|||
@Schema(name = "CompanyManagedJobItem", description = "企业自身岗位管理安全摘要") |
|||
public record CompanyManagedJobItem( |
|||
@Schema(description = "岗位 ID") Long id, |
|||
@Schema(description = "所属企业 ID") Long companyId, |
|||
@Schema(description = "岗位名称") String title, |
|||
@Schema(description = "岗位描述") String description, |
|||
@Schema(description = "任职要求") String requirements, |
|||
@Schema(description = "工作城市") String city, |
|||
@Schema(description = "用工类型") String employmentType, |
|||
@Schema(description = "最低月薪,单位元") Integer salaryMin, |
|||
@Schema(description = "最高月薪,单位元") Integer salaryMax, |
|||
@Schema(description = "岗位状态:DRAFT、PUBLISHED、CLOSED") String status, |
|||
@Schema(description = "发布时间") LocalDateTime publishedAt, |
|||
@Schema(description = "创建时间") LocalDateTime createdAt, |
|||
@Schema(description = "更新时间") LocalDateTime updatedAt) { |
|||
|
|||
public static CompanyManagedJobItem from(Job job) { |
|||
return new CompanyManagedJobItem(job.getId(), job.getCompanyId(), job.getTitle(), |
|||
job.getDescription(), job.getRequirements(), job.getCity(), job.getEmploymentType(), |
|||
job.getSalaryMin(), job.getSalaryMax(), job.getStatus(), job.getPublishedAt(), |
|||
job.getCreatedAt(), job.getUpdatedAt()); |
|||
} |
|||
} |
|||
@ -0,0 +1,63 @@ |
|||
package com.mydreamjob.onboarding; |
|||
|
|||
import jakarta.persistence.Column; |
|||
import jakarta.persistence.Entity; |
|||
import jakarta.persistence.EntityListeners; |
|||
import jakarta.persistence.GeneratedValue; |
|||
import jakarta.persistence.GenerationType; |
|||
import jakarta.persistence.Id; |
|||
import jakarta.persistence.Table; |
|||
import jakarta.persistence.UniqueConstraint; |
|||
import jakarta.persistence.Version; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
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 = "company_knowledge", |
|||
uniqueConstraints = @UniqueConstraint( |
|||
name = "uk_company_knowledge_company", |
|||
columnNames = "company_id")) |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class CompanyKnowledge { |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "company_id", nullable = false) |
|||
private Long companyId; |
|||
|
|||
@Column(length = 2000) |
|||
private String culture; |
|||
|
|||
@Column(name = "work_policies", length = 4000) |
|||
private String workPolicies; |
|||
|
|||
@Column(name = "onboarding_guide", length = 4000) |
|||
private String onboardingGuide; |
|||
|
|||
@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,74 @@ |
|||
package com.mydreamjob.onboarding; |
|||
|
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse; |
|||
import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; |
|||
import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; |
|||
import io.swagger.v3.oas.annotations.Operation; |
|||
import io.swagger.v3.oas.annotations.tags.Tag; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import jakarta.validation.Valid; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.security.core.Authentication; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
import org.springframework.web.bind.annotation.GetMapping; |
|||
import org.springframework.web.bind.annotation.PathVariable; |
|||
import org.springframework.web.bind.annotation.PutMapping; |
|||
import org.springframework.web.bind.annotation.RequestBody; |
|||
import org.springframework.web.bind.annotation.RequestMapping; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
|
|||
@Tag(name = "公司知识介绍", description = "企业维护知识内容,学生读取公司知识聚合") |
|||
@RestController |
|||
@RequestMapping("/onboarding") |
|||
@RequiredArgsConstructor |
|||
public class CompanyKnowledgeController { |
|||
|
|||
private final CompanyKnowledgeService companyKnowledgeService; |
|||
|
|||
@Operation(summary = "读取本企业知识配置") |
|||
@GetMapping("/company/knowledge") |
|||
public ResponseEntity<ApiResponse<CompanyKnowledgeView>> getCompanyKnowledge( |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
CompanyKnowledgeView data = companyKnowledgeService.getCompanyKnowledge(userId); |
|||
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "创建或更新本企业知识配置") |
|||
@PutMapping("/company/knowledge") |
|||
public ResponseEntity<ApiResponse<CompanyKnowledgeView>> upsertCompanyKnowledge( |
|||
@Valid @RequestBody CompanyKnowledgeUpdateRequest body, |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
CompanyKnowledgeView data = |
|||
companyKnowledgeService.upsertCompanyKnowledge(userId, body); |
|||
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "读取公司知识介绍") |
|||
@GetMapping("/companies/{companyId}/knowledge") |
|||
public ResponseEntity<ApiResponse<CompanyKnowledgeOverviewResponse>> getCompanyKnowledgeOverview( |
|||
@PathVariable Long companyId, |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
CompanyKnowledgeOverviewResponse data = |
|||
companyKnowledgeService.getCompanyKnowledgeOverview(userId, companyId); |
|||
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
|||
} |
|||
|
|||
private Long resolveUserId() { |
|||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); |
|||
if (authentication == null || !authentication.isAuthenticated()) { |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
} |
|||
return Long.valueOf(authentication.getName()); |
|||
} |
|||
|
|||
private String getRequestId(HttpServletRequest request) { |
|||
return request.getHeader("X-Request-Id"); |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.mydreamjob.onboarding; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.stereotype.Repository; |
|||
|
|||
import java.util.Optional; |
|||
|
|||
@Repository |
|||
public interface CompanyKnowledgeRepository extends JpaRepository<CompanyKnowledge, Long> { |
|||
|
|||
Optional<CompanyKnowledge> findByCompanyId(Long companyId); |
|||
} |
|||
@ -0,0 +1,412 @@ |
|||
package com.mydreamjob.onboarding; |
|||
|
|||
import jakarta.persistence.EntityManager; |
|||
import jakarta.persistence.LockModeType; |
|||
import jakarta.persistence.PersistenceContext; |
|||
|
|||
import com.fasterxml.jackson.core.JsonProcessingException; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
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.CompanyRepository; |
|||
import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse; |
|||
import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; |
|||
import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; |
|||
import com.mydreamjob.onboarding.dto.ReviewSummaryView; |
|||
import com.mydreamjob.review.JobReviewDimensionScoreRepository; |
|||
import com.mydreamjob.review.JobReviewRepository; |
|||
import com.mydreamjob.review.ReviewDimension; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.dao.DataIntegrityViolationException; |
|||
import org.springframework.orm.ObjectOptimisticLockingFailureException; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
import org.springframework.util.StringUtils; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.math.RoundingMode; |
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.Collection; |
|||
import java.util.HashMap; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Objects; |
|||
import java.util.regex.Pattern; |
|||
import java.util.stream.Collectors; |
|||
|
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class CompanyKnowledgeService { |
|||
|
|||
private static final String CONFIGURED = "CONFIGURED"; |
|||
private static final String BASIC_ONLY = "BASIC_ONLY"; |
|||
private static final String EMPTY = "EMPTY"; |
|||
private static final String INSUFFICIENT = "INSUFFICIENT"; |
|||
private static final String SUFFICIENT = "SUFFICIENT"; |
|||
|
|||
private static final int MINIMUM_PUBLIC_REVIEWS = 3; |
|||
private static final int MAX_TOP_TAGS = 10; |
|||
private static final int MAX_CULTURE_LENGTH = 2000; |
|||
private static final int MAX_WORK_POLICIES_LENGTH = 4000; |
|||
private static final int MAX_ONBOARDING_GUIDE_LENGTH = 4000; |
|||
private static final Pattern HTML_TAG_PATTERN = |
|||
Pattern.compile("<\\s*/?\\s*[A-Za-z!][^>]*>"); |
|||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); |
|||
|
|||
private final UserRepository userRepository; |
|||
private final CompanyRepository companyRepository; |
|||
private final CompanyKnowledgeRepository companyKnowledgeRepository; |
|||
private final JobReviewRepository jobReviewRepository; |
|||
private final JobReviewDimensionScoreRepository dimensionScoreRepository; |
|||
|
|||
@PersistenceContext |
|||
private EntityManager entityManager; |
|||
|
|||
@Transactional(readOnly = true) |
|||
public CompanyKnowledgeView getCompanyKnowledge(Long userId) { |
|||
Company company = requireOwnedCompany(userId); |
|||
return companyKnowledgeRepository.findByCompanyId(company.getId()) |
|||
.map(this::toConfiguredView) |
|||
.orElseGet(() -> CompanyKnowledgeView.builder() |
|||
.configured(false) |
|||
.version(0L) |
|||
.build()); |
|||
} |
|||
|
|||
@Transactional |
|||
public CompanyKnowledgeView upsertCompanyKnowledge( |
|||
Long userId, |
|||
CompanyKnowledgeUpdateRequest request) { |
|||
Company company = requireOwnedCompany(userId); |
|||
KnowledgeContent content = validateContent(request); |
|||
lockCompany(company); |
|||
CompanyKnowledge existing = companyKnowledgeRepository.findByCompanyId(company.getId()) |
|||
.orElse(null); |
|||
|
|||
if (existing == null) { |
|||
CompanyKnowledge created = CompanyKnowledge.builder() |
|||
.companyId(company.getId()) |
|||
.culture(content.culture()) |
|||
.workPolicies(content.workPolicies()) |
|||
.onboardingGuide(content.onboardingGuide()) |
|||
.build(); |
|||
return toConfiguredView(saveKnowledge(created)); |
|||
} |
|||
|
|||
if (request.getVersion() == null && matchesContent(existing, content)) { |
|||
return toConfiguredView(existing); |
|||
} |
|||
if (request.getVersion() == null) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
if (!Objects.equals(request.getVersion(), existing.getVersion())) { |
|||
throw new BusinessException(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE); |
|||
} |
|||
|
|||
existing.setCulture(content.culture()); |
|||
existing.setWorkPolicies(content.workPolicies()); |
|||
existing.setOnboardingGuide(content.onboardingGuide()); |
|||
return toConfiguredView(saveKnowledge(existing)); |
|||
} |
|||
|
|||
@Transactional(readOnly = true) |
|||
public CompanyKnowledgeOverviewResponse getCompanyKnowledgeOverview( |
|||
Long userId, |
|||
Long companyId) { |
|||
assertStudentRole(userId); |
|||
Company company = companyRepository.findById(companyId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); |
|||
CompanyKnowledge knowledge = companyKnowledgeRepository.findByCompanyId(companyId) |
|||
.orElse(null); |
|||
|
|||
return CompanyKnowledgeOverviewResponse.builder() |
|||
.company(toCompanyView(company)) |
|||
.knowledgeState(knowledge == null ? BASIC_ONLY : CONFIGURED) |
|||
.knowledge(knowledge == null ? null : toKnowledgeView(knowledge)) |
|||
.reviewSummary(buildReviewSummary(companyId)) |
|||
.build(); |
|||
} |
|||
|
|||
private CompanyKnowledge saveKnowledge(CompanyKnowledge knowledge) { |
|||
try { |
|||
return companyKnowledgeRepository.saveAndFlush(knowledge); |
|||
} catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) { |
|||
throw new BusinessException( |
|||
ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE, ex); |
|||
} |
|||
} |
|||
|
|||
private boolean matchesContent(CompanyKnowledge knowledge, KnowledgeContent content) { |
|||
return Objects.equals(knowledge.getCulture(), content.culture()) |
|||
&& Objects.equals(knowledge.getWorkPolicies(), content.workPolicies()) |
|||
&& Objects.equals(knowledge.getOnboardingGuide(), content.onboardingGuide()); |
|||
} |
|||
|
|||
private void lockCompany(Company company) { |
|||
if (entityManager == null || company == null || company.getId() == null) { |
|||
return; |
|||
} |
|||
entityManager.lock(company, LockModeType.PESSIMISTIC_WRITE); |
|||
} |
|||
|
|||
private KnowledgeContent validateContent(CompanyKnowledgeUpdateRequest request) { |
|||
if (request == null) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
if (request.getVersion() != null && request.getVersion() < 0) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
|
|||
String culture = normalizeText(request.getCulture(), MAX_CULTURE_LENGTH); |
|||
String workPolicies = normalizeText( |
|||
request.getWorkPolicies(), MAX_WORK_POLICIES_LENGTH); |
|||
String onboardingGuide = normalizeText( |
|||
request.getOnboardingGuide(), MAX_ONBOARDING_GUIDE_LENGTH); |
|||
|
|||
if (culture == null && workPolicies == null && onboardingGuide == null) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
return new KnowledgeContent(culture, workPolicies, onboardingGuide); |
|||
} |
|||
|
|||
private String normalizeText(String raw, int maxLength) { |
|||
if (!StringUtils.hasText(raw)) { |
|||
return null; |
|||
} |
|||
String value = raw.trim(); |
|||
if (value.length() > maxLength) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
if (HTML_TAG_PATTERN.matcher(value).find()) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
return value; |
|||
} |
|||
|
|||
private ReviewSummaryView buildReviewSummary(Long companyId) { |
|||
List<Object[]> countRows = |
|||
jobReviewRepository.countAndAveragePublishedRatingByCompanyId(companyId); |
|||
long totalReviews = extractCount(countRows); |
|||
|
|||
if (totalReviews == 0) { |
|||
return ReviewSummaryView.builder() |
|||
.state(EMPTY) |
|||
.totalReviews(0) |
|||
.build(); |
|||
} |
|||
if (totalReviews < MINIMUM_PUBLIC_REVIEWS) { |
|||
return ReviewSummaryView.builder() |
|||
.state(INSUFFICIENT) |
|||
.totalReviews(Math.toIntExact(totalReviews)) |
|||
.build(); |
|||
} |
|||
|
|||
return ReviewSummaryView.builder() |
|||
.state(SUFFICIENT) |
|||
.totalReviews(Math.toIntExact(totalReviews)) |
|||
.averageRating(extractAverageRating(countRows)) |
|||
.ratingDistribution(buildRatingDistribution(companyId)) |
|||
.topTags(buildTopTags(companyId)) |
|||
.dimensionSummary(buildDimensionSummary(companyId)) |
|||
.build(); |
|||
} |
|||
|
|||
private long extractCount(List<Object[]> rows) { |
|||
if (rows == null || rows.isEmpty() || rows.get(0) == null |
|||
|| rows.get(0).length == 0 || !(rows.get(0)[0] instanceof Number count)) { |
|||
return 0L; |
|||
} |
|||
return count.longValue(); |
|||
} |
|||
|
|||
private Double extractAverageRating(List<Object[]> rows) { |
|||
if (rows == null || rows.isEmpty() || rows.get(0) == null |
|||
|| rows.get(0).length < 2 || !(rows.get(0)[1] instanceof Number average)) { |
|||
return 0.0; |
|||
} |
|||
return BigDecimal.valueOf(average.doubleValue()) |
|||
.setScale(1, RoundingMode.HALF_UP) |
|||
.doubleValue(); |
|||
} |
|||
|
|||
private Map<String, Integer> buildRatingDistribution(Long companyId) { |
|||
Map<String, Integer> distribution = new LinkedHashMap<>(); |
|||
for (int rating = 1; rating <= 5; rating++) { |
|||
distribution.put("rating" + rating, 0); |
|||
} |
|||
|
|||
List<Object[]> rows = |
|||
jobReviewRepository.findPublishedRatingDistributionByCompanyId(companyId); |
|||
if (rows == null) { |
|||
return distribution; |
|||
} |
|||
for (Object[] row : rows) { |
|||
if (row == null || row.length < 2 |
|||
|| !(row[0] instanceof Number rating) |
|||
|| !(row[1] instanceof Number count)) { |
|||
continue; |
|||
} |
|||
int ratingValue = rating.intValue(); |
|||
if (ratingValue >= 1 && ratingValue <= 5) { |
|||
distribution.put("rating" + ratingValue, count.intValue()); |
|||
} |
|||
} |
|||
return distribution; |
|||
} |
|||
|
|||
private List<String> buildTopTags(Long companyId) { |
|||
Map<String, Long> counts = new HashMap<>(); |
|||
List<String> rawTags = jobReviewRepository.findPublishedTagsByCompanyId(companyId); |
|||
if (rawTags != null) { |
|||
for (String rawTag : rawTags) { |
|||
for (String tag : parseTags(rawTag)) { |
|||
counts.merge(tag, 1L, Long::sum); |
|||
} |
|||
} |
|||
} |
|||
|
|||
return counts.entrySet().stream() |
|||
.sorted((left, right) -> { |
|||
int byCount = Long.compare(right.getValue(), left.getValue()); |
|||
return byCount != 0 ? byCount : left.getKey().compareTo(right.getKey()); |
|||
}) |
|||
.limit(MAX_TOP_TAGS) |
|||
.map(Map.Entry::getKey) |
|||
.toList(); |
|||
} |
|||
|
|||
private List<String> parseTags(String raw) { |
|||
if (!StringUtils.hasText(raw)) { |
|||
return List.of(); |
|||
} |
|||
Object parsed = parseJson(raw); |
|||
for (int depth = 0; depth < 2 && parsed instanceof String nested; depth++) { |
|||
parsed = parseJson(nested); |
|||
} |
|||
if (!(parsed instanceof Collection<?> values)) { |
|||
return List.of(); |
|||
} |
|||
|
|||
List<String> tags = new ArrayList<>(); |
|||
for (Object value : values) { |
|||
if (value instanceof String tag && StringUtils.hasText(tag)) { |
|||
tags.add(tag.trim()); |
|||
} |
|||
} |
|||
return tags; |
|||
} |
|||
|
|||
private Object parseJson(String raw) { |
|||
try { |
|||
return OBJECT_MAPPER.readValue(raw, Object.class); |
|||
} catch (JsonProcessingException ex) { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
private Map<String, ReviewSummaryView.DimensionSummaryView> buildDimensionSummary( |
|||
Long companyId) { |
|||
Map<String, Object[]> rowsByCode = new HashMap<>(); |
|||
List<Object[]> rows = |
|||
dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(companyId); |
|||
if (rows != null) { |
|||
for (Object[] row : rows) { |
|||
if (row != null && row.length >= 3 && row[0] instanceof String code) { |
|||
rowsByCode.put(code, row); |
|||
} |
|||
} |
|||
} |
|||
|
|||
Map<String, ReviewSummaryView.DimensionSummaryView> result = new LinkedHashMap<>(); |
|||
for (ReviewDimension dimension : ReviewDimension.values()) { |
|||
Object[] row = rowsByCode.get(dimension.name()); |
|||
long sampleCount = 0L; |
|||
Double averageScore = null; |
|||
if (row != null && row[2] instanceof Number count) { |
|||
sampleCount = count.longValue(); |
|||
} |
|||
if (row != null && row[1] instanceof Number average) { |
|||
averageScore = BigDecimal.valueOf(average.doubleValue()) |
|||
.setScale(1, RoundingMode.HALF_UP) |
|||
.doubleValue(); |
|||
} |
|||
result.put(dimension.name(), ReviewSummaryView.DimensionSummaryView.builder() |
|||
.averageScore(averageScore) |
|||
.sampleCount(sampleCount) |
|||
.dataState(sampleCount >= MINIMUM_PUBLIC_REVIEWS |
|||
? SUFFICIENT : INSUFFICIENT) |
|||
.build()); |
|||
} |
|||
return result; |
|||
} |
|||
|
|||
private CompanyKnowledgeView toConfiguredView(CompanyKnowledge knowledge) { |
|||
return CompanyKnowledgeView.builder() |
|||
.configured(true) |
|||
.culture(knowledge.getCulture()) |
|||
.workPolicies(knowledge.getWorkPolicies()) |
|||
.onboardingGuide(knowledge.getOnboardingGuide()) |
|||
.updatedAt(knowledge.getUpdatedAt()) |
|||
.version(knowledge.getVersion() == null ? 0L : knowledge.getVersion()) |
|||
.build(); |
|||
} |
|||
|
|||
private CompanyKnowledgeOverviewResponse.KnowledgeView toKnowledgeView( |
|||
CompanyKnowledge knowledge) { |
|||
return CompanyKnowledgeOverviewResponse.KnowledgeView.builder() |
|||
.culture(knowledge.getCulture()) |
|||
.workPolicies(knowledge.getWorkPolicies()) |
|||
.onboardingGuide(knowledge.getOnboardingGuide()) |
|||
.updatedAt(knowledge.getUpdatedAt()) |
|||
.build(); |
|||
} |
|||
|
|||
private CompanyKnowledgeOverviewResponse.CompanyView toCompanyView(Company company) { |
|||
return CompanyKnowledgeOverviewResponse.CompanyView.builder() |
|||
.id(company.getId()) |
|||
.name(company.getName()) |
|||
.logoUrl(company.getLogoUrl()) |
|||
.description(company.getDescription()) |
|||
.industry(company.getIndustry()) |
|||
.website(company.getWebsite()) |
|||
.address(company.getAddress()) |
|||
.verified(company.getVerified()) |
|||
.build(); |
|||
} |
|||
|
|||
private Company requireOwnedCompany(Long userId) { |
|||
assertCompanyRole(userId); |
|||
return com.mydreamjob.company.VerifiedCompanyGuard.requireVerified( |
|||
companyRepository.findByOwnerUserId(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND))); |
|||
} |
|||
|
|||
private void assertCompanyRole(Long userId) { |
|||
User user = userRepository.findById(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
if (user.getRoles() == null |
|||
|| user.getRoles().stream().noneMatch(role -> role == User.Role.COMPANY)) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
} |
|||
|
|||
private void assertStudentRole(Long userId) { |
|||
User user = userRepository.findById(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
if (user.getRoles() == null |
|||
|| user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
} |
|||
|
|||
private record KnowledgeContent( |
|||
String culture, |
|||
String workPolicies, |
|||
String onboardingGuide) { |
|||
} |
|||
} |
|||
@ -0,0 +1,120 @@ |
|||
package com.mydreamjob.onboarding; |
|||
|
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest; |
|||
import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; |
|||
import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; |
|||
import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; |
|||
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; |
|||
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; |
|||
import io.swagger.v3.oas.annotations.Operation; |
|||
import io.swagger.v3.oas.annotations.tags.Tag; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import jakarta.validation.Valid; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.security.core.Authentication; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
import org.springframework.web.bind.annotation.DeleteMapping; |
|||
import org.springframework.web.bind.annotation.GetMapping; |
|||
import org.springframework.web.bind.annotation.PostMapping; |
|||
import org.springframework.web.bind.annotation.PathVariable; |
|||
import org.springframework.web.bind.annotation.PutMapping; |
|||
import org.springframework.web.bind.annotation.RequestBody; |
|||
import org.springframework.web.bind.annotation.RequestMapping; |
|||
import org.springframework.web.bind.annotation.RequestParam; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
|
|||
import java.util.List; |
|||
|
|||
|
|||
@Tag(name = "入职清单", description = "学生读取有效入职清单并更新本人完成状态") |
|||
@RestController |
|||
@RequestMapping("/onboarding") |
|||
@RequiredArgsConstructor |
|||
public class OnboardingChecklistController { |
|||
|
|||
private final OnboardingChecklistService onboardingChecklistService; |
|||
|
|||
@Operation(summary = "读取有效入职清单", description = "返回通用项与企业活动自定义项的合并结果") |
|||
@GetMapping("/companies/{companyId}/checklist") |
|||
public ResponseEntity<ApiResponse<EffectiveChecklistResponse>> getEffectiveChecklist( |
|||
@PathVariable Long companyId, |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
EffectiveChecklistResponse data = |
|||
onboardingChecklistService.getEffectiveChecklist(userId, companyId); |
|||
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "更新清单条目完成状态", description = "使用绝对状态语义设置当前学生的完成状态") |
|||
@PutMapping("/companies/{companyId}/checklist/items/{itemId}/completion") |
|||
public ResponseEntity<ApiResponse<ProgressUpdateResponse>> updateCompletion( |
|||
@PathVariable Long companyId, |
|||
@PathVariable Long itemId, |
|||
@Valid @RequestBody ProgressUpdateRequest body, |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
ProgressUpdateResponse data = onboardingChecklistService.updateCompletion( |
|||
userId, companyId, itemId, body); |
|||
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "查询本企业自定义清单项", description = "返回活动与软删除项及当前版本") |
|||
@GetMapping("/company/checklist-items") |
|||
public ResponseEntity<ApiResponse<List<CompanyChecklistItemView>>> listCompanyChecklistItems( |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
List<CompanyChecklistItemView> data = |
|||
onboardingChecklistService.listCompanyChecklistItems(userId); |
|||
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "创建本企业自定义清单项", description = "支持 idempotencyKey 重放与冲突检测") |
|||
@PostMapping("/company/checklist-items") |
|||
public ResponseEntity<ApiResponse<CompanyChecklistItemView>> createCompanyChecklistItem( |
|||
@Valid @RequestBody CompanyChecklistItemCreateRequest body, |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
CompanyChecklistItemView data = |
|||
onboardingChecklistService.createCompanyChecklistItem(userId, body); |
|||
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "更新本企业自定义清单项", description = "使用 version 执行乐观锁控制") |
|||
@PutMapping("/company/checklist-items/{itemId}") |
|||
public ResponseEntity<ApiResponse<CompanyChecklistItemView>> updateCompanyChecklistItem( |
|||
@PathVariable Long itemId, |
|||
@Valid @RequestBody CompanyChecklistItemUpdateRequest body, |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
CompanyChecklistItemView data = |
|||
onboardingChecklistService.updateCompanyChecklistItem(userId, itemId, body); |
|||
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "软删除本企业自定义清单项", description = "重复删除保持幂等成功") |
|||
@DeleteMapping("/company/checklist-items/{itemId}") |
|||
public ResponseEntity<ApiResponse<CompanyChecklistItemView>> deleteCompanyChecklistItem( |
|||
@PathVariable Long itemId, |
|||
@RequestParam Long version, |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
CompanyChecklistItemView data = |
|||
onboardingChecklistService.deleteCompanyChecklistItem(userId, itemId, version); |
|||
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
|||
} |
|||
private Long resolveUserId() { |
|||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); |
|||
if (authentication == null || !authentication.isAuthenticated()) { |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
} |
|||
return Long.valueOf(authentication.getName()); |
|||
} |
|||
|
|||
private String getRequestId(HttpServletRequest request) { |
|||
return request.getHeader("X-Request-Id"); |
|||
} |
|||
} |
|||
@ -0,0 +1,95 @@ |
|||
package com.mydreamjob.onboarding; |
|||
|
|||
import jakarta.persistence.Column; |
|||
import jakarta.persistence.Entity; |
|||
import jakarta.persistence.EntityListeners; |
|||
import jakarta.persistence.GeneratedValue; |
|||
import jakarta.persistence.GenerationType; |
|||
import jakarta.persistence.Id; |
|||
import jakarta.persistence.Table; |
|||
import jakarta.persistence.UniqueConstraint; |
|||
import jakarta.persistence.Version; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
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 = "onboarding_checklist_items", |
|||
uniqueConstraints = { |
|||
@UniqueConstraint( |
|||
name = "uk_onboarding_checklist_scope_item", |
|||
columnNames = {"scope_type", "scope_id", "item_key"}), |
|||
@UniqueConstraint( |
|||
name = "uk_onboarding_checklist_company_idempotency", |
|||
columnNames = {"company_id", "idempotency_key"}) |
|||
}) |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class OnboardingChecklistItem { |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "scope_type", nullable = false, length = 20) |
|||
private String scopeType; |
|||
|
|||
@Column(name = "scope_id", nullable = false) |
|||
private Long scopeId; |
|||
|
|||
@Column(name = "company_id") |
|||
private Long companyId; |
|||
|
|||
@Column(name = "item_key", nullable = false, length = 80) |
|||
private String itemKey; |
|||
|
|||
@Column(nullable = false, length = 120) |
|||
private String title; |
|||
|
|||
@Column(length = 1000) |
|||
private String description; |
|||
|
|||
@Column(nullable = false, length = 30) |
|||
private String category; |
|||
|
|||
@Column(name = "sort_order", nullable = false) |
|||
@Builder.Default |
|||
private Integer sortOrder = 1000; |
|||
|
|||
@Column(nullable = false) |
|||
@Builder.Default |
|||
private Boolean required = false; |
|||
|
|||
@Column(nullable = false) |
|||
@Builder.Default |
|||
private Boolean active = true; |
|||
|
|||
@Column(name = "idempotency_key", length = 64) |
|||
private String idempotencyKey; |
|||
|
|||
@Column(name = "request_hash", columnDefinition = "CHAR(64)") |
|||
private String requestHash; |
|||
|
|||
@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,39 @@ |
|||
package com.mydreamjob.onboarding; |
|||
|
|||
import jakarta.persistence.LockModeType; |
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.data.jpa.repository.Lock; |
|||
import org.springframework.data.jpa.repository.Query; |
|||
import org.springframework.data.repository.query.Param; |
|||
import org.springframework.stereotype.Repository; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Repository |
|||
public interface OnboardingChecklistItemRepository extends JpaRepository<OnboardingChecklistItem, Long> { |
|||
|
|||
@Query(""" |
|||
SELECT item |
|||
FROM OnboardingChecklistItem item |
|||
WHERE item.active = true |
|||
AND ( |
|||
item.scopeType = 'STANDARD' |
|||
OR (item.scopeType = 'COMPANY' |
|||
AND item.scopeId = :companyId |
|||
AND item.companyId = :companyId) |
|||
) |
|||
ORDER BY item.sortOrder ASC, item.id ASC |
|||
""") |
|||
List<OnboardingChecklistItem> findEffectiveChecklistItems(@Param("companyId") Long companyId); |
|||
|
|||
@Lock(LockModeType.PESSIMISTIC_WRITE) |
|||
@Query(""" |
|||
SELECT item |
|||
FROM OnboardingChecklistItem item |
|||
WHERE item.scopeType = 'COMPANY' |
|||
AND item.scopeId = :companyId |
|||
AND item.companyId = :companyId |
|||
ORDER BY item.id ASC |
|||
""") |
|||
List<OnboardingChecklistItem> findCompanyItemsForUpdate(@Param("companyId") Long companyId); |
|||
} |
|||
@ -0,0 +1,561 @@ |
|||
package com.mydreamjob.onboarding; |
|||
|
|||
import jakarta.persistence.EntityManager; |
|||
import jakarta.persistence.LockModeType; |
|||
import jakarta.persistence.PersistenceContext; |
|||
|
|||
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.CompanyRepository; |
|||
import com.mydreamjob.onboarding.dto.ChecklistItemView; |
|||
import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest; |
|||
import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; |
|||
import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; |
|||
import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; |
|||
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; |
|||
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.dao.DataIntegrityViolationException; |
|||
import org.springframework.orm.ObjectOptimisticLockingFailureException; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Isolation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.nio.charset.StandardCharsets; |
|||
import java.security.MessageDigest; |
|||
import java.security.NoSuchAlgorithmException; |
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.Comparator; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.HexFormat; |
|||
import java.util.Objects; |
|||
import java.util.Set; |
|||
import java.util.UUID; |
|||
import java.util.function.Function; |
|||
import java.util.stream.Collectors; |
|||
|
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class OnboardingChecklistService { |
|||
|
|||
private static final String STANDARD_SOURCE = "STANDARD"; |
|||
private static final String COMPANY_SOURCE = "COMPANY"; |
|||
private static final int DEFAULT_SORT_ORDER = 1000; |
|||
private static final int MAX_ACTIVE_COMPANY_ITEMS = 50; |
|||
private static final int MAX_TITLE_LENGTH = 120; |
|||
private static final int MAX_DESCRIPTION_LENGTH = 1000; |
|||
private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 64; |
|||
private static final Set<String> ALLOWED_CATEGORIES = Set.of( |
|||
"DOCUMENT", "ADMIN", "EQUIPMENT", "TRAINING", "LOGISTICS", "OTHER"); |
|||
|
|||
private final UserRepository userRepository; |
|||
private final CompanyRepository companyRepository; |
|||
private final OnboardingChecklistItemRepository checklistItemRepository; |
|||
private final StudentOnboardingProgressRepository progressRepository; |
|||
|
|||
@PersistenceContext |
|||
private EntityManager entityManager; |
|||
|
|||
@Transactional(readOnly = true) |
|||
public EffectiveChecklistResponse getEffectiveChecklist(Long userId, Long companyId) { |
|||
assertStudentRole(userId); |
|||
Company company = companyRepository.findById(companyId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); |
|||
|
|||
List<OnboardingChecklistItem> items = sortedItems( |
|||
checklistItemRepository.findEffectiveChecklistItems(companyId)); |
|||
List<StudentOnboardingProgress> progressRows = loadProgress(userId, items); |
|||
return toEffectiveChecklistResponse(company, items, progressRows); |
|||
} |
|||
|
|||
@Transactional |
|||
public ProgressUpdateResponse updateCompletion( |
|||
Long userId, |
|||
Long companyId, |
|||
Long itemId, |
|||
ProgressUpdateRequest request) { |
|||
assertStudentRole(userId); |
|||
lockUser(userId); |
|||
if (request == null || request.getCompleted() == null) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
|
|||
companyRepository.findById(companyId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); |
|||
|
|||
List<OnboardingChecklistItem> items = sortedItems( |
|||
checklistItemRepository.findEffectiveChecklistItems(companyId)); |
|||
OnboardingChecklistItem item = items.stream() |
|||
.filter(candidate -> Objects.equals(candidate.getId(), itemId)) |
|||
.findFirst() |
|||
.orElseThrow(() -> new BusinessException( |
|||
ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND)); |
|||
|
|||
List<StudentOnboardingProgress> progressRows = |
|||
new ArrayList<>(loadProgress(userId, items)); |
|||
StudentOnboardingProgress current = |
|||
progressRepository.findByStudentUserIdAndItemId(userId, itemId).orElse(null); |
|||
boolean desired = Boolean.TRUE.equals(request.getCompleted()); |
|||
|
|||
if (current == null |
|||
&& request.getVersion() != null |
|||
&& request.getVersion() != 0L) { |
|||
throw new BusinessException( |
|||
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE); |
|||
} |
|||
|
|||
if (current != null) { |
|||
boolean actual = Boolean.TRUE.equals(current.getCompleted()); |
|||
if (actual != desired) { |
|||
if (!Objects.equals(request.getVersion(), current.getVersion())) { |
|||
throw new BusinessException( |
|||
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE); |
|||
} |
|||
current.setCompleted(desired); |
|||
current.setCompletedAt(desired ? LocalDateTime.now() : null); |
|||
saveProgress(current); |
|||
} |
|||
} else if (desired) { |
|||
current = StudentOnboardingProgress.builder() |
|||
.studentUserId(userId) |
|||
.itemId(itemId) |
|||
.completed(true) |
|||
.completedAt(LocalDateTime.now()) |
|||
.build(); |
|||
progressRows.add(current); |
|||
saveProgress(current); |
|||
} |
|||
|
|||
return toProgressUpdateResponse(item, current, items, progressRows); |
|||
} |
|||
|
|||
@Transactional(readOnly = true) |
|||
public List<CompanyChecklistItemView> listCompanyChecklistItems(Long userId) { |
|||
Company company = requireCompanyOwner(userId); |
|||
return ownedCompanyItems(checklistItemRepository.findAll(), company.getId()).stream() |
|||
.sorted(Comparator |
|||
.comparing(OnboardingChecklistItem::getSortOrder, |
|||
Comparator.nullsLast(Integer::compareTo)) |
|||
.thenComparing(OnboardingChecklistItem::getId, |
|||
Comparator.nullsLast(Long::compareTo))) |
|||
.map(this::toCompanyChecklistItemView) |
|||
.toList(); |
|||
} |
|||
|
|||
@Transactional(isolation = Isolation.READ_COMMITTED) |
|||
public CompanyChecklistItemView createCompanyChecklistItem( |
|||
Long userId, |
|||
CompanyChecklistItemCreateRequest request) { |
|||
Company company = requireCompanyOwner(userId); |
|||
validateCompanyItemCreateRequest(request); |
|||
lockCompany(company); |
|||
|
|||
String title = normalizeOptional(request.getTitle()); |
|||
String description = normalizeOptional(request.getDescription()); |
|||
String category = request.getCategory(); |
|||
int sortOrder = request.getSortOrder() == null |
|||
? DEFAULT_SORT_ORDER : request.getSortOrder(); |
|||
boolean required = Boolean.TRUE.equals(request.getRequired()); |
|||
String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); |
|||
String requestHash = hashCompanyItemRequest( |
|||
title, description, category, sortOrder, required); |
|||
|
|||
List<OnboardingChecklistItem> companyItems = |
|||
checklistItemRepository.findCompanyItemsForUpdate(company.getId()); |
|||
if (idempotencyKey != null) { |
|||
OnboardingChecklistItem existing = companyItems.stream() |
|||
.filter(item -> Objects.equals(item.getIdempotencyKey(), idempotencyKey)) |
|||
.findFirst() |
|||
.orElse(null); |
|||
if (existing != null) { |
|||
if (Objects.equals(existing.getRequestHash(), requestHash)) { |
|||
return toCompanyChecklistItemView(existing); |
|||
} |
|||
throw new BusinessException(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT); |
|||
} |
|||
} |
|||
|
|||
long activeCount = companyItems.stream() |
|||
.filter(item -> Boolean.TRUE.equals(item.getActive())) |
|||
.count(); |
|||
if (activeCount >= MAX_ACTIVE_COMPANY_ITEMS) { |
|||
throw new BusinessException(ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED); |
|||
} |
|||
|
|||
OnboardingChecklistItem item = OnboardingChecklistItem.builder() |
|||
.scopeType(COMPANY_SOURCE) |
|||
.scopeId(company.getId()) |
|||
.companyId(company.getId()) |
|||
.itemKey("company-" + company.getId() + "-" + UUID.randomUUID()) |
|||
.title(title) |
|||
.description(description) |
|||
.category(category) |
|||
.sortOrder(sortOrder) |
|||
.required(required) |
|||
.active(true) |
|||
.idempotencyKey(idempotencyKey) |
|||
.requestHash(requestHash) |
|||
.build(); |
|||
return toCompanyChecklistItemView(saveNewCompanyItem(item)); |
|||
} |
|||
|
|||
@Transactional |
|||
public CompanyChecklistItemView updateCompanyChecklistItem( |
|||
Long userId, |
|||
Long itemId, |
|||
CompanyChecklistItemUpdateRequest request) { |
|||
Company company = requireCompanyOwner(userId); |
|||
validateCompanyItemUpdateRequest(request); |
|||
lockCompany(company); |
|||
OnboardingChecklistItem item = requireOwnedCompanyItem(itemId, company.getId()); |
|||
if (!Objects.equals(item.getVersion(), request.getVersion())) { |
|||
throw new BusinessException( |
|||
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE); |
|||
} |
|||
|
|||
item.setTitle(normalizeOptional(request.getTitle())); |
|||
item.setDescription(normalizeOptional(request.getDescription())); |
|||
item.setCategory(request.getCategory()); |
|||
item.setSortOrder(request.getSortOrder()); |
|||
item.setRequired(request.getRequired()); |
|||
item.setActive(request.getActive()); |
|||
return toCompanyChecklistItemView(saveCompanyItem(item)); |
|||
} |
|||
|
|||
@Transactional |
|||
public CompanyChecklistItemView deleteCompanyChecklistItem( |
|||
Long userId, |
|||
Long itemId, |
|||
Long version) { |
|||
Company company = requireCompanyOwner(userId); |
|||
lockCompany(company); |
|||
OnboardingChecklistItem item = requireOwnedCompanyItem(itemId, company.getId()); |
|||
if (Boolean.TRUE.equals(item.getActive())) { |
|||
if (!Objects.equals(item.getVersion(), version)) { |
|||
throw new BusinessException( |
|||
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE); |
|||
} |
|||
item.setActive(false); |
|||
item = saveCompanyItem(item); |
|||
} |
|||
return toCompanyChecklistItemView(item); |
|||
} |
|||
|
|||
private Company requireCompanyOwner(Long userId) { |
|||
User user = userRepository.findById(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
boolean isCompany = user.getRoles() != null |
|||
&& user.getRoles().stream().anyMatch(role -> role == User.Role.COMPANY); |
|||
if (!isCompany) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
return com.mydreamjob.company.VerifiedCompanyGuard.requireVerified( |
|||
companyRepository.findByOwnerUserId(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND))); |
|||
} |
|||
|
|||
private List<OnboardingChecklistItem> ownedCompanyItems( |
|||
List<OnboardingChecklistItem> items, |
|||
Long companyId) { |
|||
if (items == null || items.isEmpty()) { |
|||
return List.of(); |
|||
} |
|||
return items.stream() |
|||
.filter(item -> belongsToCompany(item, companyId)) |
|||
.toList(); |
|||
} |
|||
|
|||
private boolean belongsToCompany(OnboardingChecklistItem item, Long companyId) { |
|||
return item != null |
|||
&& COMPANY_SOURCE.equals(item.getScopeType()) |
|||
&& Objects.equals(item.getScopeId(), companyId) |
|||
&& Objects.equals(item.getCompanyId(), companyId); |
|||
} |
|||
|
|||
private OnboardingChecklistItem requireOwnedCompanyItem(Long itemId, Long companyId) { |
|||
return checklistItemRepository.findById(itemId) |
|||
.filter(item -> belongsToCompany(item, companyId)) |
|||
.orElseThrow(() -> new BusinessException( |
|||
ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND)); |
|||
} |
|||
|
|||
private void validateCompanyItemCreateRequest(CompanyChecklistItemCreateRequest request) { |
|||
if (request == null) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
String title = normalizeOptional(request.getTitle()); |
|||
if (title == null || title.length() > MAX_TITLE_LENGTH) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
String description = normalizeOptional(request.getDescription()); |
|||
if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
if (!ALLOWED_CATEGORIES.contains(request.getCategory())) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
Integer sortOrder = request.getSortOrder(); |
|||
if (sortOrder != null && (sortOrder < 0 || sortOrder > 9999)) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); |
|||
if (idempotencyKey != null && idempotencyKey.length() > MAX_IDEMPOTENCY_KEY_LENGTH) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
} |
|||
|
|||
private void validateCompanyItemUpdateRequest(CompanyChecklistItemUpdateRequest request) { |
|||
if (request == null) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
String title = normalizeOptional(request.getTitle()); |
|||
if (title == null || title.length() > MAX_TITLE_LENGTH) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
String description = normalizeOptional(request.getDescription()); |
|||
if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
if (!ALLOWED_CATEGORIES.contains(request.getCategory()) |
|||
|| request.getSortOrder() == null |
|||
|| request.getSortOrder() < 0 |
|||
|| request.getSortOrder() > 9999 |
|||
|| request.getRequired() == null |
|||
|| request.getActive() == null |
|||
|| request.getVersion() == null |
|||
|| request.getVersion() < 0) { |
|||
throw new BusinessException(ErrorCode.BAD_REQUEST); |
|||
} |
|||
} |
|||
|
|||
private String normalizeOptional(String value) { |
|||
if (value == null) { |
|||
return null; |
|||
} |
|||
String trimmed = value.trim(); |
|||
return trimmed.isEmpty() ? null : trimmed; |
|||
} |
|||
|
|||
private String normalizeIdempotencyKey(String value) { |
|||
return normalizeOptional(value); |
|||
} |
|||
|
|||
private String hashCompanyItemRequest( |
|||
String title, |
|||
String description, |
|||
String category, |
|||
int sortOrder, |
|||
boolean required) { |
|||
String canonical = "title=" + title.length() + ":" + title |
|||
+ "|description=" + (description == null |
|||
? "null" : description.length() + ":" + description) |
|||
+ "|category=" + category |
|||
+ "|sortOrder=" + sortOrder |
|||
+ "|required=" + required; |
|||
return sha256(canonical); |
|||
} |
|||
|
|||
private String sha256(String value) { |
|||
try { |
|||
MessageDigest digest = MessageDigest.getInstance("SHA-256"); |
|||
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); |
|||
} catch (NoSuchAlgorithmException ex) { |
|||
throw new IllegalStateException("SHA-256 not available", ex); |
|||
} |
|||
} |
|||
|
|||
private CompanyChecklistItemView toCompanyChecklistItemView(OnboardingChecklistItem item) { |
|||
return CompanyChecklistItemView.builder() |
|||
.itemId(item.getId()) |
|||
.source(COMPANY_SOURCE) |
|||
.itemKey(item.getItemKey()) |
|||
.title(item.getTitle()) |
|||
.description(item.getDescription()) |
|||
.category(item.getCategory()) |
|||
.required(item.getRequired()) |
|||
.sortOrder(item.getSortOrder()) |
|||
.active(item.getActive()) |
|||
.version(item.getVersion() == null ? 0L : item.getVersion()) |
|||
.build(); |
|||
} |
|||
|
|||
private OnboardingChecklistItem saveNewCompanyItem(OnboardingChecklistItem item) { |
|||
try { |
|||
return checklistItemRepository.saveAndFlush(item); |
|||
} catch (DataIntegrityViolationException ex) { |
|||
throw new BusinessException(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT, ex); |
|||
} |
|||
} |
|||
|
|||
private void lockCompany(Company company) { |
|||
if (entityManager == null || company == null || company.getId() == null) { |
|||
return; |
|||
} |
|||
entityManager.lock(company, LockModeType.PESSIMISTIC_WRITE); |
|||
} |
|||
|
|||
private void lockUser(Long userId) { |
|||
if (entityManager == null) { |
|||
return; |
|||
} |
|||
User user = entityManager.find(User.class, userId, LockModeType.PESSIMISTIC_WRITE); |
|||
if (user == null) { |
|||
throw new BusinessException(ErrorCode.USER_NOT_FOUND); |
|||
} |
|||
} |
|||
|
|||
private OnboardingChecklistItem saveCompanyItem(OnboardingChecklistItem item) { |
|||
try { |
|||
return checklistItemRepository.saveAndFlush(item); |
|||
} catch (ObjectOptimisticLockingFailureException ex) { |
|||
throw new BusinessException( |
|||
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE, ex); |
|||
} |
|||
} |
|||
|
|||
private void assertStudentRole(Long userId) { |
|||
User user = userRepository.findById(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
boolean isStudent = user.getRoles() != null |
|||
&& user.getRoles().stream().anyMatch(role -> role == User.Role.STUDENT); |
|||
if (!isStudent) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
} |
|||
|
|||
private List<OnboardingChecklistItem> sortedItems(List<OnboardingChecklistItem> items) { |
|||
if (items == null || items.isEmpty()) { |
|||
return List.of(); |
|||
} |
|||
List<OnboardingChecklistItem> sorted = new ArrayList<>(items); |
|||
sorted.sort(Comparator |
|||
.comparing( |
|||
OnboardingChecklistItem::getSortOrder, |
|||
Comparator.nullsLast(Integer::compareTo)) |
|||
.thenComparing( |
|||
OnboardingChecklistItem::getId, |
|||
Comparator.nullsLast(Long::compareTo))); |
|||
return sorted; |
|||
} |
|||
|
|||
private List<StudentOnboardingProgress> loadProgress( |
|||
Long userId, List<OnboardingChecklistItem> items) { |
|||
if (items.isEmpty()) { |
|||
return List.of(); |
|||
} |
|||
List<Long> itemIds = items.stream() |
|||
.map(OnboardingChecklistItem::getId) |
|||
.filter(Objects::nonNull) |
|||
.toList(); |
|||
if (itemIds.isEmpty()) { |
|||
return List.of(); |
|||
} |
|||
return progressRepository.findByStudentUserIdAndItemIdIn(userId, itemIds); |
|||
} |
|||
|
|||
private EffectiveChecklistResponse toEffectiveChecklistResponse( |
|||
Company company, |
|||
List<OnboardingChecklistItem> items, |
|||
List<StudentOnboardingProgress> progressRows) { |
|||
Map<Long, StudentOnboardingProgress> progressByItemId = progressRows.stream() |
|||
.collect(Collectors.toMap( |
|||
StudentOnboardingProgress::getItemId, |
|||
Function.identity(), |
|||
(first, ignored) -> first)); |
|||
|
|||
List<ChecklistItemView> views = items.stream() |
|||
.map(item -> toChecklistItemView(item, progressByItemId.get(item.getId()))) |
|||
.toList(); |
|||
|
|||
int totalItems = views.size(); |
|||
int completedItems = completedCount(items, progressRows); |
|||
return EffectiveChecklistResponse.builder() |
|||
.companyId(company.getId()) |
|||
.companyName(company.getName()) |
|||
.customized(items.stream() |
|||
.anyMatch(item -> COMPANY_SOURCE.equals(item.getScopeType()))) |
|||
.totalItems(totalItems) |
|||
.completedItems(completedItems) |
|||
.completionRate(completionRate(totalItems, completedItems)) |
|||
.items(views) |
|||
.build(); |
|||
|
|||
} |
|||
|
|||
private ProgressUpdateResponse toProgressUpdateResponse( |
|||
OnboardingChecklistItem item, |
|||
StudentOnboardingProgress progress, |
|||
List<OnboardingChecklistItem> items, |
|||
List<StudentOnboardingProgress> progressRows) { |
|||
int totalItems = items.size(); |
|||
int completedItems = completedCount(items, progressRows); |
|||
boolean completed = progress != null && Boolean.TRUE.equals(progress.getCompleted()); |
|||
return ProgressUpdateResponse.builder() |
|||
.itemId(item.getId()) |
|||
.completed(completed) |
|||
.completedAt(completed && progress != null ? progress.getCompletedAt() : null) |
|||
.progressVersion(progress == null || progress.getVersion() == null |
|||
? 0L : progress.getVersion()) |
|||
.totalItems(totalItems) |
|||
.completedItems(completedItems) |
|||
.completionRate(completionRate(totalItems, completedItems)) |
|||
.build(); |
|||
} |
|||
|
|||
private ChecklistItemView toChecklistItemView( |
|||
OnboardingChecklistItem item, |
|||
StudentOnboardingProgress progress) { |
|||
boolean completed = progress != null && Boolean.TRUE.equals(progress.getCompleted()); |
|||
return ChecklistItemView.builder() |
|||
.itemId(item.getId()) |
|||
.source(STANDARD_SOURCE.equals(item.getScopeType()) |
|||
? STANDARD_SOURCE : COMPANY_SOURCE) |
|||
.itemKey(item.getItemKey()) |
|||
.title(item.getTitle()) |
|||
.description(item.getDescription()) |
|||
.category(item.getCategory()) |
|||
.required(item.getRequired()) |
|||
.sortOrder(item.getSortOrder()) |
|||
.active(item.getActive()) |
|||
.completed(completed) |
|||
.completedAt(completed && progress != null ? progress.getCompletedAt() : null) |
|||
.progressVersion(progress == null || progress.getVersion() == null |
|||
? 0L : progress.getVersion()) |
|||
.build(); |
|||
} |
|||
|
|||
private int completedCount( |
|||
List<OnboardingChecklistItem> items, |
|||
List<StudentOnboardingProgress> progressRows) { |
|||
var effectiveItemIds = items.stream() |
|||
.map(OnboardingChecklistItem::getId) |
|||
.filter(Objects::nonNull) |
|||
.collect(Collectors.toSet()); |
|||
return (int) progressRows.stream() |
|||
.filter(progress -> effectiveItemIds.contains(progress.getItemId())) |
|||
.filter(progress -> Boolean.TRUE.equals(progress.getCompleted())) |
|||
.count(); |
|||
} |
|||
|
|||
private double completionRate(int totalItems, int completedItems) { |
|||
if (totalItems == 0) { |
|||
return 0.0; |
|||
} |
|||
double rawRate = completedItems * 100.0 / totalItems; |
|||
return Math.min(100.0, Math.round(rawRate * 10.0) / 10.0); |
|||
} |
|||
|
|||
private void saveProgress(StudentOnboardingProgress progress) { |
|||
try { |
|||
progressRepository.saveAndFlush(progress); |
|||
} catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) { |
|||
throw new BusinessException( |
|||
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE, ex); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,64 @@ |
|||
package com.mydreamjob.onboarding; |
|||
|
|||
import jakarta.persistence.Column; |
|||
import jakarta.persistence.Entity; |
|||
import jakarta.persistence.EntityListeners; |
|||
import jakarta.persistence.GeneratedValue; |
|||
import jakarta.persistence.GenerationType; |
|||
import jakarta.persistence.Id; |
|||
import jakarta.persistence.Table; |
|||
import jakarta.persistence.UniqueConstraint; |
|||
import jakarta.persistence.Version; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
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_onboarding_progress", |
|||
uniqueConstraints = @UniqueConstraint( |
|||
name = "uk_student_onboarding_progress_student_item", |
|||
columnNames = {"student_user_id", "item_id"})) |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class StudentOnboardingProgress { |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "student_user_id", nullable = false) |
|||
private Long studentUserId; |
|||
|
|||
@Column(name = "item_id", nullable = false) |
|||
private Long itemId; |
|||
|
|||
@Column(nullable = false) |
|||
@Builder.Default |
|||
private Boolean completed = false; |
|||
|
|||
@Column(name = "completed_at") |
|||
private LocalDateTime completedAt; |
|||
|
|||
@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,19 @@ |
|||
package com.mydreamjob.onboarding; |
|||
|
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.stereotype.Repository; |
|||
|
|||
import java.util.Collection; |
|||
import java.util.List; |
|||
import java.util.Optional; |
|||
|
|||
@Repository |
|||
public interface StudentOnboardingProgressRepository |
|||
extends JpaRepository<StudentOnboardingProgress, Long> { |
|||
|
|||
List<StudentOnboardingProgress> findByStudentUserIdAndItemIdIn( |
|||
Long studentUserId, Collection<Long> itemIds); |
|||
|
|||
Optional<StudentOnboardingProgress> findByStudentUserIdAndItemId( |
|||
Long studentUserId, Long itemId); |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
package com.mydreamjob.onboarding.dto; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
public class ChecklistItemView { |
|||
|
|||
private Long itemId; |
|||
private String source; |
|||
private String itemKey; |
|||
private String title; |
|||
private String description; |
|||
private String category; |
|||
private Boolean required; |
|||
private Integer sortOrder; |
|||
private Boolean active; |
|||
private Boolean completed; |
|||
private LocalDateTime completedAt; |
|||
private Long progressVersion; |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
package com.mydreamjob.onboarding.dto; |
|||
|
|||
import jakarta.validation.constraints.Max; |
|||
import jakarta.validation.constraints.Min; |
|||
import jakarta.validation.constraints.NotBlank; |
|||
import jakarta.validation.constraints.Pattern; |
|||
import jakarta.validation.constraints.Size; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
public class CompanyChecklistItemCreateRequest { |
|||
|
|||
@NotBlank(message = "title is required") |
|||
@Size(max = 120, message = "title must be at most 120 chars") |
|||
private String title; |
|||
|
|||
@Size(max = 1000, message = "description must be at most 1000 chars") |
|||
private String description; |
|||
|
|||
@NotBlank(message = "category is required") |
|||
@Pattern( |
|||
regexp = "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER", |
|||
message = "category is invalid") |
|||
private String category; |
|||
|
|||
@Min(value = 0, message = "sortOrder must not be negative") |
|||
@Max(value = 9999, message = "sortOrder must be at most 9999") |
|||
private Integer sortOrder = 1000; |
|||
|
|||
private Boolean required = false; |
|||
|
|||
@Size(max = 64, message = "idempotencyKey must be at most 64 chars") |
|||
private String idempotencyKey; |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
package com.mydreamjob.onboarding.dto; |
|||
|
|||
import jakarta.validation.constraints.Max; |
|||
import jakarta.validation.constraints.Min; |
|||
import jakarta.validation.constraints.NotBlank; |
|||
import jakarta.validation.constraints.NotNull; |
|||
import jakarta.validation.constraints.Pattern; |
|||
import jakarta.validation.constraints.Size; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
public class CompanyChecklistItemUpdateRequest { |
|||
|
|||
@NotBlank(message = "title is required") |
|||
@Size(max = 120, message = "title must be at most 120 chars") |
|||
private String title; |
|||
|
|||
@Size(max = 1000, message = "description must be at most 1000 chars") |
|||
private String description; |
|||
|
|||
@NotBlank(message = "category is required") |
|||
@Pattern( |
|||
regexp = "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER", |
|||
message = "category is invalid") |
|||
private String category; |
|||
|
|||
@NotNull(message = "sortOrder is required") |
|||
@Min(value = 0, message = "sortOrder must not be negative") |
|||
@Max(value = 9999, message = "sortOrder must be at most 9999") |
|||
private Integer sortOrder; |
|||
|
|||
@NotNull(message = "required is required") |
|||
private Boolean required; |
|||
|
|||
@NotNull(message = "active is required") |
|||
private Boolean active; |
|||
|
|||
@NotNull(message = "version is required") |
|||
@Min(value = 0, message = "version must not be negative") |
|||
private Long version; |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package com.mydreamjob.onboarding.dto; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
public class CompanyChecklistItemView { |
|||
|
|||
private Long itemId; |
|||
private String source; |
|||
private String itemKey; |
|||
private String title; |
|||
private String description; |
|||
private String category; |
|||
private Boolean required; |
|||
private Integer sortOrder; |
|||
private Boolean active; |
|||
private Long version; |
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
package com.mydreamjob.onboarding.dto; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonInclude; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@JsonInclude(JsonInclude.Include.NON_NULL) |
|||
public class CompanyKnowledgeOverviewResponse { |
|||
|
|||
private CompanyView company; |
|||
private String knowledgeState; |
|||
private KnowledgeView knowledge; |
|||
private ReviewSummaryView reviewSummary; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@JsonInclude(JsonInclude.Include.NON_NULL) |
|||
public static class CompanyView { |
|||
|
|||
private Long id; |
|||
private String name; |
|||
private String logoUrl; |
|||
private String description; |
|||
private String industry; |
|||
private String website; |
|||
private String address; |
|||
private Boolean verified; |
|||
} |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@JsonInclude(JsonInclude.Include.NON_NULL) |
|||
public static class KnowledgeView { |
|||
|
|||
private String culture; |
|||
private String workPolicies; |
|||
private String onboardingGuide; |
|||
private LocalDateTime updatedAt; |
|||
private Long version; |
|||
} |
|||
} |
|||
@ -0,0 +1,21 @@ |
|||
package com.mydreamjob.onboarding.dto; |
|||
|
|||
import jakarta.validation.constraints.Min; |
|||
import jakarta.validation.constraints.Size; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
public class CompanyKnowledgeUpdateRequest { |
|||
|
|||
@Size(max = 2000, message = "culture must be at most 2000 chars") |
|||
private String culture; |
|||
|
|||
@Size(max = 4000, message = "workPolicies must be at most 4000 chars") |
|||
private String workPolicies; |
|||
|
|||
@Size(max = 4000, message = "onboardingGuide must be at most 4000 chars") |
|||
private String onboardingGuide; |
|||
|
|||
@Min(value = 0, message = "version must not be negative") |
|||
private Long version; |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package com.mydreamjob.onboarding.dto; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonInclude; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@JsonInclude(JsonInclude.Include.NON_NULL) |
|||
public class CompanyKnowledgeView { |
|||
|
|||
private Boolean configured; |
|||
private String culture; |
|||
private String workPolicies; |
|||
private String onboardingGuide; |
|||
private LocalDateTime updatedAt; |
|||
private Long version; |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
package com.mydreamjob.onboarding.dto; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
public class EffectiveChecklistResponse { |
|||
|
|||
private Long companyId; |
|||
private String companyName; |
|||
private Boolean customized; |
|||
private Integer totalItems; |
|||
private Integer completedItems; |
|||
private Double completionRate; |
|||
private List<ChecklistItemView> items; |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
package com.mydreamjob.onboarding.dto; |
|||
|
|||
import jakarta.validation.constraints.Min; |
|||
import jakarta.validation.constraints.NotNull; |
|||
import lombok.Data; |
|||
|
|||
@Data |
|||
public class ProgressUpdateRequest { |
|||
|
|||
@NotNull(message = "completed is required") |
|||
private Boolean completed; |
|||
|
|||
@Min(value = 0, message = "version must not be negative") |
|||
private Long version; |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
package com.mydreamjob.onboarding.dto; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.time.LocalDateTime; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
public class ProgressUpdateResponse { |
|||
|
|||
private Long itemId; |
|||
private Boolean completed; |
|||
private LocalDateTime completedAt; |
|||
private Long progressVersion; |
|||
private Integer totalItems; |
|||
private Integer completedItems; |
|||
private Double completionRate; |
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
package com.mydreamjob.onboarding.dto; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonInclude; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@JsonInclude(JsonInclude.Include.NON_NULL) |
|||
public class ReviewSummaryView { |
|||
|
|||
private String state; |
|||
private int totalReviews; |
|||
private Double averageRating; |
|||
private Map<String, Integer> ratingDistribution; |
|||
private List<String> topTags; |
|||
private Map<String, DimensionSummaryView> dimensionSummary; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@JsonInclude(JsonInclude.Include.NON_NULL) |
|||
public static class DimensionSummaryView { |
|||
|
|||
private Double averageScore; |
|||
private long sampleCount; |
|||
private String dataState; |
|||
} |
|||
} |
|||
@ -0,0 +1,91 @@ |
|||
package com.mydreamjob.salarycalculator; |
|||
|
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.api.PageResponse; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; |
|||
import com.mydreamjob.salarycalculator.dto.SalaryCalculationResponse; |
|||
import com.mydreamjob.salarycalculator.dto.SalaryHistoryDetailResponse; |
|||
import com.mydreamjob.salarycalculator.dto.SalaryHistorySummaryResponse; |
|||
import io.swagger.v3.oas.annotations.Operation; |
|||
import io.swagger.v3.oas.annotations.tags.Tag; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import jakarta.validation.Valid; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.security.core.Authentication; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
import org.springframework.web.bind.annotation.DeleteMapping; |
|||
import org.springframework.web.bind.annotation.GetMapping; |
|||
import org.springframework.web.bind.annotation.PathVariable; |
|||
import org.springframework.web.bind.annotation.PostMapping; |
|||
import org.springframework.web.bind.annotation.RequestBody; |
|||
import org.springframework.web.bind.annotation.RequestMapping; |
|||
import org.springframework.web.bind.annotation.RequestParam; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
|
|||
@Tag(name = "薪资计算器", description = "城市政策、到手工资估算与历史记录") |
|||
@RestController |
|||
@RequestMapping("/salary-calculator") |
|||
@RequiredArgsConstructor |
|||
public class SalaryCalculationController { |
|||
|
|||
private final SalaryCalculationService calculationService; |
|||
private final SalaryHistoryService historyService; |
|||
|
|||
@Operation(summary = "计算并保存到手工资", description = "仅 STUDENT;未发布政策返回 404/10601,幂等重放不可绕过发布门禁;本人历史查询不受影响") |
|||
@PostMapping("/calculations") |
|||
public ResponseEntity<ApiResponse<SalaryCalculationResponse>> calculate( |
|||
@Valid @RequestBody SalaryCalculationRequest body, HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
return ResponseEntity.ok(ApiResponse.ok( |
|||
calculationService.calculateAndSave(userId, body), getRequestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "查询本人薪资历史", description = "仅 STUDENT;按创建时间倒序分页") |
|||
@GetMapping("/calculations") |
|||
public ResponseEntity<ApiResponse<PageResponse<SalaryHistorySummaryResponse>>> list( |
|||
@RequestParam(defaultValue = "0") int page, |
|||
@RequestParam(defaultValue = "20") int size, |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
return ResponseEntity.ok(ApiResponse.ok( |
|||
historyService.list(userId, page, size), getRequestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "查询本人薪资历史详情", description = "仅 STUDENT;返回完整快照") |
|||
@GetMapping("/calculations/{id}") |
|||
public ResponseEntity<ApiResponse<SalaryHistoryDetailResponse>> get( |
|||
@PathVariable Long id, HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
return ResponseEntity.ok(ApiResponse.ok( |
|||
historyService.get(userId, id), getRequestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "删除本人薪资历史", description = "仅 STUDENT;物理删除") |
|||
@DeleteMapping("/calculations/{id}") |
|||
public ResponseEntity<ApiResponse<Void>> delete( |
|||
@PathVariable Long id, HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
historyService.delete(userId, id); |
|||
return ResponseEntity.ok(ApiResponse.ok(null, getRequestId(request))); |
|||
} |
|||
|
|||
private Long resolveUserId() { |
|||
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
|||
if (auth == null || !auth.isAuthenticated() |
|||
|| "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) { |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
} |
|||
try { |
|||
return Long.valueOf(auth.getName()); |
|||
} catch (NumberFormatException ex) { |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
} |
|||
} |
|||
|
|||
private String getRequestId(HttpServletRequest request) { |
|||
return request.getHeader("X-Request-Id"); |
|||
} |
|||
} |
|||
@ -0,0 +1,262 @@ |
|||
package com.mydreamjob.salarycalculator; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.math.RoundingMode; |
|||
import java.util.List; |
|||
|
|||
@Component |
|||
public class SalaryCalculationEngine { |
|||
|
|||
public static final String TAX_RULES_VERSION = "CN_IIT_2019_V1"; |
|||
|
|||
private static final BigDecimal ZERO = new BigDecimal("0"); |
|||
private static final BigDecimal MONTHLY_BASIC_DEDUCTION = new BigDecimal("5000"); |
|||
private static final BigDecimal MAX_INPUT_AMOUNT = new BigDecimal("10000000"); |
|||
private static final BigDecimal MAX_ILLNESS_FEE = new BigDecimal("10000"); |
|||
private static final BigDecimal MAX_PENSION_RATE = new BigDecimal("0.20"); |
|||
private static final BigDecimal MAX_MEDICAL_RATE = new BigDecimal("0.20"); |
|||
private static final BigDecimal MAX_UNEMPLOYMENT_RATE = new BigDecimal("0.10"); |
|||
private static final BigDecimal MAX_HOUSING_FUND_RATE = new BigDecimal("0.12"); |
|||
private static final int MONEY_SCALE = 2; |
|||
|
|||
private static final List<TaxBracket> ANNUAL_BRACKETS = List.of( |
|||
new TaxBracket(new BigDecimal("36000"), new BigDecimal("0.03"), new BigDecimal("0")), |
|||
new TaxBracket(new BigDecimal("144000"), new BigDecimal("0.10"), new BigDecimal("2520")), |
|||
new TaxBracket(new BigDecimal("300000"), new BigDecimal("0.20"), new BigDecimal("16920")), |
|||
new TaxBracket(new BigDecimal("420000"), new BigDecimal("0.25"), new BigDecimal("31920")), |
|||
new TaxBracket(new BigDecimal("660000"), new BigDecimal("0.30"), new BigDecimal("52920")), |
|||
new TaxBracket(new BigDecimal("960000"), new BigDecimal("0.35"), new BigDecimal("85920")), |
|||
new TaxBracket(null, new BigDecimal("0.45"), new BigDecimal("181920"))); |
|||
|
|||
public SalaryCalculationResult calculate(SalaryCalculationInput input, SalaryCityPolicy policy) { |
|||
if (input == null || policy == null) { |
|||
throw invalidParameter("input and city policy are required"); |
|||
} |
|||
int monthIndex = input.getMonthIndex() == null ? 1 : input.getMonthIndex(); |
|||
BigDecimal gross = requiredMoney(input.getGrossMonthlySalary(), "grossMonthlySalary"); |
|||
BigDecimal specialDeduction = optionalMoney( |
|||
input.getSpecialAdditionalDeduction(), "specialAdditionalDeduction", MAX_INPUT_AMOUNT); |
|||
SalaryOverrideParameters overrides = input.getOverrides(); |
|||
|
|||
if (gross.compareTo(ZERO) <= 0 || gross.compareTo(MAX_INPUT_AMOUNT) > 0) { |
|||
throw invalidParameter("grossMonthlySalary must be within (0, 10000000]"); |
|||
} |
|||
if (monthIndex < 1 || monthIndex > 12) { |
|||
throw invalidParameter("monthIndex must be between 1 and 12"); |
|||
} |
|||
|
|||
BigDecimal pensionRate = effectiveRate( |
|||
overrides == null ? null : overrides.getPensionRate(), |
|||
policy.getPensionRate(), MAX_PENSION_RATE, "pensionRate"); |
|||
BigDecimal medicalRate = effectiveRate( |
|||
overrides == null ? null : overrides.getMedicalRate(), |
|||
policy.getMedicalRate(), MAX_MEDICAL_RATE, "medicalRate"); |
|||
BigDecimal unemploymentRate = effectiveRate( |
|||
overrides == null ? null : overrides.getUnemploymentRate(), |
|||
policy.getUnemploymentRate(), MAX_UNEMPLOYMENT_RATE, "unemploymentRate"); |
|||
BigDecimal housingFundRate = effectiveRate( |
|||
overrides == null ? null : overrides.getHousingFundRate(), |
|||
policy.getHousingFundRate(), MAX_HOUSING_FUND_RATE, "housingFundRate"); |
|||
BigDecimal illnessFee = effectiveFee( |
|||
overrides == null ? null : overrides.getSeriousIllnessMonthlyFee(), |
|||
policy.getSeriousIllnessMonthlyFee()); |
|||
|
|||
BigDecimal socialSecurityBase = effectiveBase( |
|||
overrides == null ? null : overrides.getSocialSecurityBase(), |
|||
gross, |
|||
policy.getSocialSecurityBaseMin(), |
|||
policy.getSocialSecurityBaseMax(), |
|||
"socialSecurityBase"); |
|||
BigDecimal housingFundBase = effectiveBase( |
|||
overrides == null ? null : overrides.getHousingFundBase(), |
|||
gross, |
|||
policy.getHousingFundBaseMin(), |
|||
policy.getHousingFundBaseMax(), |
|||
"housingFundBase"); |
|||
|
|||
if (socialSecurityBase.compareTo(ZERO) == 0 |
|||
&& (pensionRate.signum() != 0 || medicalRate.signum() != 0 |
|||
|| unemploymentRate.signum() != 0)) { |
|||
throw invalidParameter("socialSecurityBase may be zero only when social insurance rates are zero"); |
|||
} |
|||
if (housingFundBase.compareTo(ZERO) == 0 && housingFundRate.signum() != 0) { |
|||
throw invalidParameter("housingFundBase may be zero only when housingFundRate is zero"); |
|||
} |
|||
|
|||
BigDecimal pension = socialSecurityBase.multiply(pensionRate); |
|||
BigDecimal medical = socialSecurityBase.multiply(medicalRate); |
|||
BigDecimal unemployment = socialSecurityBase.multiply(unemploymentRate); |
|||
BigDecimal housingFund = housingFundBase.multiply(housingFundRate); |
|||
BigDecimal monthlySocialInsurance = pension.add(medical).add(unemployment); |
|||
|
|||
BigDecimal cumulativeGross = gross.multiply(BigDecimal.valueOf(monthIndex)); |
|||
BigDecimal cumulativeSocialInsurance = monthlySocialInsurance.multiply(BigDecimal.valueOf(monthIndex)); |
|||
BigDecimal cumulativeHousingFund = housingFund.multiply(BigDecimal.valueOf(monthIndex)); |
|||
BigDecimal cumulativeIllness = illnessFee.multiply(BigDecimal.valueOf(monthIndex)); |
|||
BigDecimal cumulativeBasicDeduction = MONTHLY_BASIC_DEDUCTION.multiply(BigDecimal.valueOf(monthIndex)); |
|||
BigDecimal cumulativeSpecialDeduction = specialDeduction.multiply(BigDecimal.valueOf(monthIndex)); |
|||
|
|||
BigDecimal cumulativeTaxable = nonNegative(cumulativeGross |
|||
.subtract(cumulativeSocialInsurance) |
|||
.subtract(cumulativeHousingFund) |
|||
.subtract(cumulativeIllness) |
|||
.subtract(cumulativeBasicDeduction) |
|||
.subtract(cumulativeSpecialDeduction)); |
|||
BigDecimal cumulativeTaxDue = taxFor(cumulativeTaxable); |
|||
|
|||
int priorMonths = monthIndex - 1; |
|||
BigDecimal priorTaxable = nonNegative(gross.multiply(BigDecimal.valueOf(priorMonths)) |
|||
.subtract(monthlySocialInsurance.multiply(BigDecimal.valueOf(priorMonths))) |
|||
.subtract(housingFund.multiply(BigDecimal.valueOf(priorMonths))) |
|||
.subtract(illnessFee.multiply(BigDecimal.valueOf(priorMonths))) |
|||
.subtract(MONTHLY_BASIC_DEDUCTION.multiply(BigDecimal.valueOf(priorMonths))) |
|||
.subtract(specialDeduction.multiply(BigDecimal.valueOf(priorMonths)))); |
|||
BigDecimal priorTax = taxFor(priorTaxable); |
|||
BigDecimal currentTax = nonNegative(cumulativeTaxDue.subtract(priorTax)); |
|||
BigDecimal netSalary = nonNegative(gross |
|||
.subtract(pension) |
|||
.subtract(medical) |
|||
.subtract(unemployment) |
|||
.subtract(housingFund) |
|||
.subtract(illnessFee) |
|||
.subtract(currentTax)); |
|||
|
|||
return SalaryCalculationResult.builder() |
|||
.cityCode(policy.getCityCode()) |
|||
.cityName(policy.getCityName()) |
|||
.dataLevel(policy.getDataLevel()) |
|||
.policyVersion(policy.getPolicyVersion()) |
|||
.policyEffectiveDate(policy.getPolicyEffectiveDate()) |
|||
.taxRulesVersion(TAX_RULES_VERSION) |
|||
.estimate(true) |
|||
.monthIndex(monthIndex) |
|||
.grossMonthlySalary(money(gross)) |
|||
.specialAdditionalDeduction(money(specialDeduction)) |
|||
.socialSecurityBase(money(socialSecurityBase)) |
|||
.housingFundBase(money(housingFundBase)) |
|||
.pensionRate(rate(pensionRate)) |
|||
.medicalRate(rate(medicalRate)) |
|||
.unemploymentRate(rate(unemploymentRate)) |
|||
.housingFundRate(rate(housingFundRate)) |
|||
.seriousIllnessMonthlyFee(money(illnessFee)) |
|||
.pension(money(pension)) |
|||
.medical(money(medical)) |
|||
.unemployment(money(unemployment)) |
|||
.housingFund(money(housingFund)) |
|||
.cumulativeGrossIncome(money(cumulativeGross)) |
|||
.cumulativeSocialInsurance(money(cumulativeSocialInsurance)) |
|||
.cumulativeHousingFund(money(cumulativeHousingFund)) |
|||
.cumulativeSeriousIllness(money(cumulativeIllness)) |
|||
.cumulativeBasicDeduction(money(cumulativeBasicDeduction)) |
|||
.cumulativeSpecialAdditionalDeduction(money(cumulativeSpecialDeduction)) |
|||
.cumulativeTaxableIncome(money(cumulativeTaxable)) |
|||
.cumulativeTaxDue(money(cumulativeTaxDue)) |
|||
.previouslyWithheldTax(money(priorTax)) |
|||
.currentMonthTax(money(currentTax)) |
|||
.netSalary(money(netSalary)) |
|||
.build(); |
|||
} |
|||
|
|||
private BigDecimal effectiveBase( |
|||
BigDecimal override, BigDecimal gross, BigDecimal min, BigDecimal max, String field) { |
|||
validatePolicyRange(min, max, field); |
|||
if (override != null) { |
|||
BigDecimal value = requiredMoney(override, field); |
|||
if (value.compareTo(ZERO) < 0) { |
|||
throw invalidParameter(field + " must not be negative"); |
|||
} |
|||
if (value.compareTo(ZERO) > 0 && (value.compareTo(min) < 0 || value.compareTo(max) > 0)) { |
|||
throw invalidParameter(field + " must be within city range or zero for an exempt contribution"); |
|||
} |
|||
return value; |
|||
} |
|||
return clamp(gross, min, max); |
|||
} |
|||
|
|||
private BigDecimal effectiveRate( |
|||
BigDecimal override, BigDecimal fallback, BigDecimal max, String field) { |
|||
BigDecimal value = override == null ? fallback : requiredRate(override, field); |
|||
if (value == null || value.compareTo(ZERO) < 0 || value.compareTo(max) > 0) { |
|||
throw invalidParameter(field + " is outside the allowed range"); |
|||
} |
|||
return value; |
|||
} |
|||
|
|||
private BigDecimal effectiveFee(BigDecimal override, BigDecimal fallback) { |
|||
BigDecimal value = override == null ? fallback : requiredMoney(override, "seriousIllnessMonthlyFee"); |
|||
if (value == null || value.compareTo(ZERO) < 0 || value.compareTo(MAX_ILLNESS_FEE) > 0) { |
|||
throw invalidParameter("seriousIllnessMonthlyFee is outside the allowed range"); |
|||
} |
|||
return value; |
|||
} |
|||
|
|||
private BigDecimal taxFor(BigDecimal taxableIncome) { |
|||
if (taxableIncome.compareTo(ZERO) <= 0) { |
|||
return ZERO; |
|||
} |
|||
for (TaxBracket bracket : ANNUAL_BRACKETS) { |
|||
if (bracket.upperBound == null || taxableIncome.compareTo(bracket.upperBound) <= 0) { |
|||
return nonNegative(taxableIncome.multiply(bracket.rate).subtract(bracket.quickDeduction)); |
|||
} |
|||
} |
|||
return ZERO; |
|||
} |
|||
|
|||
private BigDecimal clamp(BigDecimal value, BigDecimal min, BigDecimal max) { |
|||
if (value.compareTo(min) < 0) { |
|||
return min; |
|||
} |
|||
return value.compareTo(max) > 0 ? max : value; |
|||
} |
|||
|
|||
private void validatePolicyRange(BigDecimal min, BigDecimal max, String field) { |
|||
if (min == null || max == null || min.compareTo(ZERO) < 0 || max.compareTo(min) < 0) { |
|||
throw invalidParameter(field + " city range is invalid"); |
|||
} |
|||
} |
|||
|
|||
private BigDecimal requiredMoney(BigDecimal value, String field) { |
|||
if (value == null || value.scale() > MONEY_SCALE) { |
|||
throw invalidParameter(field + " must have at most two decimal places"); |
|||
} |
|||
return value; |
|||
} |
|||
|
|||
private BigDecimal optionalMoney(BigDecimal value, String field, BigDecimal max) { |
|||
BigDecimal effective = value == null ? ZERO : requiredMoney(value, field); |
|||
if (effective.compareTo(ZERO) < 0 || effective.compareTo(max) > 0) { |
|||
throw invalidParameter(field + " is outside the allowed range"); |
|||
} |
|||
return effective; |
|||
} |
|||
|
|||
private BigDecimal requiredRate(BigDecimal value, String field) { |
|||
if (value == null || value.scale() > 6) { |
|||
throw invalidParameter(field + " must have at most six decimal places"); |
|||
} |
|||
return value; |
|||
} |
|||
|
|||
private BigDecimal nonNegative(BigDecimal value) { |
|||
return value.compareTo(ZERO) < 0 ? ZERO : value; |
|||
} |
|||
|
|||
private BigDecimal money(BigDecimal value) { |
|||
return value.setScale(MONEY_SCALE, RoundingMode.HALF_UP); |
|||
} |
|||
|
|||
private BigDecimal rate(BigDecimal value) { |
|||
return value.setScale(6, RoundingMode.HALF_UP); |
|||
} |
|||
|
|||
private BusinessException invalidParameter(String detail) { |
|||
return new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, detail); |
|||
} |
|||
|
|||
private record TaxBracket(BigDecimal upperBound, BigDecimal rate, BigDecimal quickDeduction) { |
|||
} |
|||
} |
|||
@ -0,0 +1,86 @@ |
|||
package com.mydreamjob.salarycalculator; |
|||
|
|||
import jakarta.persistence.Column; |
|||
import jakarta.persistence.Entity; |
|||
import jakarta.persistence.EnumType; |
|||
import jakarta.persistence.Enumerated; |
|||
import jakarta.persistence.EntityListeners; |
|||
import jakarta.persistence.GeneratedValue; |
|||
import jakarta.persistence.GenerationType; |
|||
import jakarta.persistence.Id; |
|||
import jakarta.persistence.Table; |
|||
import jakarta.persistence.UniqueConstraint; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
import org.springframework.data.annotation.CreatedDate; |
|||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.time.LocalDateTime; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table( |
|||
name = "salary_calculation_history", |
|||
uniqueConstraints = @UniqueConstraint( |
|||
name = "uk_sch_student_idempotency", |
|||
columnNames = {"student_user_id", "idempotency_key"})) |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class SalaryCalculationHistory { |
|||
|
|||
@Id |
|||
@GeneratedValue(strategy = GenerationType.IDENTITY) |
|||
private Long id; |
|||
|
|||
@Column(name = "student_user_id", nullable = false) |
|||
private Long studentUserId; |
|||
|
|||
@Column(name = "city_code", nullable = false, length = 6) |
|||
private String cityCode; |
|||
|
|||
@Column(name = "city_name", nullable = false, length = 80) |
|||
private String cityName; |
|||
|
|||
@Enumerated(EnumType.STRING) |
|||
@Column(name = "data_level", nullable = false, length = 30) |
|||
private SalaryDataLevel dataLevel; |
|||
|
|||
@Column(name = "policy_version", nullable = false, length = 64) |
|||
private String policyVersion; |
|||
|
|||
@Column(name = "tax_rules_version", nullable = false, length = 64) |
|||
private String taxRulesVersion; |
|||
|
|||
@Column(name = "month_index", nullable = false) |
|||
private Integer monthIndex; |
|||
|
|||
@Column(name = "gross_monthly_salary", nullable = false, precision = 12, scale = 2) |
|||
private BigDecimal grossMonthlySalary; |
|||
|
|||
@Column(name = "net_salary", nullable = false, precision = 12, scale = 2) |
|||
private BigDecimal netSalary; |
|||
|
|||
@Column(name = "idempotency_key", length = 64) |
|||
private String idempotencyKey; |
|||
|
|||
@Column(name = "request_hash", columnDefinition = "CHAR(64)") |
|||
private String requestHash; |
|||
|
|||
@Column(name = "input_snapshot_json", nullable = false, columnDefinition = "TEXT") |
|||
private String inputSnapshotJson; |
|||
|
|||
@Column(name = "policy_snapshot_json", nullable = false, columnDefinition = "TEXT") |
|||
private String policySnapshotJson; |
|||
|
|||
@Column(name = "result_snapshot_json", nullable = false, columnDefinition = "TEXT") |
|||
private String resultSnapshotJson; |
|||
|
|||
@CreatedDate |
|||
@Column(name = "created_at", nullable = false, updatable = false) |
|||
private LocalDateTime createdAt; |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
package com.mydreamjob.salarycalculator; |
|||
|
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.data.domain.Pageable; |
|||
import org.springframework.data.jpa.repository.JpaRepository; |
|||
import org.springframework.stereotype.Repository; |
|||
|
|||
import java.util.Optional; |
|||
|
|||
@Repository |
|||
public interface SalaryCalculationHistoryRepository |
|||
extends JpaRepository<SalaryCalculationHistory, Long> { |
|||
|
|||
Page<SalaryCalculationHistory> findByStudentUserIdOrderByCreatedAtDescIdDesc( |
|||
Long studentUserId, Pageable pageable); |
|||
|
|||
Optional<SalaryCalculationHistory> findByIdAndStudentUserId(Long id, Long studentUserId); |
|||
|
|||
Optional<SalaryCalculationHistory> findByStudentUserIdAndIdempotencyKey( |
|||
Long studentUserId, String idempotencyKey); |
|||
|
|||
long deleteByStudentUserId(Long studentUserId); |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
package com.mydreamjob.salarycalculator; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.math.BigDecimal; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
public class SalaryCalculationInput { |
|||
private BigDecimal grossMonthlySalary; |
|||
private Integer monthIndex; |
|||
private BigDecimal specialAdditionalDeduction; |
|||
private SalaryOverrideParameters overrides; |
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
package com.mydreamjob.salarycalculator; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.time.LocalDate; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
public class SalaryCalculationResult { |
|||
|
|||
private String cityCode; |
|||
private String cityName; |
|||
private SalaryDataLevel dataLevel; |
|||
private String policyVersion; |
|||
private LocalDate policyEffectiveDate; |
|||
private String taxRulesVersion; |
|||
private boolean estimate; |
|||
|
|||
private Integer monthIndex; |
|||
private BigDecimal grossMonthlySalary; |
|||
private BigDecimal specialAdditionalDeduction; |
|||
|
|||
private BigDecimal socialSecurityBase; |
|||
private BigDecimal housingFundBase; |
|||
private BigDecimal pensionRate; |
|||
private BigDecimal medicalRate; |
|||
private BigDecimal unemploymentRate; |
|||
private BigDecimal housingFundRate; |
|||
private BigDecimal seriousIllnessMonthlyFee; |
|||
|
|||
private BigDecimal pension; |
|||
private BigDecimal medical; |
|||
private BigDecimal unemployment; |
|||
private BigDecimal housingFund; |
|||
|
|||
private BigDecimal cumulativeGrossIncome; |
|||
private BigDecimal cumulativeSocialInsurance; |
|||
private BigDecimal cumulativeHousingFund; |
|||
private BigDecimal cumulativeSeriousIllness; |
|||
private BigDecimal cumulativeBasicDeduction; |
|||
private BigDecimal cumulativeSpecialAdditionalDeduction; |
|||
private BigDecimal cumulativeTaxableIncome; |
|||
private BigDecimal cumulativeTaxDue; |
|||
private BigDecimal previouslyWithheldTax; |
|||
private BigDecimal currentMonthTax; |
|||
private BigDecimal netSalary; |
|||
} |
|||
@ -0,0 +1,190 @@ |
|||
package com.mydreamjob.salarycalculator; |
|||
|
|||
import com.fasterxml.jackson.core.JsonProcessingException; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
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.salarycalculator.dto.SalaryCalculationOverrideRequest; |
|||
import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; |
|||
import com.mydreamjob.salarycalculator.dto.SalaryCalculationResponse; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
import org.springframework.transaction.annotation.Isolation; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.util.LinkedHashMap; |
|||
import java.util.Map; |
|||
import java.util.Objects; |
|||
|
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class SalaryCalculationService { |
|||
|
|||
private final SalaryCityPolicyRepository cityPolicyRepository; |
|||
private final SalaryCalculationHistoryRepository historyRepository; |
|||
private final UserRepository userRepository; |
|||
private final SalaryCalculationEngine calculationEngine; |
|||
private final SalaryRequestFingerprint requestFingerprint; |
|||
private final ObjectMapper objectMapper; |
|||
private final SalaryPolicyPublicationProperties publication; |
|||
|
|||
@Transactional(isolation = Isolation.READ_COMMITTED) |
|||
public SalaryCalculationResponse calculateAndSave(Long userId, SalaryCalculationRequest request) { |
|||
String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); |
|||
String requestHash = idempotencyKey == null ? null : requestFingerprint.fingerprint(request); |
|||
// 在读取幂等记录之前锁定用户,跨实例串行化同一用户的写入;锁随事务结束释放。
|
|||
requireStudent(userId); |
|||
SalaryCityPolicy policy = cityPolicyRepository.findByCityCodeAndEnabledTrue(request.getCityCode()) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); |
|||
requirePublished(policy.getCityCode(), policy.getPolicyVersion()); |
|||
if (idempotencyKey != null) { |
|||
var existing = historyRepository.findByStudentUserIdAndIdempotencyKey(userId, idempotencyKey); |
|||
if (existing.isPresent()) { |
|||
requirePublished(existing.get().getCityCode(), existing.get().getPolicyVersion()); |
|||
return replay(existing.get(), requestHash); |
|||
} |
|||
} |
|||
|
|||
if (!Objects.equals(policy.getPolicyVersion(), request.getExpectedPolicyVersion())) { |
|||
throw new SalaryPolicyVersionConflictException( |
|||
policy.getPolicyVersion(), policy.getPolicyEffectiveDate()); |
|||
} |
|||
|
|||
SalaryCalculationResult result = calculationEngine.calculate(toEngineInput(request), policy); |
|||
SalaryCalculationHistory history = SalaryCalculationHistory.builder() |
|||
.studentUserId(userId) |
|||
.cityCode(policy.getCityCode()) |
|||
.cityName(policy.getCityName()) |
|||
.dataLevel(policy.getDataLevel()) |
|||
.policyVersion(policy.getPolicyVersion()) |
|||
.taxRulesVersion(result.getTaxRulesVersion()) |
|||
.monthIndex(result.getMonthIndex()) |
|||
.grossMonthlySalary(result.getGrossMonthlySalary()) |
|||
.netSalary(result.getNetSalary()) |
|||
.idempotencyKey(idempotencyKey) |
|||
.requestHash(requestHash) |
|||
.inputSnapshotJson(toJson(inputSnapshot(request))) |
|||
.policySnapshotJson(toJson(policySnapshot(policy, result))) |
|||
.resultSnapshotJson(toJson(result)) |
|||
.build(); |
|||
|
|||
history = historyRepository.saveAndFlush(history); |
|||
return toResponse(history); |
|||
} |
|||
|
|||
private SalaryCalculationResponse replay(SalaryCalculationHistory history, String requestHash) { |
|||
if (!Objects.equals(history.getRequestHash(), requestHash)) { |
|||
throw new BusinessException(ErrorCode.SALARY_IDEMPOTENCY_CONFLICT); |
|||
} |
|||
return toResponse(history); |
|||
} |
|||
|
|||
private void requirePublished(String cityCode, String policyVersion) { |
|||
if (!publication.isPublished(cityCode, policyVersion)) { |
|||
throw new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND); |
|||
} |
|||
} |
|||
|
|||
private SalaryCalculationResponse toResponse(SalaryCalculationHistory history) { |
|||
try { |
|||
SalaryCalculationResult result = objectMapper.readValue( |
|||
history.getResultSnapshotJson(), SalaryCalculationResult.class); |
|||
return SalaryCalculationResponse.builder() |
|||
.historyId(history.getId()) |
|||
.createdAt(history.getCreatedAt()) |
|||
.result(result) |
|||
.build(); |
|||
} catch (JsonProcessingException ex) { |
|||
throw new IllegalStateException("stored salary result snapshot is invalid", ex); |
|||
} |
|||
} |
|||
|
|||
private SalaryCalculationInput toEngineInput(SalaryCalculationRequest request) { |
|||
SalaryCalculationOverrideRequest source = request.getOverrides(); |
|||
SalaryOverrideParameters overrides = source == null ? null : SalaryOverrideParameters.builder() |
|||
.socialSecurityBase(source.getSocialSecurityBase()) |
|||
.housingFundBase(source.getHousingFundBase()) |
|||
.pensionRate(source.getPensionRate()) |
|||
.medicalRate(source.getMedicalRate()) |
|||
.unemploymentRate(source.getUnemploymentRate()) |
|||
.housingFundRate(source.getHousingFundRate()) |
|||
.seriousIllnessMonthlyFee(source.getSeriousIllnessMonthlyFee()) |
|||
.build(); |
|||
return SalaryCalculationInput.builder() |
|||
.grossMonthlySalary(request.getGrossMonthlySalary()) |
|||
.monthIndex(request.getMonthIndex()) |
|||
.specialAdditionalDeduction(request.getSpecialAdditionalDeduction()) |
|||
.overrides(overrides) |
|||
.build(); |
|||
} |
|||
|
|||
private Map<String, Object> inputSnapshot(SalaryCalculationRequest request) { |
|||
Map<String, Object> input = new LinkedHashMap<>(); |
|||
input.put("cityCode", request.getCityCode()); |
|||
input.put("grossMonthlySalary", request.getGrossMonthlySalary()); |
|||
input.put("monthIndex", request.getMonthIndex() == null ? 1 : request.getMonthIndex()); |
|||
input.put("specialAdditionalDeduction", request.getSpecialAdditionalDeduction()); |
|||
input.put("expectedPolicyVersion", request.getExpectedPolicyVersion()); |
|||
input.put("overrides", request.getOverrides()); |
|||
return input; |
|||
} |
|||
|
|||
private Map<String, Object> policySnapshot( |
|||
SalaryCityPolicy policy, SalaryCalculationResult result) { |
|||
Map<String, Object> snapshot = new LinkedHashMap<>(); |
|||
snapshot.put("cityCode", policy.getCityCode()); |
|||
snapshot.put("cityName", policy.getCityName()); |
|||
snapshot.put("dataLevel", policy.getDataLevel()); |
|||
snapshot.put("policyVersion", policy.getPolicyVersion()); |
|||
snapshot.put("policyEffectiveDate", policy.getPolicyEffectiveDate()); |
|||
snapshot.put("defaultParameters", Map.of( |
|||
"socialSecurityBaseMin", policy.getSocialSecurityBaseMin(), |
|||
"socialSecurityBaseMax", policy.getSocialSecurityBaseMax(), |
|||
"housingFundBaseMin", policy.getHousingFundBaseMin(), |
|||
"housingFundBaseMax", policy.getHousingFundBaseMax(), |
|||
"pensionRate", policy.getPensionRate(), |
|||
"medicalRate", policy.getMedicalRate(), |
|||
"unemploymentRate", policy.getUnemploymentRate(), |
|||
"housingFundRate", policy.getHousingFundRate(), |
|||
"seriousIllnessMonthlyFee", policy.getSeriousIllnessMonthlyFee())); |
|||
snapshot.put("effectiveParameters", Map.of( |
|||
"socialSecurityBase", result.getSocialSecurityBase(), |
|||
"housingFundBase", result.getHousingFundBase(), |
|||
"pensionRate", result.getPensionRate(), |
|||
"medicalRate", result.getMedicalRate(), |
|||
"unemploymentRate", result.getUnemploymentRate(), |
|||
"housingFundRate", result.getHousingFundRate(), |
|||
"seriousIllnessMonthlyFee", result.getSeriousIllnessMonthlyFee())); |
|||
return snapshot; |
|||
} |
|||
|
|||
private String toJson(Object value) { |
|||
try { |
|||
return objectMapper.writeValueAsString(value); |
|||
} catch (JsonProcessingException ex) { |
|||
throw new IllegalStateException("unable to serialize salary snapshot", ex); |
|||
} |
|||
} |
|||
|
|||
private String normalizeIdempotencyKey(String value) { |
|||
if (value == null || value.isBlank()) { |
|||
return null; |
|||
} |
|||
String normalized = value.trim(); |
|||
if (normalized.length() > 64) { |
|||
throw new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, "idempotencyKey is too long"); |
|||
} |
|||
return normalized; |
|||
} |
|||
|
|||
private void requireStudent(Long userId) { |
|||
User user = userRepository.findByIdForUpdate(userId) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
|||
if (user.getRoles() == null |
|||
|| user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,97 @@ |
|||
package com.mydreamjob.salarycalculator; |
|||
|
|||
import jakarta.persistence.Column; |
|||
import jakarta.persistence.Entity; |
|||
import jakarta.persistence.EnumType; |
|||
import jakarta.persistence.Enumerated; |
|||
import jakarta.persistence.EntityListeners; |
|||
import jakarta.persistence.Id; |
|||
import jakarta.persistence.Table; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
import org.springframework.data.annotation.CreatedDate; |
|||
import org.springframework.data.annotation.LastModifiedDate; |
|||
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.time.LocalDate; |
|||
import java.time.LocalDateTime; |
|||
|
|||
@Data |
|||
@Builder |
|||
@NoArgsConstructor |
|||
@AllArgsConstructor |
|||
@Entity |
|||
@Table(name = "salary_city_policy") |
|||
@EntityListeners(AuditingEntityListener.class) |
|||
public class SalaryCityPolicy { |
|||
|
|||
@Id |
|||
@Column(name = "city_code", nullable = false, length = 6) |
|||
private String cityCode; |
|||
|
|||
@Column(name = "province_code", nullable = false, length = 6) |
|||
private String provinceCode; |
|||
|
|||
@Column(name = "province_name", nullable = false, length = 40) |
|||
private String provinceName; |
|||
|
|||
@Column(name = "city_name", nullable = false, length = 80) |
|||
private String cityName; |
|||
|
|||
@Column(name = "policy_version", nullable = false, length = 64) |
|||
private String policyVersion; |
|||
|
|||
@Column(name = "policy_effective_date", nullable = false) |
|||
private LocalDate policyEffectiveDate; |
|||
|
|||
@Column(name = "data_source", length = 500) |
|||
private String dataSource; |
|||
|
|||
@Column(name = "social_security_base_min", nullable = false, precision = 12, scale = 2) |
|||
private BigDecimal socialSecurityBaseMin; |
|||
|
|||
@Column(name = "social_security_base_max", nullable = false, precision = 12, scale = 2) |
|||
private BigDecimal socialSecurityBaseMax; |
|||
|
|||
@Column(name = "housing_fund_base_min", nullable = false, precision = 12, scale = 2) |
|||
private BigDecimal housingFundBaseMin; |
|||
|
|||
@Column(name = "housing_fund_base_max", nullable = false, precision = 12, scale = 2) |
|||
private BigDecimal housingFundBaseMax; |
|||
|
|||
@Column(name = "pension_rate", nullable = false, precision = 7, scale = 6) |
|||
private BigDecimal pensionRate; |
|||
|
|||
@Column(name = "medical_rate", nullable = false, precision = 7, scale = 6) |
|||
private BigDecimal medicalRate; |
|||
|
|||
@Column(name = "unemployment_rate", nullable = false, precision = 7, scale = 6) |
|||
private BigDecimal unemploymentRate; |
|||
|
|||
@Column(name = "housing_fund_rate", nullable = false, precision = 7, scale = 6) |
|||
private BigDecimal housingFundRate; |
|||
|
|||
@Column(name = "serious_illness_monthly_fee", nullable = false, precision = 10, scale = 2) |
|||
@Builder.Default |
|||
private BigDecimal seriousIllnessMonthlyFee = BigDecimal.ZERO; |
|||
|
|||
@Enumerated(EnumType.STRING) |
|||
@Column(name = "data_level", nullable = false, length = 30) |
|||
@Builder.Default |
|||
private SalaryDataLevel dataLevel = SalaryDataLevel.CITY; |
|||
|
|||
@Column(nullable = false) |
|||
@Builder.Default |
|||
private Boolean enabled = true; |
|||
|
|||
@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,64 @@ |
|||
package com.mydreamjob.salarycalculator; |
|||
|
|||
import com.mydreamjob.common.api.ApiResponse; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.salarycalculator.dto.SalaryCityDetailResponse; |
|||
import com.mydreamjob.salarycalculator.dto.SalaryCitySummaryResponse; |
|||
import io.swagger.v3.oas.annotations.Operation; |
|||
import io.swagger.v3.oas.annotations.tags.Tag; |
|||
import jakarta.servlet.http.HttpServletRequest; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.http.ResponseEntity; |
|||
import org.springframework.security.core.Authentication; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
import org.springframework.web.bind.annotation.GetMapping; |
|||
import org.springframework.web.bind.annotation.PathVariable; |
|||
import org.springframework.web.bind.annotation.RequestMapping; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Tag(name = "薪资计算器", description = "城市政策、到手工资估算与历史记录") |
|||
@RestController |
|||
@RequestMapping("/salary-calculator") |
|||
@RequiredArgsConstructor |
|||
public class SalaryCityPolicyController { |
|||
|
|||
private final SalaryCityPolicyService cityPolicyService; |
|||
|
|||
@Operation(summary = "查询启用城市列表", description = "仅 STUDENT;仅返回已核验发布白名单中的启用城市版本;默认空列表,不分页") |
|||
@GetMapping("/cities") |
|||
public ResponseEntity<ApiResponse<List<SalaryCitySummaryResponse>>> listCities( |
|||
HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
return ResponseEntity.ok(ApiResponse.ok( |
|||
cityPolicyService.listCities(userId), getRequestId(request))); |
|||
} |
|||
|
|||
@Operation(summary = "查询城市默认参数", description = "仅 STUDENT;未发布或未启用政策返回 404/10601,不返回参数") |
|||
@GetMapping("/cities/{cityCode}") |
|||
public ResponseEntity<ApiResponse<SalaryCityDetailResponse>> getCity( |
|||
@PathVariable String cityCode, HttpServletRequest request) { |
|||
Long userId = resolveUserId(); |
|||
return ResponseEntity.ok(ApiResponse.ok( |
|||
cityPolicyService.getCity(userId, cityCode), getRequestId(request))); |
|||
} |
|||
|
|||
private Long resolveUserId() { |
|||
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
|||
if (auth == null || !auth.isAuthenticated() |
|||
|| "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) { |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
} |
|||
try { |
|||
return Long.valueOf(auth.getName()); |
|||
} catch (NumberFormatException ex) { |
|||
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
|||
} |
|||
} |
|||
|
|||
private String getRequestId(HttpServletRequest request) { |
|||
return request.getHeader("X-Request-Id"); |
|||
} |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue