Browse Source

docs: add salary calculator API and black-box verification

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
c93b0d61fe
  1. 5
      README.md
  2. 2
      changes/n05-salary-calc/.spec-superflow.yaml
  3. 37
      changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/5.md
  4. 37
      changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/6.md
  5. 37
      changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/7.md
  6. 10
      changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzM.json
  7. 18
      changes/n05-salary-calc/.superpowers/sdd/progress.md
  8. 10
      changes/n05-salary-calc/.superpowers/sdd/reviews/VzM.json
  9. 7
      changes/n05-salary-calc/.superpowers/sdd/reviews/W4-blocked.md
  10. 27
      docs/backend-development-roadmap.md
  11. 11
      docs/frontend-integration.md
  12. 4
      docs/openapi-rc.json
  13. 158
      docs/salary-calculator-api.md
  14. 27
      docs/verify_openapi.py
  15. 131
      scripts/verify_salary_calculator_blackbox.py

5
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`
青核项目不属于本仓库,禁止将其源代码提交到职来仓库。

2
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

37
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

37
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

37
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

10
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"
}

18
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 可用后运行。

10
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"
}

7
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.

27
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)<br>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。

11
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;详情返回完整输入、政策和结果快照。
- 删除历史为物理删除;删除后前端应从列表移除该记录。

4
docs/openapi-rc.json

File diff suppressed because one or more lines are too long

158
docs/salary-calculator-api.md

@ -0,0 +1,158 @@
# 薪资计算器 API(N05)
> Base URL:`/api/v1`
> 认证:`Authorization: Bearer <access-token>`
> 角色:当前登录用户必须具备 `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)
```

27
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!")
print("All checks passed!")

131
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:<PORT>/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
Loading…
Cancel
Save