From c93b0d61fe547844f6ba29b74457670ed7f00c93 Mon Sep 17 00:00:00 2001 From: pwklppg <123456@.com> Date: Sun, 20 Sep 2026 16:56:32 +0800 Subject: [PATCH] docs: add salary calculator API and black-box verification --- README.md | 5 +- changes/n05-salary-calc/.spec-superflow.yaml | 2 +- .../checkpoints/5.md | 37 ++++ .../checkpoints/6.md | 37 ++++ .../checkpoints/7.md | 37 ++++ .../reviews/VzM.json | 10 ++ .../.superpowers/sdd/progress.md | 18 +- .../.superpowers/sdd/reviews/VzM.json | 10 ++ .../.superpowers/sdd/reviews/W4-blocked.md | 7 + docs/backend-development-roadmap.md | 27 +-- docs/frontend-integration.md | 11 ++ docs/openapi-rc.json | 4 +- docs/salary-calculator-api.md | 158 ++++++++++++++++++ docs/verify_openapi.py | 27 ++- scripts/verify_salary_calculator_blackbox.py | 131 +++++++++++++++ 15 files changed, 492 insertions(+), 29 deletions(-) create mode 100644 changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/5.md create mode 100644 changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/6.md create mode 100644 changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/7.md create mode 100644 changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzM.json create mode 100644 changes/n05-salary-calc/.superpowers/sdd/reviews/VzM.json create mode 100644 changes/n05-salary-calc/.superpowers/sdd/reviews/W4-blocked.md create mode 100644 docs/salary-calculator-api.md create mode 100644 scripts/verify_salary_calculator_blackbox.py 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