diff --git a/README.md b/README.md
index 470d381..dbf4a31 100644
--- a/README.md
+++ b/README.md
@@ -12,10 +12,11 @@
| 自动化测试 | 1492 个执行:1481 通过、2 失败、9 错误;失败均在既有 CareerProgress/JobReviewDimension/LocalResumeStorage 测试,与 N04 文档变更无关 |
| 已验收范围 | V27–V34;V33 已完成真实 MySQL/Docker 黑盒验收 |
| N04 入职清单与公司知识介绍 | 实现完成,待真实 MySQL/Docker 黑盒验收 |
+| N05 薪资计算器与历史记录 | 实现完成,专项测试 24/24,待真实 MySQL/Docker 黑盒验收 |
| 账号注销冷静期 | 30 天 |
| 本地服务端口 | `8080` |
-N04 入职清单与公司知识介绍已完成代码、N04 专项测试、OpenAPI 和联调文档;全量 `mvn clean verify` 仍受既有非 N04 测试失败阻塞,待真实 MySQL/Docker 黑盒验收。V35 霍兰德职业兴趣测评目前仅完成设计,尚未进入编码和数据库迁移阶段。
+N04 入职清单与公司知识介绍、N05 薪资计算器与历史记录均已完成代码、专项测试、OpenAPI 和联调文档;N05 专项 24/24 通过。全量 `mvn clean verify` 仍受既有非 N05 测试失败阻塞,两个模块都待真实 MySQL/Docker 黑盒验收。
## 仓库结构
@@ -125,6 +126,7 @@ python .\docs\verify_openapi.py
- [前端联调说明](docs/frontend-integration.md)
- [后端开发路线图](docs/backend-development-roadmap.md)
- [入职清单与公司知识介绍](docs/onboarding-api.md)
+- [薪资计算器 API](docs/salary-calculator-api.md)
- [统一求职进度接口](docs/career-progress-api.md)
- [同岗位竞争力排名](docs/competition-ranking-api.md)
- [举报与屏蔽](docs/report-block-api.md)
@@ -172,3 +174,4 @@ python .\docs\verify_openapi.py
`C:\Users\15642\Desktop\青核\collegeCli`
青核项目不属于本仓库,禁止将其源代码提交到职来仓库。
+
diff --git a/changes/n05-salary-calc/.spec-superflow.yaml b/changes/n05-salary-calc/.spec-superflow.yaml
index 26a2229..457aa6c 100644
--- a/changes/n05-salary-calc/.spec-superflow.yaml
+++ b/changes/n05-salary-calc/.spec-superflow.yaml
@@ -14,7 +14,7 @@ contract_hash: sha256:c85abc5d5ddd79b03da8ff42816436f4389bb99a5739d4fcb2a960c3c8
execution_mode: sdd
execution_plan_hash: sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5
execution_plan_revision: 1
-batches_completed: 2
+batches_completed: 3
test_result: null
spec_merged: false
spec_publication_receipt: null
diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/5.md b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/5.md
new file mode 100644
index 0000000..d48ebcf
--- /dev/null
+++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/5.md
@@ -0,0 +1,37 @@
+---
+task_id: "5"
+task_hash: "sha256:3d4c3ad3efc8a298211c74dd6c8da2d95fe4a8c1269fc58f0672ccc5f148e3c6"
+next: "Task 6: history list/detail/delete"
+completed: "Calculation save, policy version and idempotency"
+evidence: "W3 suite 6/6 passed"
+review: ".superpowers/sdd/reviews/T5.md"
+risk: "Concurrent same-key race needs MySQL validation"
+commit_start: "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7"
+commit_end: "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b"
+created_at: "2026-09-20T08:34:50.804Z"
+plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5"
+plan_revision: 1
+---
+
+# Checkpoint: 5
+
+## Next
+Task 6: history list/detail/delete
+
+## Completed
+Calculation save, policy version and idempotency
+
+## Evidence
+W3 suite 6/6 passed
+
+## Review
+.superpowers/sdd/reviews/T5.md
+
+## Risk
+Concurrent same-key race needs MySQL validation
+
+## Commit Start
+4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7
+
+## Commit End
+27dbc095e0413f9566bcc4ddc3bdb4f642535a6b
diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/6.md b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/6.md
new file mode 100644
index 0000000..1653ed1
--- /dev/null
+++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/6.md
@@ -0,0 +1,37 @@
+---
+task_id: "6"
+task_hash: "sha256:ee793f06e3bc8e83ed5836909b204f7234a68754d4ce938ae9fd927be426c7e6"
+next: "Task 7: authorization, logging privacy and account deletion cleanup"
+completed: "History list/detail/delete and snapshot responses"
+evidence: "W3 suite 6/6 passed"
+review: ".superpowers/sdd/reviews/T6.md"
+risk: "Snapshot JSON compatibility requires MySQL validation"
+commit_start: "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7"
+commit_end: "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b"
+created_at: "2026-09-20T08:34:50.905Z"
+plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5"
+plan_revision: 1
+---
+
+# Checkpoint: 6
+
+## Next
+Task 7: authorization, logging privacy and account deletion cleanup
+
+## Completed
+History list/detail/delete and snapshot responses
+
+## Evidence
+W3 suite 6/6 passed
+
+## Review
+.superpowers/sdd/reviews/T6.md
+
+## Risk
+Snapshot JSON compatibility requires MySQL validation
+
+## Commit Start
+4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7
+
+## Commit End
+27dbc095e0413f9566bcc4ddc3bdb4f642535a6b
diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/7.md b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/7.md
new file mode 100644
index 0000000..e89cb62
--- /dev/null
+++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/7.md
@@ -0,0 +1,37 @@
+---
+task_id: "7"
+task_hash: "sha256:a3bf97aab3db413f757f95db0c0698d2a2041fe87309be003ccff6f2a267f162"
+next: "Wave W4 task 8: full module tests, OpenAPI and docs"
+completed: "Authorization, privacy boundaries and account deletion purge"
+evidence: "W3 suite 6/6 passed"
+review: ".superpowers/sdd/reviews/T7.md"
+risk: "Legacy deletion tests must remain green after constructor change"
+commit_start: "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7"
+commit_end: "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b"
+created_at: "2026-09-20T08:34:51.016Z"
+plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5"
+plan_revision: 1
+---
+
+# Checkpoint: 7
+
+## Next
+Wave W4 task 8: full module tests, OpenAPI and docs
+
+## Completed
+Authorization, privacy boundaries and account deletion purge
+
+## Evidence
+W3 suite 6/6 passed
+
+## Review
+.superpowers/sdd/reviews/T7.md
+
+## Risk
+Legacy deletion tests must remain green after constructor change
+
+## Commit Start
+4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7
+
+## Commit End
+27dbc095e0413f9566bcc4ddc3bdb4f642535a6b
diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzM.json b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzM.json
new file mode 100644
index 0000000..e87ab95
--- /dev/null
+++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzM.json
@@ -0,0 +1,10 @@
+{
+ "status": "pass",
+ "base": "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7",
+ "head": "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b",
+ "report": ".superpowers\\sdd\\reviews\\W3.md",
+ "report_sha256": "sha256:7d2823941f43fd101c1271be77703e6ae614650c64c7c1fe1484a7cae41f68fa",
+ "plan_hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5",
+ "plan_revision": 1,
+ "recorded_at": "2026-09-20T08:34:51.607Z"
+}
diff --git a/changes/n05-salary-calc/.superpowers/sdd/progress.md b/changes/n05-salary-calc/.superpowers/sdd/progress.md
index 061247e..2915768 100644
--- a/changes/n05-salary-calc/.superpowers/sdd/progress.md
+++ b/changes/n05-salary-calc/.superpowers/sdd/progress.md
@@ -1,14 +1,10 @@
# n05-salary-calc 执行进度
-## W1 - 数据基础:完成
-- V40、实体、Repository、359 城分层 V41、迁移和种子测试;3/3 通过。
-- W1 review receipt:pass。
+## W1-W3
+- 全部完成并已有 pass review receipt。
-## W2 - 城市查询与计算引擎:完成
-- 城市列表/详情、STUDENT 权限、dataLevel 和 BigDecimal 计算引擎;15/15 通过。
-- W2 review receipt:pass。
-
-## W3 - 历史、幂等、权限与注销:完成待 review
-- 计算并保存、政策版本冲突、幂等重放/冲突、历史列表/详情/物理删除、跨用户隔离和账号注销清理。
-- 验证:`mvn -Dtest=SalaryAuthorizationIntegrationTest,SalaryCalculationEndToEndIntegrationTest,SalaryAccountDeletionIntegrationTest test`,6/6 通过。
-- 隐私:薪资包无直接日志写入;API 不返回 requestHash、idempotencyKey 或 studentUserId。
+## W4 - 发布证明与文档
+- Task 8:完成。薪资专项 24/24,OpenAPI 139 paths / 178 operations,API/联调/路线图文档已更新。
+- Task 9:阻塞。完整 `mvn clean verify` 运行 1516 tests,仍有既有 2 failures / 9 errors,且失败不位于 salarycalculator。
+- Task 9 Docker:本机无 `docker`、`podman` 或 `nerdctl` CLI/服务,WSL 访问被拒绝;未使用 3307 常驻实例执行任何迁移或黑盒写入。
+- 一次性容器专用脚本:`scripts/verify_salary_calculator_blackbox.py`,待 Docker 可用后运行。
diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/VzM.json b/changes/n05-salary-calc/.superpowers/sdd/reviews/VzM.json
new file mode 100644
index 0000000..e87ab95
--- /dev/null
+++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/VzM.json
@@ -0,0 +1,10 @@
+{
+ "status": "pass",
+ "base": "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7",
+ "head": "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b",
+ "report": ".superpowers\\sdd\\reviews\\W3.md",
+ "report_sha256": "sha256:7d2823941f43fd101c1271be77703e6ae614650c64c7c1fe1484a7cae41f68fa",
+ "plan_hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5",
+ "plan_revision": 1,
+ "recorded_at": "2026-09-20T08:34:51.607Z"
+}
diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/W4-blocked.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/W4-blocked.md
new file mode 100644
index 0000000..528a403
--- /dev/null
+++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/W4-blocked.md
@@ -0,0 +1,7 @@
+# Wave W4 blocked
+
+- Task 8: pass evidence recorded in progress ledger.
+- Task 9: blocked by missing Docker runtime.
+- Docker check: `docker`, `podman`, and `nerdctl` are absent from PATH and standard install paths; Docker Desktop service/files not found; WSL invocation is access denied.
+- Policy: the resident MySQL at 127.0.0.1:3307 is prohibited for W4 acceptance and was not migrated or written to.
+- Resume command: install/enable Docker, start app against a disposable MySQL container, then run `python scripts/verify_salary_calculator_blackbox.py` with explicit black-box environment variables.
diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md
index 3543b39..d22b590 100644
--- a/docs/backend-development-roadmap.md
+++ b/docs/backend-development-roadmap.md
@@ -26,11 +26,11 @@
| 项目 | 当前状态 |
|---|---|
-| OpenAPI | **135 paths、172 operations** |
-| Flyway | **V1–V39** 已存在(V34 为纯接口扩展;N04 新增 V38–V39) |
-| 全量测试 | **1492 个执行:1481 通过、2 失败、9 错误**;失败集中在既有 CareerProgress、JobReviewDimension、LocalResumeStorage 测试,与 N04 文档变更无关 |
-| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合,以及 N04 入职清单与公司知识介绍;另含认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
-| 当前结论 | N04 入职清单与公司知识介绍已完成代码、N04 专项 96/96 测试和 OpenAPI/文档更新;全量 `mvn clean verify` 仍受既有非 N04 测试失败阻塞,待真实 MySQL/Docker 黑盒验收 |
+| OpenAPI | **139 paths、178 operations**(N05 新增 4 paths / 6 operations) |
+| Flyway | **V1–V41** 已存在(N04 新增 V38–V39;N05 新增 V40–V41) |
+| 全量测试 | **1516 个执行:1505 通过、2 失败、9 错误**;失败仍集中在既有 CareerProgress、JobReviewDimension、LocalResumeStorage 测试,薪资专项 24/24 通过 |
+| 已完成范围 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合,N04 入职清单与公司知识介绍,以及 N05 薪资计算器与历史记录;另含认证、资料、岗位、投递、面试、Offer、看板等核心闭环 |
+| 当前结论 | N05 薪资计算器已完成代码、24/24 专项测试和 OpenAPI/文档更新;N04 仍待真实 MySQL/Docker 验收,N05 也需补齐真实 MySQL 黑盒验收 |
| 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 |
### 2.0 模块状态分类
@@ -38,9 +38,9 @@
| 类别 | 模块 |
|---|---|
| 已完成并已验收 | V24 Candidate Scoring、V25 Governance、V26 Offer Advance、V27 Privacy、V28/V29 Account Deletion、V30 Data Export、V31 Interview Score、V32 Competition Ranking、V33 Report/Block、V34 统一求职进度聚合 |
-| 已完成、待真实 MySQL/Docker 验收 | N04 入职清单与公司知识介绍(V38/V39,9 个接口;专项测试 96/96,全量回归待清除既有失败) |
+| 已完成、待真实 MySQL/Docker 验收 | N04 入职清单与公司知识介绍(V38/V39,9 个接口;专项测试 96/96)
N05 薪资计算器(V40/V41,6 个接口;专项测试 24/24,347 个城市为 PROVINCE_FALLBACK) |
| 已设计未编码 | 测评系统(Holland/能力测评)、公平性检测、SHAP/规则解释、防刷审计、企业评价管理、企业人才推荐 |
-| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、薪资计算器、诚信积分、消息撤回 |
+| 未开始 | AI 简历诊断/优化、AI 模拟面试、行业技能词云、诚信积分、消息撤回 |
### 2.1 已确认的产品决策
@@ -78,7 +78,8 @@
- Candidate Scoring Engine;
- 评分申诉、审核和回滚;
- 入职清单:通用项与企业自定义项合并、个人完成状态和完成率;
-- 公司知识介绍:企业资料、企业知识内容和公开评价摘要。
+- 公司知识介绍:企业资料、企业知识内容和公开评价摘要;
+- 薪资计算器:城市政策、用户覆盖参数、到手工资估算和历史快照。
### 3.2 企业端
@@ -118,7 +119,8 @@
| 企业候选人看板 | `/company/candidates`、`/company/candidates/{id}` | 已完成 |
| 企业评分看板 | `/company/scoring/dashboard/*` | 已完成 |
| 企业认证 | `/companies/me/verification`、`/admin/companies/verifications*` | 已完成 |
-| 入职清单与公司知识介绍 | `/onboarding/**`(9 个接口) | **实现完成,待真实 MySQL/Docker 验收** |
+| 入职清单与公司知识介绍 | /onboarding/**(9 个接口) | **实现完成,待真实 MySQL/Docker 验收** |
+| 薪资计算器 | /salary-calculator/**(6 个接口) | **实现完成,待真实 MySQL/Docker 验收** |
### 4.2 部分完成或口径不一致
@@ -141,12 +143,12 @@
- AI 简历诊断和优化;
- AI 模拟面试和面试题预测;
- 行业技能词云;
-- 薪资计算器和薪资参考;
+- 薪资参考和行业薪资大数据;
- 公平性检测;
- SHAP/规则解释;
- ADMIN 防刷审计和规则配置;
- 诚信积分和激励。
-- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)、入职清单和公司知识介绍(N04)。
+- 已非缺失(已实现):个人数据导出(V30)、隐私设置(V27)、账号注销(V28/V29)、举报与屏蔽(V33)、结构化面试评分(V31)、同岗位竞争力排名(V32)、统一求职进度聚合(V34)、入职清单和公司知识介绍(N04)、薪资计算器与历史记录(N05)。
## 5. 已有代码的优先修复项
@@ -531,3 +533,6 @@ CandidateFeedback 当前响应包含 companyUserId、studentUserId 等内部身
- 新增未批准的 Flyway;
- 删除或清理业务数据;
- 启动或停止 Docker。
+
+
+
diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md
index 7379f67..56b96a1 100644
--- a/docs/frontend-integration.md
+++ b/docs/frontend-integration.md
@@ -857,3 +857,14 @@ Content-Type: application/json
- 创建重试必须复用原 `idempotencyKey` 和完全相同的标题、说明、分类、排序、必填标记。
- DELETE 只在首次删除时要求当前版本;重复删除虽幂等,但客户端仍应携带 `version` 查询参数。
- 更新/删除成功后必须用响应中的新 `version` 覆盖本地状态,不能继续使用旧值。
+
+## 薪资计算器(N05)
+
+- 接口文档:[salary-calculator-api.md](salary-calculator-api.md)
+- 全部接口要求登录且当前用户具有 `STUDENT` 角色。
+- 前端切换城市后必须重新调用城市详情,清空上一城市的覆盖项,并在计算请求中携带当前 `policyVersion`。
+- `dataLevel=PROVINCE_FALLBACK` 必须展示为“省级回退估算”,不能显示成城市级官方精确值。
+- `POST /salary-calculator/calculations` 成功即自动生成历史;网络重试应复用 `idempotencyKey`。
+- 收到 `409/9805` 时读取响应中的最新政策版本和生效日期,重新加载城市参数后再试算。
+- 历史列表使用 `page=0`、`size=20`,`size` 最大 100;详情返回完整输入、政策和结果快照。
+- 删除历史为物理删除;删除后前端应从列表移除该记录。
diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json
index cbc0f5f..ba72fa2 100644
--- a/docs/openapi-rc.json
+++ b/docs/openapi-rc.json
@@ -128,7 +128,7 @@
"description": "企业维护知识内容,学生读取公司基础资料、知识与公开评价摘要"
}
],
- "paths": {
+ "paths": {"/salary-calculator/cities":{"get":{"tags":["薪资计算器"],"summary":"查询启用城市列表","operationId":"listSalaryCities","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"成功","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseListSalaryCitySummaryResponse"}}}},"401":{"description":"未认证"},"403":{"description":"非 STUDENT"}}}},"/salary-calculator/cities/{cityCode}":{"get":{"tags":["薪资计算器"],"summary":"查询城市默认参数","operationId":"getSalaryCity","security":[{"bearerAuth":[]}],"parameters":[{"name":"cityCode","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"成功","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseSalaryCityDetailResponse"}}}},"401":{"description":"未认证"},"403":{"description":"非 STUDENT"},"404":{"description":"城市不存在或停用"}}}},"/salary-calculator/calculations":{"post":{"tags":["薪资计算器"],"summary":"计算并保存到手工资","operationId":"calculateSalary","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SalaryCalculationRequest"}}}},"responses":{"200":{"description":"成功","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseSalaryCalculationResponse"}}}},"400":{"description":"参数非法"},"401":{"description":"未认证"},"403":{"description":"非 STUDENT"},"404":{"description":"城市不存在或停用"},"409":{"description":"幂等或政策版本冲突"}}},"get":{"tags":["薪资计算器"],"summary":"查询本人薪资历史","operationId":"listSalaryCalculations","security":[{"bearerAuth":[]}],"parameters":[{"name":"page","in":"query","schema":{"type":"integer","default":0}},{"name":"size","in":"query","schema":{"type":"integer","default":20,"maximum":100}}],"responses":{"200":{"description":"成功","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponsePageResponseSalaryHistorySummaryResponse"}}}},"400":{"description":"分页参数非法"},"401":{"description":"未认证"},"403":{"description":"非 STUDENT"}}}},"/salary-calculator/calculations/{id}":{"get":{"tags":["薪资计算器"],"summary":"查询本人薪资历史详情","operationId":"getSalaryCalculation","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"成功","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseSalaryHistoryDetailResponse"}}}},"401":{"description":"未认证"},"403":{"description":"非 STUDENT"},"404":{"description":"历史不存在或无权访问"}}},"delete":{"tags":["薪资计算器"],"summary":"删除本人薪资历史","operationId":"deleteSalaryCalculation","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"成功","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseVoid"}}}},"401":{"description":"未认证"},"403":{"description":"非 STUDENT"},"404":{"description":"历史不存在或无权访问"}}}},
"/": {
"get": {
"tags": [
@@ -8034,7 +8034,7 @@
}
},
"components": {
- "schemas": {
+ "schemas": {"SalaryDataLevel":{"type":"string","enum":["CITY","PROVINCE_FALLBACK"]},"SalaryCitySummaryResponse":{"type":"object","properties":{"cityCode":{"type":"string"},"provinceCode":{"type":"string"},"provinceName":{"type":"string"},"cityName":{"type":"string"},"policyVersion":{"type":"string"},"policyEffectiveDate":{"type":"string","format":"date"},"dataLevel":{"$ref":"#/components/schemas/SalaryDataLevel"}}},"SalaryCityDetailResponse":{"type":"object","properties":{"cityCode":{"type":"string"},"provinceCode":{"type":"string"},"provinceName":{"type":"string"},"cityName":{"type":"string"},"policyVersion":{"type":"string"},"policyEffectiveDate":{"type":"string","format":"date"},"dataLevel":{"$ref":"#/components/schemas/SalaryDataLevel"},"dataSource":{"type":"string"},"socialSecurityBaseMin":{"type":"number"},"socialSecurityBaseMax":{"type":"number"},"housingFundBaseMin":{"type":"number"},"housingFundBaseMax":{"type":"number"},"pensionRate":{"type":"number"},"medicalRate":{"type":"number"},"unemploymentRate":{"type":"number"},"housingFundRate":{"type":"number"},"seriousIllnessMonthlyFee":{"type":"number"}}},"SalaryCalculationOverrideRequest":{"type":"object","properties":{"socialSecurityBase":{"type":"number"},"housingFundBase":{"type":"number"},"pensionRate":{"type":"number"},"medicalRate":{"type":"number"},"unemploymentRate":{"type":"number"},"housingFundRate":{"type":"number"},"seriousIllnessMonthlyFee":{"type":"number"}}},"SalaryCalculationRequest":{"type":"object","required":["cityCode","grossMonthlySalary","expectedPolicyVersion"],"properties":{"cityCode":{"type":"string"},"grossMonthlySalary":{"type":"number"},"monthIndex":{"type":"integer","minimum":1,"maximum":12},"specialAdditionalDeduction":{"type":"number"},"expectedPolicyVersion":{"type":"string"},"idempotencyKey":{"type":"string","maxLength":64},"overrides":{"$ref":"#/components/schemas/SalaryCalculationOverrideRequest"}}},"SalaryCalculationResult":{"type":"object","properties":{"cityCode":{"type":"string"},"cityName":{"type":"string"},"dataLevel":{"$ref":"#/components/schemas/SalaryDataLevel"},"policyVersion":{"type":"string"},"policyEffectiveDate":{"type":"string","format":"date"},"taxRulesVersion":{"type":"string"},"estimate":{"type":"boolean"},"monthIndex":{"type":"integer"},"grossMonthlySalary":{"type":"number"},"specialAdditionalDeduction":{"type":"number"},"socialSecurityBase":{"type":"number"},"housingFundBase":{"type":"number"},"pensionRate":{"type":"number"},"medicalRate":{"type":"number"},"unemploymentRate":{"type":"number"},"housingFundRate":{"type":"number"},"seriousIllnessMonthlyFee":{"type":"number"},"pension":{"type":"number"},"medical":{"type":"number"},"unemployment":{"type":"number"},"housingFund":{"type":"number"},"cumulativeGrossIncome":{"type":"number"},"cumulativeSocialInsurance":{"type":"number"},"cumulativeHousingFund":{"type":"number"},"cumulativeSeriousIllness":{"type":"number"},"cumulativeBasicDeduction":{"type":"number"},"cumulativeSpecialAdditionalDeduction":{"type":"number"},"cumulativeTaxableIncome":{"type":"number"},"cumulativeTaxDue":{"type":"number"},"previouslyWithheldTax":{"type":"number"},"currentMonthTax":{"type":"number"},"netSalary":{"type":"number"}}},"SalaryCalculationResponse":{"type":"object","properties":{"historyId":{"type":"integer","format":"int64"},"createdAt":{"type":"string","format":"date-time"},"result":{"$ref":"#/components/schemas/SalaryCalculationResult"}}},"SalaryHistorySummaryResponse":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"cityCode":{"type":"string"},"cityName":{"type":"string"},"dataLevel":{"$ref":"#/components/schemas/SalaryDataLevel"},"grossMonthlySalary":{"type":"number"},"netSalary":{"type":"number"},"monthIndex":{"type":"integer"},"createdAt":{"type":"string","format":"date-time"}}},"SalaryHistoryDetailResponse":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"cityCode":{"type":"string"},"cityName":{"type":"string"},"dataLevel":{"$ref":"#/components/schemas/SalaryDataLevel"},"policyVersion":{"type":"string"},"taxRulesVersion":{"type":"string"},"monthIndex":{"type":"integer"},"grossMonthlySalary":{"type":"number"},"netSalary":{"type":"number"},"createdAt":{"type":"string","format":"date-time"},"inputSnapshot":{"type":"object"},"policySnapshot":{"type":"object"},"resultSnapshot":{"type":"object"}}},"PageResponseSalaryHistorySummaryResponse":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SalaryHistorySummaryResponse"}},"total":{"type":"integer","format":"int64"},"page":{"type":"integer"},"size":{"type":"integer"},"totalPages":{"type":"integer"}}},"ApiResponseListSalaryCitySummaryResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/SalaryCitySummaryResponse"}},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}},"ApiResponseSalaryCityDetailResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/SalaryCityDetailResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}},"ApiResponseSalaryCalculationResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/SalaryCalculationResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}},"ApiResponsePageResponseSalaryHistorySummaryResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/PageResponseSalaryHistorySummaryResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}},"ApiResponseSalaryHistoryDetailResponse":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"data":{"$ref":"#/components/schemas/SalaryHistoryDetailResponse"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}},"ApiResponseVoid":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"},"requestId":{"type":"string"},"timestamp":{"type":"string","format":"date-time"}}},
"AccountDeletionResponse": {
"type": "object",
"properties": {
diff --git a/docs/salary-calculator-api.md b/docs/salary-calculator-api.md
new file mode 100644
index 0000000..1ca1e29
--- /dev/null
+++ b/docs/salary-calculator-api.md
@@ -0,0 +1,158 @@
+# 薪资计算器 API(N05)
+
+> Base URL:`/api/v1`
+> 认证:`Authorization: Bearer `
+> 角色:当前登录用户必须具备 `STUDENT` 角色。
+
+## 1. 能力边界
+
+- 输入税前月薪,估算个人五险一金、大病固定费和当月个税后的到手工资。
+- 采用居民个人综合所得累计预扣法;`monthIndex` 表示当前工资口径下的累计计税月份数,而不是自然月编号。
+- 支持用户覆盖社保基数、公积金基数和个人比例。
+- 城市政策以地级行政区、直辖市和省直辖县级市为最小单元,不拆分到区县。
+- `dataLevel=CITY` 表示城市级参数;`dataLevel=PROVINCE_FALLBACK` 表示由于公开城市级资料不足而使用省级回退参数,结果精度较低。
+- 所有结果均为估算值,实际以工资单、当地政策和税务申报为准。
+
+## 2. 查询城市列表
+
+`GET /salary-calculator/cities`
+
+返回全部启用城市摘要,按省份编码和城市编码排序。
+
+响应示例:
+
+```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/9804`。
+- 覆盖基数必须位于对应城市范围内;比例为 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/9803`。
+
+## 6. 删除历史
+
+`DELETE /salary-calculator/calculations/{id}`
+
+物理删除本人历史;成功后再次查询返回 `404/9803`。删除后原幂等键可重新使用。
+
+## 7. 错误码
+
+| HTTP | code | 含义 |
+|---|---|---|
+| 400 | 9802 | 工资、比例、基数或分页参数非法 |
+| 401 | 401 | 未登录或 Token 无效 |
+| 403 | 2002 | 已登录但不是 `STUDENT` |
+| 404 | 9801 | 城市不存在、停用或未配置 |
+| 404 | 9803 | 历史不存在或不属于当前用户 |
+| 409 | 9804 | 幂等键冲突 |
+| 409 | 9805 | 城市政策版本已变化 |
+
+`9805` 响应会在 `data` 中返回最新 `policyVersion` 和 `policyEffectiveDate`,客户端应重新加载城市详情后再试算。
+
+## 8. 计算口径
+
+```text
+社保基数 = 用户覆盖值,否则 min(max(税前月薪, 社保下限), 社保上限)
+公积金基数 = 用户覆盖值,否则 min(max(税前月薪, 公积金下限), 公积金上限)
+
+累计收入 = 税前月薪 × monthIndex
+累计基本减除费用 = 5000 × monthIndex
+累计专项附加扣除 = 月度专项附加扣除 × monthIndex
+累计应纳税所得额 = max(累计收入 - 累计个人社保 - 累计公积金 - 累计大病费
+ - 累计基本减除费用 - 累计专项附加扣除, 0)
+当月个税 = max(累计应纳税额 - 累计已预扣税额, 0)
+到手工资 = max(税前月薪 - 个人养老 - 个人医疗 - 个人失业 - 个人公积金
+ - 大病固定费 - 当月个税, 0)
+```
diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py
index 753ea25..1f4d60e 100644
--- a/docs/verify_openapi.py
+++ b/docs/verify_openapi.py
@@ -52,9 +52,26 @@ assert sum(
for path_item in paths.values()
for method in path_item
if method in http_methods
-) == 172, 'Expected 172 operations after N04 onboarding'
+) == 178, 'Expected 178 operations after N05 salary calculator'
-assert len(d['paths']) == 135, f"Expected 135 paths, got {len(d['paths'])}"
+assert len(d['paths']) == 139, f"Expected 139 paths, got {len(d['paths'])}"
+
+required_salary_paths = ['/salary-calculator/cities','/salary-calculator/cities/{cityCode}','/salary-calculator/calculations','/salary-calculator/calculations/{id}']
+for path in required_salary_paths:
+ assert path in paths, f'Missing salary path: {path}'
+
+salary_operations = {
+ ('/salary-calculator/cities', 'get'): 'listSalaryCities',
+ ('/salary-calculator/cities/{cityCode}', 'get'): 'getSalaryCity',
+ ('/salary-calculator/calculations', 'post'): 'calculateSalary',
+ ('/salary-calculator/calculations', 'get'): 'listSalaryCalculations',
+ ('/salary-calculator/calculations/{id}', 'get'): 'getSalaryCalculation',
+ ('/salary-calculator/calculations/{id}', 'delete'): 'deleteSalaryCalculation',
+}
+for (path, method), operation_id in salary_operations.items():
+ assert method in paths[path], f'Missing {method.upper()} {path}'
+ assert paths[path][method].get('operationId') == operation_id, f'Wrong operationId for {method.upper()} {path}'
+ assert paths[path][method].get('security') == [{'bearerAuth': []}], f'{method.upper()} {path} must require bearerAuth'
required = [
'/review-weight-templates',
@@ -116,6 +133,10 @@ required = [
'/skills/word-cloud',
'/skills/match',
'/skills/learning-path',
+ '/salary-calculator/cities',
+ '/salary-calculator/cities/{cityCode}',
+ '/salary-calculator/calculations',
+ '/salary-calculator/calculations/{id}',
]
for path in required:
assert path in paths, f"Missing path: {path}"
@@ -327,4 +348,4 @@ assert '"" : "#/components/schemas/' not in data, "Found empty ref key"
print("No empty ref keys found")
print("Valid JSON: True")
-print("All checks passed!")
\ No newline at end of file
+print("All checks passed!")
diff --git a/scripts/verify_salary_calculator_blackbox.py b/scripts/verify_salary_calculator_blackbox.py
new file mode 100644
index 0000000..f577eba
--- /dev/null
+++ b/scripts/verify_salary_calculator_blackbox.py
@@ -0,0 +1,131 @@
+"""N05 salary calculator black-box verification.
+
+Run only against an isolated, disposable MySQL container and an app wired to it.
+Required environment variables:
+ BASE_URL=http://127.0.0.1:/api/v1
+ DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_NAME
+"""
+import os
+import sys
+import time
+
+import pymysql
+import requests
+
+BASE = os.environ.get("BASE_URL", "").rstrip("/")
+DB = {
+ "host": os.environ["DB_HOST"],
+ "port": int(os.environ["DB_PORT"]),
+ "user": os.environ["DB_USERNAME"],
+ "password": os.environ["DB_PASSWORD"],
+ "database": os.environ["DB_NAME"],
+}
+if not BASE:
+ raise SystemExit("BASE_URL is required")
+
+
+def db():
+ return pymysql.connect(**DB, autocommit=True)
+
+
+def sql_one(query, args=()):
+ with db() as conn:
+ with conn.cursor() as cur:
+ cur.execute(query, args)
+ return cur.fetchone()
+
+
+def sql_all(query, args=()):
+ with db() as conn:
+ with conn.cursor() as cur:
+ cur.execute(query, args)
+ return cur.fetchall()
+
+
+def login_student(phone):
+ r = requests.post(f"{BASE}/auth/sms/send", params={"phone": phone, "purpose": "login"}, timeout=10)
+ assert r.status_code == 200, r.text
+ code = None
+ for _ in range(20):
+ row = sql_one(
+ "SELECT code FROM sms_verification_codes "
+ "WHERE phone=%s AND purpose='login' AND used=0 ORDER BY id DESC LIMIT 1",
+ (phone,),
+ )
+ if row:
+ code = row[0]
+ break
+ time.sleep(0.25)
+ assert code, "mock SMS code not found"
+ r = requests.post(f"{BASE}/auth/login", json={"phone": phone, "code": code, "purpose": "login"}, timeout=10)
+ assert r.status_code == 200, r.text
+ return r.json()["data"]["accessToken"]
+
+
+def main():
+ versions = {row[0] for row in sql_all("SELECT version FROM flyway_schema_history")}
+ assert "40" in versions and "41" in versions, versions
+
+ assert sql_one("SELECT COUNT(*) FROM salary_city_policy")[0] == 359
+ levels = dict(sql_all("SELECT data_level, COUNT(*) FROM salary_city_policy GROUP BY data_level"))
+ assert levels == {"CITY": 12, "PROVINCE_FALLBACK": 347}, levels
+
+ phone = os.environ.get("BLACKBOX_PHONE", f"139{int(time.time()) % 100000000:08d}")
+ token = login_student(phone)
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
+
+ cities = requests.get(f"{BASE}/salary-calculator/cities", headers=headers, timeout=10)
+ assert cities.status_code == 200 and len(cities.json()["data"]) == 359, cities.text
+
+ city = requests.get(f"{BASE}/salary-calculator/cities/110000", headers=headers, timeout=10)
+ assert city.status_code == 200 and city.json()["data"]["dataLevel"] == "CITY", city.text
+
+ payload = {
+ "cityCode": "110000",
+ "grossMonthlySalary": 10000.00,
+ "monthIndex": 1,
+ "specialAdditionalDeduction": 0.00,
+ "expectedPolicyVersion": city.json()["data"]["policyVersion"],
+ "idempotencyKey": f"blackbox-{phone}",
+ "overrides": None,
+ }
+ first = requests.post(f"{BASE}/salary-calculator/calculations", headers=headers, json=payload, timeout=10)
+ assert first.status_code == 200, first.text
+ history_id = first.json()["data"]["historyId"]
+ assert str(first.json()["data"]["result"]["netSalary"]) == "7667.50", first.text
+
+ replay = requests.post(f"{BASE}/salary-calculator/calculations", headers=headers, json=payload, timeout=10)
+ assert replay.status_code == 200 and replay.json()["data"]["historyId"] == history_id, replay.text
+
+ conflict_payload = dict(payload)
+ conflict_payload["grossMonthlySalary"] = 12000.00
+ conflict = requests.post(f"{BASE}/salary-calculator/calculations", headers=headers, json=conflict_payload, timeout=10)
+ assert conflict.status_code == 409 and conflict.json()["code"] == 9804, conflict.text
+
+ stale_payload = dict(payload)
+ stale_payload["idempotencyKey"] = "stale-" + phone
+ stale_payload["expectedPolicyVersion"] = "stale-version"
+ stale = requests.post(f"{BASE}/salary-calculator/calculations", headers=headers, json=stale_payload, timeout=10)
+ assert stale.status_code == 409 and stale.json()["code"] == 9805, stale.text
+ assert stale.json()["data"]["latestPolicyVersion"] == city.json()["data"]["policyVersion"]
+
+ history = requests.get(f"{BASE}/salary-calculator/calculations", headers=headers, timeout=10)
+ assert history.status_code == 200 and history.json()["data"]["total"] == 1, history.text
+
+ detail = requests.get(f"{BASE}/salary-calculator/calculations/{history_id}", headers=headers, timeout=10)
+ assert detail.status_code == 200 and detail.json()["data"]["policySnapshot"]["dataLevel"] == "CITY", detail.text
+
+ deleted = requests.delete(f"{BASE}/salary-calculator/calculations/{history_id}", headers=headers, timeout=10)
+ assert deleted.status_code == 200, deleted.text
+ missing = requests.get(f"{BASE}/salary-calculator/calculations/{history_id}", headers=headers, timeout=10)
+ assert missing.status_code == 404 and missing.json()["code"] == 9803, missing.text
+
+ print("PASS: N05 salary calculator disposable-MySQL black box")
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except Exception as exc:
+ print(f"FAIL: {type(exc).__name__}: {exc}", file=sys.stderr)
+ raise