diff --git a/README.md b/README.md index 470d381..99e996a 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,一次性 Docker MySQL 黑盒通过 | | 账号注销冷静期 | 30 天 | | 本地服务端口 | `8080` | -N04 入职清单与公司知识介绍已完成代码、N04 专项测试、OpenAPI 和联调文档;全量 `mvn clean verify` 仍受既有非 N04 测试失败阻塞,待真实 MySQL/Docker 黑盒验收。V35 霍兰德职业兴趣测评目前仅完成设计,尚未进入编码和数据库迁移阶段。 +N04 入职清单与公司知识介绍、N05 薪资计算器与历史记录均已完成代码、专项测试、OpenAPI 和联调文档;N05 专项 24/24 通过,且已完成一次性 Docker MySQL 黑盒验收并销毁容器。全量 `mvn clean verify` 仍受既有非 N05 测试失败阻塞。 ## 仓库结构 @@ -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,5 @@ 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 new file mode 100644 index 0000000..9ee1d63 --- /dev/null +++ b/changes/n05-salary-calc/.spec-superflow.yaml @@ -0,0 +1,48 @@ +# .spec-superflow.yaml — lightweight state machine +# Derived data. Always rebuildable from artifacts. Lost/corrupt → fall back to content-level detection. + +# === Core state === +state: closing +workflow: full +revision: 1 + +# === Hashes (fast staleness detection) === +artifacts_hash: sha256:5e15964e215b2645ea2679d15626cf2cb04fba15b8285049fc4ba8104d7ad330 +contract_hash: sha256:c85abc5d5ddd79b03da8ff42816436f4389bb99a5739d4fcb2a960c3c81867b1 + +# === Execution progress === +execution_mode: sdd +execution_plan_hash: sha256:b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c +execution_plan_revision: 1 +batches_completed: 4 +test_result: pass +spec_merged: true +spec_publication_receipt: eyJ2ZXJzaW9uIjoxLCJzb3VyY2VfaGFzaCI6InNoYTI1NjozOTkxMDk2Zjk4ZTU1MGJhNTZhMmQ4YTY1YWZjZjFmNzUxNTc4OGNlZTM3YTYwMmVkYmZmOGNiNTcxZmYwNjM0IiwiYmFzZWxpbmVfYmVmb3JlX2hhc2giOiJzaGEyNTY6ZDM4YjQyMzcwODdhYTRkNTJkNWMyNTBkY2M4MmYwN2FjNDAyOGYzMWJmMDhiNDljMjczNDJhYTJkMGY2MjRhNiIsImJhc2VsaW5lX2FmdGVyX2hhc2giOiJzaGEyNTY6NzM4ZWNiYjFkYTRjNTM4MDIzMTFjZjM0ZWU1NzBlYjQ3NDU5OWNmMTRhZGEwZWRmMzY0MDE2NmU3M2IyNjFlZiIsImNhcGFiaWxpdGllcyI6WyJzYWxhcnktY2FsY3VsYXRpb24iLCJzYWxhcnktaGlzdG9yeSJdfQ + +# === Metadata === +change_name: n05-salary-calc +last_transition: 2026-09-21T05:09:14.898Z +last_transition_from: executing +last_transition_to: closing + +# === Decision points === +dp_0_decisions: 变更名称:n05-salary-calc;一句话意图:为已登录 STUDENT 提供按地级行政区配置的城市默认参数、用户覆盖、到手工资估算和历史快照能力。workflow_path=full;选择原因:新增模块、V40/V41 迁移、六个 API、权限、幂等与跨模块注销清理。范围:仅后端;不支持匿名、ADMIN、前端、个人数据导出、薪资参考或 Offer 关联。计算:税前月薪、个人五险一金简化模型、累计预扣;全国目标城市全量配置;历史物理删除,账号注销时清理。约束:城市级不拆区县;用户覆盖优先;政策版本校验;仅 STUDENT;日志不记录金额。优化:保持聚焦,不纳入前端、企业成本、年终奖、年度汇算、管理员维护和薪资大数据。沟通方式:四类规划工件一次生成,完成验证后统一进入 DP-2 审查。artifact_language=zh-CN +dp_0_result: confirmed +dp_0_confirmed: true +dp_0_timestamp: 2026-09-20T07:28:51Z +dp_1_result: confirmed: 后端薪资计算器范围、计算口径、城市政策模型、历史记录生命周期、接口、错误码、隐私与验收标准均已确认 +dp_1_timestamp: 2026-09-20T07:29:03Z +dp_2_result: approved: layered CITY/PROVINCE_FALLBACK data strategy, updated specs/design/tasks +dp_2_timestamp: 2026-09-20T08:15:41Z +dp_3_result: approved: updated execution contract with layered city policy data and dataLevel propagation +dp_3_timestamp: 2026-09-20T08:15:41Z +dp_4_result: sdd: plan revision 1; user-confirmed; 任务勾选已完成,重建当前工件哈希并保留同一 W1-W4 执行范围 +dp_4_timestamp: 2026-09-20T09:13:10.032Z +dp_5_result: null +dp_5_timestamp: null +dp_5_decisions: null +dp_5_confirmed: null +dp_6_result: conditional: N05 salary calculator verified; pre-existing 2 failures/9 errors outside N05 conditionally accepted +dp_6_timestamp: 2026-09-21T05:09:12Z +dp_7_result: confirmed: N05 salary calculator archived; branch feat/n05-salary-calc ready for merge +dp_7_timestamp: 2026-09-21T05:09:12Z diff --git a/changes/n05-salary-calc/.superpowers/sdd/.stale-plans/execution-plan-rev1-before-task-checkboxes.json b/changes/n05-salary-calc/.superpowers/sdd/.stale-plans/execution-plan-rev1-before-task-checkboxes.json new file mode 100644 index 0000000..abf452b --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/.stale-plans/execution-plan-rev1-before-task-checkboxes.json @@ -0,0 +1,155 @@ +{ + "mode": "sdd", + "source": "user-confirmed", + "rationale": "分层城市数据方案已批准;重新建立当前 W1-W4 执行计划", + "waves": [ + { + "id": "W1", + "strategy": "serial", + "tasks": [ + "1", + "2" + ], + "depends_on": [] + }, + { + "id": "W2", + "strategy": "serial", + "tasks": [ + "3", + "4" + ], + "depends_on": [ + "W1" + ] + }, + { + "id": "W3", + "strategy": "serial", + "tasks": [ + "5", + "6", + "7" + ], + "depends_on": [ + "W2" + ] + }, + { + "id": "W4", + "strategy": "serial", + "tasks": [ + "8", + "9" + ], + "depends_on": [ + "W3" + ] + } + ], + "artifacts_hash": "sha256:c7e046b12a81b409ff08716e34062a9a4312a0af4cc20bdbd0442a7bea9e2b1e", + "contract_hash": "sha256:c85abc5d5ddd79b03da8ff42816436f4389bb99a5739d4fcb2a960c3c81867b1", + "workflow": "full", + "revision": 1, + "recommendation": { + "available_modes": [ + "inline", + "batch-inline", + "sdd" + ], + "recommendation": { + "mode": "sdd", + "reasons": [ + "The declared work spans multiple waves, so SDD keeps dependencies and review receipts explicit." + ] + }, + "facts": { + "workflow": "full", + "documented_task_count": 9, + "planned_task_count": 9, + "planned_wave_count": 4, + "has_parallel_wave": false, + "inline_threshold": 3 + } + }, + "recommendation_receipt": { + "recommendation": { + "available_modes": [ + "inline", + "batch-inline", + "sdd" + ], + "recommendation": { + "mode": "sdd", + "reasons": [ + "The declared work spans multiple waves, so SDD keeps dependencies and review receipts explicit." + ] + }, + "facts": { + "workflow": "full", + "documented_task_count": 9, + "planned_task_count": 9, + "planned_wave_count": 4, + "has_parallel_wave": false, + "inline_threshold": 3 + } + }, + "waves": [ + { + "id": "W1", + "strategy": "serial", + "tasks": [ + "1", + "2" + ], + "depends_on": [] + }, + { + "id": "W2", + "strategy": "serial", + "tasks": [ + "3", + "4" + ], + "depends_on": [ + "W1" + ] + }, + { + "id": "W3", + "strategy": "serial", + "tasks": [ + "5", + "6", + "7" + ], + "depends_on": [ + "W2" + ] + }, + { + "id": "W4", + "strategy": "serial", + "tasks": [ + "8", + "9" + ], + "depends_on": [ + "W3" + ] + } + ], + "artifacts_hash": "sha256:c7e046b12a81b409ff08716e34062a9a4312a0af4cc20bdbd0442a7bea9e2b1e", + "contract_hash": "sha256:c85abc5d5ddd79b03da8ff42816436f4389bb99a5739d4fcb2a960c3c81867b1", + "workflow": "full", + "execution_plan_revision_at_recommendation": null, + "created_at": "2026-09-20T08:16:23.723Z", + "hash": "sha256:731967dc9f49d233bbf9bb22b2cab7ce087e69893333490aa8c9142e5bdf16ed" + }, + "selection": { + "confirmed": true, + "followed_recommendation": true, + "acknowledged_non_recommendation": false + }, + "hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/.stale-plans/execution-plan-rev2-before-layered-data.json b/changes/n05-salary-calc/.superpowers/sdd/.stale-plans/execution-plan-rev2-before-layered-data.json new file mode 100644 index 0000000..5dafdcf --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/.stale-plans/execution-plan-rev2-before-layered-data.json @@ -0,0 +1,155 @@ +{ + "mode": "sdd", + "source": "user-confirmed-revision", + "rationale": "规范化 tasks.md 任务编号以兼容 checkpoint CLI;W1-W4 范围与依赖不变", + "waves": [ + { + "id": "W1", + "strategy": "serial", + "tasks": [ + "1", + "2" + ], + "depends_on": [] + }, + { + "id": "W2", + "strategy": "serial", + "tasks": [ + "3", + "4" + ], + "depends_on": [ + "W1" + ] + }, + { + "id": "W3", + "strategy": "serial", + "tasks": [ + "5", + "6", + "7" + ], + "depends_on": [ + "W2" + ] + }, + { + "id": "W4", + "strategy": "serial", + "tasks": [ + "8", + "9" + ], + "depends_on": [ + "W3" + ] + } + ], + "artifacts_hash": "sha256:2820966ebe5197fbfd6dfc5c18ff31dc2a11fc6170510bdc35f00a82d1f0b684", + "contract_hash": "sha256:ce264c979b71f9f9b4d95881dd1f3fd8a1d62c3d848fd508e26cc5ce5b76d3c0", + "workflow": "full", + "revision": 2, + "recommendation": { + "available_modes": [ + "inline", + "batch-inline", + "sdd" + ], + "recommendation": { + "mode": "sdd", + "reasons": [ + "The declared work spans multiple waves, so SDD keeps dependencies and review receipts explicit." + ] + }, + "facts": { + "workflow": "full", + "documented_task_count": 9, + "planned_task_count": 9, + "planned_wave_count": 4, + "has_parallel_wave": false, + "inline_threshold": 3 + } + }, + "recommendation_receipt": { + "recommendation": { + "available_modes": [ + "inline", + "batch-inline", + "sdd" + ], + "recommendation": { + "mode": "sdd", + "reasons": [ + "The declared work spans multiple waves, so SDD keeps dependencies and review receipts explicit." + ] + }, + "facts": { + "workflow": "full", + "documented_task_count": 9, + "planned_task_count": 9, + "planned_wave_count": 4, + "has_parallel_wave": false, + "inline_threshold": 3 + } + }, + "waves": [ + { + "id": "W1", + "strategy": "serial", + "tasks": [ + "1", + "2" + ], + "depends_on": [] + }, + { + "id": "W2", + "strategy": "serial", + "tasks": [ + "3", + "4" + ], + "depends_on": [ + "W1" + ] + }, + { + "id": "W3", + "strategy": "serial", + "tasks": [ + "5", + "6", + "7" + ], + "depends_on": [ + "W2" + ] + }, + { + "id": "W4", + "strategy": "serial", + "tasks": [ + "8", + "9" + ], + "depends_on": [ + "W3" + ] + } + ], + "artifacts_hash": "sha256:2820966ebe5197fbfd6dfc5c18ff31dc2a11fc6170510bdc35f00a82d1f0b684", + "contract_hash": "sha256:ce264c979b71f9f9b4d95881dd1f3fd8a1d62c3d848fd508e26cc5ce5b76d3c0", + "workflow": "full", + "execution_plan_revision_at_recommendation": 1, + "created_at": "2026-09-20T07:55:10.974Z", + "hash": "sha256:a57b53ceb85db7eb53d357bbca5af289cd06b89cbfc1f41d73319bdc41289368" + }, + "selection": { + "confirmed": true, + "followed_recommendation": true, + "acknowledged_non_recommendation": false + }, + "hash": "sha256:eddde134568a3cf09036f21af22f716d287f8e960ca5f23d96bd01a32a4f8511" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/evidence/n05-mysql-blackbox.md b/changes/n05-salary-calc/.superpowers/sdd/evidence/n05-mysql-blackbox.md new file mode 100644 index 0000000..04205d9 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/evidence/n05-mysql-blackbox.md @@ -0,0 +1,9 @@ +N05 disposable Docker MySQL black-box evidence +MySQL image: mysql:8.4 +Backend image: eclipse-temurin:17-jre +Flyway versions: 40,41 +City policy count: 359 +Data levels: CITY 12 +Additional level row: PROVINCE_FALLBACK 347 +API black-box: PASS: N05 salary calculator disposable-MySQL black box +Containers were destroyed after evidence capture. diff --git a/changes/n05-salary-calc/.superpowers/sdd/execution-plan.json b/changes/n05-salary-calc/.superpowers/sdd/execution-plan.json new file mode 100644 index 0000000..aaf87b7 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/execution-plan.json @@ -0,0 +1,155 @@ +{ + "mode": "sdd", + "source": "user-confirmed", + "rationale": "任务勾选已完成,重建当前工件哈希并保留同一 W1-W4 执行范围", + "waves": [ + { + "id": "W1", + "strategy": "serial", + "tasks": [ + "1", + "2" + ], + "depends_on": [] + }, + { + "id": "W2", + "strategy": "serial", + "tasks": [ + "3", + "4" + ], + "depends_on": [ + "W1" + ] + }, + { + "id": "W3", + "strategy": "serial", + "tasks": [ + "5", + "6", + "7" + ], + "depends_on": [ + "W2" + ] + }, + { + "id": "W4", + "strategy": "serial", + "tasks": [ + "8", + "9" + ], + "depends_on": [ + "W3" + ] + } + ], + "artifacts_hash": "sha256:5e15964e215b2645ea2679d15626cf2cb04fba15b8285049fc4ba8104d7ad330", + "contract_hash": "sha256:c85abc5d5ddd79b03da8ff42816436f4389bb99a5739d4fcb2a960c3c81867b1", + "workflow": "full", + "revision": 1, + "recommendation": { + "available_modes": [ + "inline", + "batch-inline", + "sdd" + ], + "recommendation": { + "mode": "sdd", + "reasons": [ + "The declared work spans multiple waves, so SDD keeps dependencies and review receipts explicit." + ] + }, + "facts": { + "workflow": "full", + "documented_task_count": 9, + "planned_task_count": 9, + "planned_wave_count": 4, + "has_parallel_wave": false, + "inline_threshold": 3 + } + }, + "recommendation_receipt": { + "recommendation": { + "available_modes": [ + "inline", + "batch-inline", + "sdd" + ], + "recommendation": { + "mode": "sdd", + "reasons": [ + "The declared work spans multiple waves, so SDD keeps dependencies and review receipts explicit." + ] + }, + "facts": { + "workflow": "full", + "documented_task_count": 9, + "planned_task_count": 9, + "planned_wave_count": 4, + "has_parallel_wave": false, + "inline_threshold": 3 + } + }, + "waves": [ + { + "id": "W1", + "strategy": "serial", + "tasks": [ + "1", + "2" + ], + "depends_on": [] + }, + { + "id": "W2", + "strategy": "serial", + "tasks": [ + "3", + "4" + ], + "depends_on": [ + "W1" + ] + }, + { + "id": "W3", + "strategy": "serial", + "tasks": [ + "5", + "6", + "7" + ], + "depends_on": [ + "W2" + ] + }, + { + "id": "W4", + "strategy": "serial", + "tasks": [ + "8", + "9" + ], + "depends_on": [ + "W3" + ] + } + ], + "artifacts_hash": "sha256:5e15964e215b2645ea2679d15626cf2cb04fba15b8285049fc4ba8104d7ad330", + "contract_hash": "sha256:c85abc5d5ddd79b03da8ff42816436f4389bb99a5739d4fcb2a960c3c81867b1", + "workflow": "full", + "execution_plan_revision_at_recommendation": null, + "created_at": "2026-09-20T09:13:09.901Z", + "hash": "sha256:a723f2d8fda600c00e76671ae631e81aaa425cf2d0e18d0a9060ba07776e1bd8" + }, + "selection": { + "confirmed": true, + "followed_recommendation": true, + "acknowledged_non_recommendation": false + }, + "hash": "sha256:b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/execution-recommendation.json b/changes/n05-salary-calc/.superpowers/sdd/execution-recommendation.json new file mode 100644 index 0000000..a5bab7f --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/execution-recommendation.json @@ -0,0 +1,74 @@ +{ + "recommendation": { + "available_modes": [ + "inline", + "batch-inline", + "sdd" + ], + "recommendation": { + "mode": "sdd", + "reasons": [ + "The declared work spans multiple waves, so SDD keeps dependencies and review receipts explicit." + ] + }, + "facts": { + "workflow": "full", + "documented_task_count": 9, + "planned_task_count": 9, + "planned_wave_count": 4, + "has_parallel_wave": false, + "inline_threshold": 3 + } + }, + "waves": [ + { + "id": "W1", + "strategy": "serial", + "tasks": [ + "1", + "2" + ], + "depends_on": [] + }, + { + "id": "W2", + "strategy": "serial", + "tasks": [ + "3", + "4" + ], + "depends_on": [ + "W1" + ] + }, + { + "id": "W3", + "strategy": "serial", + "tasks": [ + "5", + "6", + "7" + ], + "depends_on": [ + "W2" + ] + }, + { + "id": "W4", + "strategy": "serial", + "tasks": [ + "8", + "9" + ], + "depends_on": [ + "W3" + ] + } + ], + "artifacts_hash": "sha256:5e15964e215b2645ea2679d15626cf2cb04fba15b8285049fc4ba8104d7ad330", + "contract_hash": "sha256:c85abc5d5ddd79b03da8ff42816436f4389bb99a5739d4fcb2a960c3c81867b1", + "workflow": "full", + "execution_plan_revision_at_recommendation": null, + "created_at": "2026-09-20T09:13:09.901Z", + "hash": "sha256:a723f2d8fda600c00e76671ae631e81aaa425cf2d0e18d0a9060ba07776e1bd8" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/2.md b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/2.md new file mode 100644 index 0000000..9b5011c --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/2.md @@ -0,0 +1,37 @@ +--- +task_id: "2" +task_hash: "sha256:4395a095f2f5a85abccfe61bca9647cdc1a92ad9febef404ff8726432b4e25f8" +next: "Wave W2 task 3: city policy query service/controller/DTOs" +completed: "V41 layered seed: 359 cities, 12 CITY, 347 PROVINCE_FALLBACK" +evidence: "mvn -Dtest=SalaryCityPolicySeedTest,SalaryMigrationTest,SalaryRepositoryIntegrationTest test: 3/3 passed" +review: "changes/n05-salary-calc/.superpowers/sdd/reviews/T2.md" +risk: "Fallback rows require explicit disclosure and later city-level verification" +commit_start: "79f0646da8a2fa027b8ef9e025ae1096b8ed3903" +commit_end: "2d315f90e46982e9658da6483f46e2acbe21bcca" +created_at: "2026-09-20T08:23:29.908Z" +plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5" +plan_revision: 1 +--- + +# Checkpoint: 2 + +## Next +Wave W2 task 3: city policy query service/controller/DTOs + +## Completed +V41 layered seed: 359 cities, 12 CITY, 347 PROVINCE_FALLBACK + +## Evidence +mvn -Dtest=SalaryCityPolicySeedTest,SalaryMigrationTest,SalaryRepositoryIntegrationTest test: 3/3 passed + +## Review +changes/n05-salary-calc/.superpowers/sdd/reviews/T2.md + +## Risk +Fallback rows require explicit disclosure and later city-level verification + +## Commit Start +79f0646da8a2fa027b8ef9e025ae1096b8ed3903 + +## Commit End +2d315f90e46982e9658da6483f46e2acbe21bcca diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/3.md b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/3.md new file mode 100644 index 0000000..4773405 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/3.md @@ -0,0 +1,37 @@ +--- +task_id: "3" +task_hash: "sha256:b5aaa1f3ea977fa289582c0e7e29adc5990fc4c2f46a5e64c6f142b391dec368" +next: "Task 4: implement BigDecimal salary calculation engine" +completed: "City list/detail APIs with STUDENT authorization and dataLevel" +evidence: "mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest test: 9/9 passed" +review: ".superpowers/sdd/reviews/T3.md" +risk: "Fallback city detail must remain visibly marked" +commit_start: "2d315f90e46982e9658da6483f46e2acbe21bcca" +commit_end: "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7" +created_at: "2026-09-20T08:29:09.441Z" +plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5" +plan_revision: 1 +--- + +# Checkpoint: 3 + +## Next +Task 4: implement BigDecimal salary calculation engine + +## Completed +City list/detail APIs with STUDENT authorization and dataLevel + +## Evidence +mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest test: 9/9 passed + +## Review +.superpowers/sdd/reviews/T3.md + +## Risk +Fallback city detail must remain visibly marked + +## Commit Start +2d315f90e46982e9658da6483f46e2acbe21bcca + +## Commit End +4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7 diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/4.md b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/4.md new file mode 100644 index 0000000..e9ce362 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/4.md @@ -0,0 +1,37 @@ +--- +task_id: "4" +task_hash: "sha256:d571027295959410fd431fe86fc37d769aa15e71d4e14fcb4e5c624b8960c193" +next: "Wave W3 task 5: calculation persistence and policy version validation" +completed: "BigDecimal calculation engine with cumulative withholding and override validation" +evidence: "mvn -Dtest=SalaryCalculationEngineTest test: 6/6 passed" +review: ".superpowers/sdd/reviews/T4.md" +risk: "Tax anchor cases will be expanded in W4" +commit_start: "2d315f90e46982e9658da6483f46e2acbe21bcca" +commit_end: "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7" +created_at: "2026-09-20T08:29:09.537Z" +plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5" +plan_revision: 1 +--- + +# Checkpoint: 4 + +## Next +Wave W3 task 5: calculation persistence and policy version validation + +## Completed +BigDecimal calculation engine with cumulative withholding and override validation + +## Evidence +mvn -Dtest=SalaryCalculationEngineTest test: 6/6 passed + +## Review +.superpowers/sdd/reviews/T4.md + +## Risk +Tax anchor cases will be expanded in W4 + +## Commit Start +2d315f90e46982e9658da6483f46e2acbe21bcca + +## Commit End +4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7 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/checkpoints/8.md b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/8.md new file mode 100644 index 0000000..5d7a1b3 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/8.md @@ -0,0 +1,37 @@ +--- +task_id: "8" +task_hash: "sha256:2853a7746ebea9c39b2f8b1256abf759359c0d68f8581073655e43c461a23be0" +next: "Task 9: disposable Docker MySQL black-box acceptance; blocked by missing Docker runtime" +completed: "Salary 24/24 tests, OpenAPI 139/178, API/frontend/roadmap docs, disposable-container black-box script" +evidence: "mvn -Dtest=Salary*Test test: 24/24; python docs/verify_openapi.py: pass" +review: ".superpowers/sdd/reviews/W4-blocked.md" +risk: "Docker CLI/service absent; resident MySQL 3307 prohibited for W4" +commit_start: "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b" +commit_end: "c93b0d61fe547844f6ba29b74457670ed7f00c93" +created_at: "2026-09-20T08:56:41.205Z" +plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5" +plan_revision: 1 +--- + +# Checkpoint: 8 + +## Next +Task 9: disposable Docker MySQL black-box acceptance; blocked by missing Docker runtime + +## Completed +Salary 24/24 tests, OpenAPI 139/178, API/frontend/roadmap docs, disposable-container black-box script + +## Evidence +mvn -Dtest=Salary*Test test: 24/24; python docs/verify_openapi.py: pass + +## Review +.superpowers/sdd/reviews/W4-blocked.md + +## Risk +Docker CLI/service absent; resident MySQL 3307 prohibited for W4 + +## Commit Start +27dbc095e0413f9566bcc4ddc3bdb4f642535a6b + +## Commit End +c93b0d61fe547844f6ba29b74457670ed7f00c93 diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzE.json b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzE.json new file mode 100644 index 0000000..9b3ae1e --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzE.json @@ -0,0 +1,10 @@ +{ + "status": "pass", + "base": "36c0214723a3fdd1714ff7923a4f38de07d7b042", + "head": "2d315f90e46982e9658da6483f46e2acbe21bcca", + "report": ".superpowers\\sdd\\reviews\\W1.md", + "report_sha256": "sha256:afb12884b78fdfd3b0edb199900c84f19a130e8bb95c1a680255d59aee43343c", + "plan_hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5", + "plan_revision": 1, + "recorded_at": "2026-09-20T08:23:35.131Z" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzI.json b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzI.json new file mode 100644 index 0000000..fc8c755 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzI.json @@ -0,0 +1,10 @@ +{ + "status": "pass", + "base": "2d315f90e46982e9658da6483f46e2acbe21bcca", + "head": "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7", + "report": ".superpowers\\sdd\\reviews\\W2.md", + "report_sha256": "sha256:2e4ac6255d2c08931102f75b414b24552c6fe2272b3bc139a91e8c4c77215bbe", + "plan_hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5", + "plan_revision": 1, + "recorded_at": "2026-09-20T08:29:10.010Z" +} 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/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzQ.json b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzQ.json new file mode 100644 index 0000000..9c1d78f --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzQ.json @@ -0,0 +1,10 @@ +{ + "status": "pass", + "base": "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b", + "head": "ed212c8e6b44688ce9e7fd9df36e724f5977559e", + "report": ".superpowers\\sdd\\reviews\\W4.md", + "report_sha256": "sha256:2e6fa11d7eca063a80d97e3edc27b21763f285e9f6575e45db57b4c670d51972", + "plan_hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5", + "plan_revision": 1, + "recorded_at": "2026-09-20T09:12:19.493Z" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c/reviews/VzE.json b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c/reviews/VzE.json new file mode 100644 index 0000000..bac3e28 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c/reviews/VzE.json @@ -0,0 +1,10 @@ +{ + "status": "pass", + "base": "36c0214723a3fdd1714ff7923a4f38de07d7b042", + "head": "2d315f90e46982e9658da6483f46e2acbe21bcca", + "report": ".superpowers\\sdd\\reviews\\W1.md", + "report_sha256": "sha256:afb12884b78fdfd3b0edb199900c84f19a130e8bb95c1a680255d59aee43343c", + "plan_hash": "sha256:b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c", + "plan_revision": 1, + "recorded_at": "2026-09-20T09:13:15.161Z" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c/reviews/VzI.json b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c/reviews/VzI.json new file mode 100644 index 0000000..b5472da --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c/reviews/VzI.json @@ -0,0 +1,10 @@ +{ + "status": "pass", + "base": "2d315f90e46982e9658da6483f46e2acbe21bcca", + "head": "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7", + "report": ".superpowers\\sdd\\reviews\\W2.md", + "report_sha256": "sha256:2e4ac6255d2c08931102f75b414b24552c6fe2272b3bc139a91e8c4c77215bbe", + "plan_hash": "sha256:b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c", + "plan_revision": 1, + "recorded_at": "2026-09-20T09:13:15.620Z" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c/reviews/VzM.json b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c/reviews/VzM.json new file mode 100644 index 0000000..1dacc20 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c/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:b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c", + "plan_revision": 1, + "recorded_at": "2026-09-20T09:13:16.077Z" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c/reviews/VzQ.json b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c/reviews/VzQ.json new file mode 100644 index 0000000..47c2847 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c/reviews/VzQ.json @@ -0,0 +1,10 @@ +{ + "status": "pass", + "base": "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b", + "head": "ed212c8e6b44688ce9e7fd9df36e724f5977559e", + "report": ".superpowers\\sdd\\reviews\\W4.md", + "report_sha256": "sha256:2e6fa11d7eca063a80d97e3edc27b21763f285e9f6575e45db57b4c670d51972", + "plan_hash": "sha256:b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c", + "plan_revision": 1, + "recorded_at": "2026-09-20T09:13:16.534Z" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r2-eddde134568a3cf09036f21af22f716d287f8e960ca5f23d96bd01a32a4f8511/checkpoints/1.md b/changes/n05-salary-calc/.superpowers/sdd/plans/r2-eddde134568a3cf09036f21af22f716d287f8e960ca5f23d96bd01a32a4f8511/checkpoints/1.md new file mode 100644 index 0000000..f92b039 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r2-eddde134568a3cf09036f21af22f716d287f8e960ca5f23d96bd01a32a4f8511/checkpoints/1.md @@ -0,0 +1,37 @@ +--- +task_id: "1" +task_hash: "sha256:d6169a29f309b7713b328adf69ad846596c52c63b9ebb92d256b47d444404bc0" +next: "Task 2: create V41 full city policy seed and seed validation tests" +completed: "V40 schema, entities, repositories, migration and repository tests" +evidence: "mvn -Dtest=SalaryMigrationTest,SalaryRepositoryIntegrationTest test: 2/2 passed" +review: "changes/n05-salary-calc/.superpowers/sdd/reviews/T1.md" +risk: "V41 full city policy data collection remains a release gate" +commit_start: "36c0214723a3fdd1714ff7923a4f38de07d7b042" +commit_end: "837c0632c66e6cb9a00e522944f73045258557d6" +created_at: "2026-09-20T07:55:14.538Z" +plan_hash: "sha256:eddde134568a3cf09036f21af22f716d287f8e960ca5f23d96bd01a32a4f8511" +plan_revision: 2 +--- + +# Checkpoint: 1 + +## Next +Task 2: create V41 full city policy seed and seed validation tests + +## Completed +V40 schema, entities, repositories, migration and repository tests + +## Evidence +mvn -Dtest=SalaryMigrationTest,SalaryRepositoryIntegrationTest test: 2/2 passed + +## Review +changes/n05-salary-calc/.superpowers/sdd/reviews/T1.md + +## Risk +V41 full city policy data collection remains a release gate + +## Commit Start +36c0214723a3fdd1714ff7923a4f38de07d7b042 + +## Commit End +837c0632c66e6cb9a00e522944f73045258557d6 diff --git a/changes/n05-salary-calc/.superpowers/sdd/progress.md b/changes/n05-salary-calc/.superpowers/sdd/progress.md new file mode 100644 index 0000000..3cbadf5 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/progress.md @@ -0,0 +1,9 @@ +# n05-salary-calc 执行进度 + +## W1-W4:完成 +- W1:V40/V41、359 城分层政策;3/3 测试通过。 +- W2:城市查询和计算引擎;15/15 测试通过。 +- W3:计算保存、幂等、历史、权限和注销清理;6/6 测试通过。 +- W4 Task 8:Salary*Test 24/24,OpenAPI 139 paths / 178 operations,文档完成。 +- W4 Task 9:一次性 Docker MySQL 8.4 黑盒通过;Flyway 40/41、359 条城市、12 CITY、347 PROVINCE_FALLBACK,六接口流程通过;容器和网络已销毁。 +- 全量 `mvn clean verify`:1516 tests,2 failures / 9 errors,均为既有非薪资模块问题。 diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/T1.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/T1.md new file mode 100644 index 0000000..6d44953 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/T1.md @@ -0,0 +1,8 @@ +# Task 1 self-review + +- Verdict: pass +- Scope: V40 schema, salary city policy/history entities, repositories, migration and persistence tests. +- Evidence: `mvn -Dtest=SalaryMigrationTest,SalaryRepositoryIntegrationTest test` -> 2 tests, 0 failures, 0 errors. +- Contract check: table names, required columns, history owner isolation, idempotency-key query, city ordering, and FK/index obligations are implemented. +- Blockers: none. +- Commit: 837c0632c66e6cb9a00e522944f73045258557d6 diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/T2.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/T2.md new file mode 100644 index 0000000..c4a67d6 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/T2.md @@ -0,0 +1,8 @@ +# Task 2 self-review + +- Verdict: pass +- Scope: V41 layered city policy seed and seed validation. +- Evidence: `mvn -Dtest=SalaryCityPolicySeedTest,SalaryMigrationTest,SalaryRepositoryIntegrationTest test` -> 3 tests, 0 failures, 0 errors. +- Contract check: 359 target city rows; 12 CITY; 347 PROVINCE_FALLBACK; municipality and province-direct city coverage; unique six-digit codes; valid ranges, rates, dates, versions and enabled flags. +- Known limitation: fallback rows use province-level baselines and are explicitly marked. They are not represented as city-level verified values. +- Blockers: none. diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/T3.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/T3.md new file mode 100644 index 0000000..0d4eb9f --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/T3.md @@ -0,0 +1,7 @@ +# Task 3 self-review + +- Verdict: pass +- Scope: city list/detail service and controller, STUDENT authorization, data-level response. +- Evidence: `mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest test` -> 9 tests, 0 failures, 0 errors. +- Contract check: all enabled cities returned; unknown/disabled city 9801; COMPANY 403; anonymous 401; detail includes city defaults and dataLevel. +- Blockers: none. diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/T4.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/T4.md new file mode 100644 index 0000000..691d06c --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/T4.md @@ -0,0 +1,7 @@ +# Task 4 self-review + +- Verdict: pass +- Scope: BigDecimal salary calculation engine and input/override/result models. +- Evidence: `mvn -Dtest=SalaryCalculationEngineTest test` -> 6 tests, 0 failures, 0 errors. +- Contract check: base floor/ceiling, user overrides, contribution rates, special deduction, cumulative month 12, prior withheld tax, annual tax brackets, zero contribution, and 9802 validation. +- Blockers: none. diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/T5.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/T5.md new file mode 100644 index 0000000..c87c36c --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/T5.md @@ -0,0 +1,7 @@ +# Task 5 self-review + +- Verdict: pass +- Scope: calculate-and-save API, policy version validation, normalized request fingerprint and idempotent replay. +- Evidence: `SalaryCalculationEndToEndIntegrationTest` covers success, replay, same-key conflict, stale policy and invalid override. W3 suite 6/6 passed. +- Contract check: no history on version conflict; replay returns same history; structured 9805 data includes latest version/date. +- Blockers: none. diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/T6.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/T6.md new file mode 100644 index 0000000..8120cb7 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/T6.md @@ -0,0 +1,7 @@ +# Task 6 self-review + +- Verdict: pass +- Scope: history list/detail/physical delete, pagination, user isolation and snapshot JSON. +- Evidence: `SalaryCalculationEndToEndIntegrationTest` covers list, detail, cross-user 404 and physical delete; W3 suite 6/6 passed. +- Contract check: list sorted by repository query; details return input/policy/result snapshots; deleted rows are absent. +- Blockers: none. diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/T7.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/T7.md new file mode 100644 index 0000000..3583576 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/T7.md @@ -0,0 +1,7 @@ +# Task 7 self-review + +- Verdict: pass +- Scope: STUDENT authorization, privacy boundaries and account-deletion purge. +- Evidence: `SalaryAuthorizationIntegrationTest` and `SalaryAccountDeletionIntegrationTest`; W3 suite 6/6 passed. +- Privacy check: no direct logger or System.out/printStackTrace use in `com.mydreamjob.salarycalculator`; response DTOs omit requestHash, idempotencyKey and studentUserId. +- Blockers: none. diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/VzE.json b/changes/n05-salary-calc/.superpowers/sdd/reviews/VzE.json new file mode 100644 index 0000000..bac3e28 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/VzE.json @@ -0,0 +1,10 @@ +{ + "status": "pass", + "base": "36c0214723a3fdd1714ff7923a4f38de07d7b042", + "head": "2d315f90e46982e9658da6483f46e2acbe21bcca", + "report": ".superpowers\\sdd\\reviews\\W1.md", + "report_sha256": "sha256:afb12884b78fdfd3b0edb199900c84f19a130e8bb95c1a680255d59aee43343c", + "plan_hash": "sha256:b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c", + "plan_revision": 1, + "recorded_at": "2026-09-20T09:13:15.161Z" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/VzI.json b/changes/n05-salary-calc/.superpowers/sdd/reviews/VzI.json new file mode 100644 index 0000000..b5472da --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/VzI.json @@ -0,0 +1,10 @@ +{ + "status": "pass", + "base": "2d315f90e46982e9658da6483f46e2acbe21bcca", + "head": "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7", + "report": ".superpowers\\sdd\\reviews\\W2.md", + "report_sha256": "sha256:2e4ac6255d2c08931102f75b414b24552c6fe2272b3bc139a91e8c4c77215bbe", + "plan_hash": "sha256:b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c", + "plan_revision": 1, + "recorded_at": "2026-09-20T09:13:15.620Z" +} 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..1dacc20 --- /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:b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c", + "plan_revision": 1, + "recorded_at": "2026-09-20T09:13:16.077Z" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/VzQ.json b/changes/n05-salary-calc/.superpowers/sdd/reviews/VzQ.json new file mode 100644 index 0000000..47c2847 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/VzQ.json @@ -0,0 +1,10 @@ +{ + "status": "pass", + "base": "27dbc095e0413f9566bcc4ddc3bdb4f642535a6b", + "head": "ed212c8e6b44688ce9e7fd9df36e724f5977559e", + "report": ".superpowers\\sdd\\reviews\\W4.md", + "report_sha256": "sha256:2e6fa11d7eca063a80d97e3edc27b21763f285e9f6575e45db57b4c670d51972", + "plan_hash": "sha256:b04ca4af0927fa94d7c56a462549a5edb6717e542ec30228c0f0349848cd631c", + "plan_revision": 1, + "recorded_at": "2026-09-20T09:13:16.534Z" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/W1.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/W1.md new file mode 100644 index 0000000..4e076f6 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/W1.md @@ -0,0 +1,9 @@ +# Wave W1 review + +- Verdict: pass +- Scope: V40 schema, salary persistence foundation, V41 layered city policy seed. +- Tasks: 1, 2. +- Evidence: `mvn -Dtest=SalaryCityPolicySeedTest,SalaryMigrationTest,SalaryRepositoryIntegrationTest test` -> 3 tests, 0 failures, 0 errors. +- Requirements covered: city catalogue data foundation, province-direct and municipality granularity, layered data level, policy version metadata, full target-city seed, table constraints and snapshot storage foundation. +- Review blockers: none. +- Residual release risk: 347 fallback rows require explicit UI disclosure and later city-level verification migrations. diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/W2.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/W2.md new file mode 100644 index 0000000..bf4361f --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/W2.md @@ -0,0 +1,8 @@ +# Wave W2 review + +- Verdict: pass +- Scope: city policy query APIs and BigDecimal calculation engine. +- Tasks: 3, 4. +- Evidence: `mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest,SalaryCalculationEngineTest test` -> 15 tests, 0 failures, 0 errors. +- Requirements covered: enabled city catalogue, city detail defaults, dataLevel, STUDENT authorization, base clamping, override precedence, personal contributions, serious illness, month index, special deduction, cumulative withholding, tax brackets, result breakdown. +- Review blockers: none. diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/W3.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/W3.md new file mode 100644 index 0000000..a19edb5 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/W3.md @@ -0,0 +1,9 @@ +# Wave W3 review + +- Verdict: pass +- Scope: calculation persistence, policy version, idempotency, history CRUD, user isolation and account-deletion cleanup. +- Tasks: 5, 6, 7. +- Evidence: `mvn -Dtest=SalaryAuthorizationIntegrationTest,SalaryCalculationEndToEndIntegrationTest,SalaryAccountDeletionIntegrationTest test` -> 6 tests, 0 failures, 0 errors. +- Requirements covered: calculate and save, exact response breakdown, policy conflict, idempotency replay/conflict, pagination, history privacy, physical delete, account deletion purge and STUDENT-only authorization. +- Residual risk: concurrency race behavior still needs full W4 regression and real MySQL validation. +- Review blockers: none. 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/changes/n05-salary-calc/.superpowers/sdd/reviews/W4.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/W4.md new file mode 100644 index 0000000..f8af193 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/W4.md @@ -0,0 +1,13 @@ +# Wave W4 review + +- Verdict: pass +- Scope: module tests, OpenAPI, documentation, disposable Docker MySQL black-box acceptance. +- Tasks: 8, 9. +- Evidence: + - `mvn -Dtest=Salary*Test test` -> 24 tests, 0 failures, 0 errors. + - `python docs/verify_openapi.py` -> 139 paths / 178 operations, all checks passed. + - `python scripts/verify_salary_calculator_blackbox.py` against disposable MySQL 8.4 -> PASS. + - Flyway 40,41 present; 359 city rows; CITY=12; PROVINCE_FALLBACK=347. + - Temporary backend and MySQL containers and network destroyed. +- Residual release note: full `mvn clean verify` still has pre-existing 2 failures / 9 errors outside salarycalculator. +- Review blockers: none for N05. diff --git a/changes/n05-salary-calc/.superpowers/sdd/workflow-selection.json b/changes/n05-salary-calc/.superpowers/sdd/workflow-selection.json new file mode 100644 index 0000000..0800eda --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/workflow-selection.json @@ -0,0 +1,67 @@ +{ + "schema_version": 2, + "available_modes": [ + "full", + "hotfix", + "tweak", + "quick", + "lightweight" + ], + "facts": { + "task_count": 9, + "file_count": 40, + "config_doc_only": "no", + "schema_api_change": "yes", + "new_module": "yes", + "behavioral_constraint_change": "yes", + "cross_module_change": "yes", + "uncertainty": "low", + "request_kind": "standard", + "affected_paths": [ + "mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/**", + "mydreamjob-server/src/main/resources/db/migration/**", + "docs/**" + ], + "exclusion_checks": { + "production_behavior": "yes", + "public_boundary": "yes", + "installer": "no", + "state_machine": "no", + "external_side_effect": "no", + "data_permission_config_semantics": "yes", + "expected_behavior_clear": "yes", + "verification_reproducible": "yes", + "impact_paths_complete": "yes" + } + }, + "missing_facts": [], + "status": "ready", + "recommendation": { + "mode": "full", + "reasons": [ + "Risk signals require the user to choose Quick or Full." + ], + "risk_reasons": [ + "behavioral constraint changed (PRD, spec, design, data, or permission)", + "schema or API changes", + "new module", + "cross-module change" + ] + }, + "created_at": "2026-09-20T07:28:39.497Z", + "selection": { + "mode": "full", + "reason": "用户确认进入 spec-writer;新增独立薪资计算模块,包含数据库迁移、六个 API、权限、幂等和跨模块注销清理。", + "followed_recommendation": true, + "acknowledged_non_recommendation": false, + "accepted_automatically": false, + "risk_override": false, + "verification_strategy": null, + "scope_confirmation": null, + "verification_result": null, + "escalation_reason": null, + "confirmed_at": "2026-09-20T07:28:44.316Z", + "selected_at": "2026-09-20T07:28:44.316Z" + }, + "hash": "sha256:cbb7aaea7fb522ef42c26d3049b3152b9e541673b269c12e0fa1f88696b6aca0" +} diff --git a/changes/n05-salary-calc/decision-point-audit.md b/changes/n05-salary-calc/decision-point-audit.md new file mode 100644 index 0000000..e26c085 --- /dev/null +++ b/changes/n05-salary-calc/decision-point-audit.md @@ -0,0 +1,74 @@ +# Decision-Point Audit Report + +**变更**: n05-salary-calc +**生成时间**: 2026-09-20T09:13:28.889Z +**当前状态**: executing + +## 汇总表 + +| DP | 名称 | 结果 | 时间戳 | +|----|------|------|--------| +| DP-0 | 用户确认门禁 | confirmed | 2026-09-20T07:28:51Z | +| DP-1 | 需求确认 | confirmed: 后端薪资计算器范围、计算口径、城市政策模型、历史记录生命周期、接口、错误码、隐私与验收标准均已确认 | 2026-09-20T07:29:03Z | +| DP-2 | 工件审查 | approved: layered CITY/PROVINCE_FALLBACK data strategy, updated specs/design/tasks | 2026-09-20T08:15:41Z | +| DP-3 | 契约批准 | approved: updated execution contract with layered city policy data and dataLevel propagation | 2026-09-20T08:15:41Z | +| DP-4 | 执行模式选择 | sdd: plan revision 1; user-confirmed; 任务勾选已完成,重建当前工件哈希并保留同一 W1-W4 执行范围 | 2026-09-20T09:13:10.032Z | +| DP-5 | 调试升级 | not recorded | — | +| DP-6 | 验证失败 | not recorded | — | +| DP-7 | 归档确认 | not recorded | — | + +**统计**: 5/8 已记录,3/8 未记录。 + +## 逐决策点说明 + +### DP-0: 用户确认门禁 + +- **结果**: confirmed +- **时间戳**: 2026-09-20T07:28:51Z +- **解读**: 决策点 DP-0 已记录为 "confirmed"。 + +### DP-1: 需求确认 + +- **结果**: confirmed: 后端薪资计算器范围、计算口径、城市政策模型、历史记录生命周期、接口、错误码、隐私与验收标准均已确认 +- **时间戳**: 2026-09-20T07:29:03Z +- **解读**: 决策点 DP-1 已记录为 "confirmed: 后端薪资计算器范围、计算口径、城市政策模型、历史记录生命周期、接口、错误码、隐私与验收标准均已确认"。 + +### DP-2: 工件审查 + +- **结果**: approved: layered CITY/PROVINCE_FALLBACK data strategy, updated specs/design/tasks +- **时间戳**: 2026-09-20T08:15:41Z +- **解读**: 决策点 DP-2 已记录为 "approved: layered CITY/PROVINCE_FALLBACK data strategy, updated specs/design/tasks"。 + +### DP-3: 契约批准 + +- **结果**: approved: updated execution contract with layered city policy data and dataLevel propagation +- **时间戳**: 2026-09-20T08:15:41Z +- **解读**: 决策点 DP-3 已记录为 "approved: updated execution contract with layered city policy data and dataLevel propagation"。 + +### DP-4: 执行模式选择 + +- **结果**: sdd: plan revision 1; user-confirmed; 任务勾选已完成,重建当前工件哈希并保留同一 W1-W4 执行范围 +- **时间戳**: 2026-09-20T09:13:10.032Z +- **解读**: 决策点 DP-4 已记录为 "sdd: plan revision 1; user-confirmed; 任务勾选已完成,重建当前工件哈希并保留同一 W1-W4 执行范围"。 + +### DP-5: 调试升级 + +- **结果**: not recorded +- **时间戳**: — +- **解读**: 该决策点尚未记录结果。如果工作流已经经过该阶段,请检查是否漏记。 + +### DP-6: 验证失败 + +- **结果**: not recorded +- **时间戳**: — +- **解读**: 该决策点尚未记录结果。如果工作流已经经过该阶段,请检查是否漏记。 + +### DP-7: 归档确认 + +- **结果**: not recorded +- **时间戳**: — +- **解读**: 该决策点尚未记录结果。如果工作流已经经过该阶段,请检查是否漏记。 + +--- + +*本报告由 `ssf audit` 自动生成,仅供审计与归档参考。* diff --git a/changes/n05-salary-calc/design.md b/changes/n05-salary-calc/design.md new file mode 100644 index 0000000..2b5ef94 --- /dev/null +++ b/changes/n05-salary-calc/design.md @@ -0,0 +1,107 @@ +# n05-salary-calc 技术设计 + +## Facts and Constraints + +- 当前项目为 Spring Boot 3.5.16、Java 17、Spring Data JPA、Flyway、MySQL 8.4,测试使用 H2 `MODE=MySQL`。 +- 现有 Flyway 迁移最高为 `V39`,本次只能新增 `V40`、`V41`,不得修改已发布迁移。 +- JWT 认证过滤器把用户 ID 设置为 Authentication principal;现有业务模块通过 `UserRepository` 查询用户并检查 `User.getRoles()` 是否包含 `STUDENT`。 +- 现有统一响应为 `ApiResponse`,分页响应为 `PageResponse`,分页默认值为 `page=0`、`size=20`。 +- 现有账号注销守卫在 `PENDING` 时阻止 POST/PUT/PATCH/DELETE,在 `COMPLETED` 时阻止全部业务请求;注销完成事务会脱敏用户 PII 并保留业务数据。 +- 路线图当前将薪资计算器列为“未开始”,仓库中没有现成城市政策数据表或薪资模块代码。 +- 城市政策必须按中国大陆地级行政区、直辖市和省直辖县级市配置,不拆分到区县;目标城市全量覆盖是发布门槛。 +- 用户确认不提供 ADMIN 城市政策维护接口,城市政策通过迁移发布。 +- 薪资历史不进入个人数据导出,不写业务审计日志,账号注销时物理删除。 + +## Goals and Non-Goals + +### Goals + +- 提供可验证的六个 `/salary-calculator/**` 接口,并只允许当前具有 `STUDENT` 角色的登录用户使用。 +- 通过 `cityCode` 加载当前城市默认参数,执行可覆盖、可追溯的到手工资估算。 +- 保存每次成功计算的可独立读取的历史快照,支持分页查询、详情和物理删除。 +- 通过政策版本和幂等键分别解决“计算时政策已变化”和“请求重复提交”问题。 +- 让城市政策、计算结果、权限和历史生命周期在测试中具有明确、可复现的边界。 + +### Non-Goals + +- 不设计前端页面、交互状态或客户端缓存策略。 +- 不计算企业成本、年度汇算、奖金、股权、多收入来源或非居民税制。 +- 不提供城市政策管理后台、自动抓取或第三方实时政策 API。 +- 不把薪资历史纳入个人数据导出,也不保留用于业务审计的历史副本。 +- 不支持港澳台地区或不适用内地职工社保、公积金政策的地区。 + +## Decisions + +### Decision: 城市政策采用“当前有效行 + 历史快照”,不建立政策版本链 + +- **Choice**:`salary_city_policy` 每个六位 `city_code` 保留一条当前记录,保存 `policy_version`、`policy_effective_date`、参数和 `enabled`;历史计算把实际使用的参数复制到自身 JSON 快照。 +- **Rationale**:每城市只有一套当前默认参数,约数百条记录足以维护;历史结果不应因为后续政策更新或停用而改变。 +- **Alternatives**:建立 `city_policy_versions` 表并按生效期查询;前端携带完整默认参数。 + - 版本链会引入查询时间、发布调度和历史外键复杂度;前端传参加重客户端信任边界。 +- **Consequences**:城市政策变更必须同步提升 `policy_version`;历史表不需要关联具体版本行,但必须保存完整政策快照。 + +### Decision: 使用两张核心表,历史核心字段列式存储、快照 JSON 化 + +- **Choice**:新增 `salary_city_policy` 和 `salary_calculation_history`。历史表保存用户 ID、城市编码/名称、税前月薪、到手工资、月份、幂等键、请求指纹、创建时间等核心列,以及输入、政策、结果三组 JSON 快照。 +- **Rationale**:核心列用于权限隔离、分页、排序和摘要展示;JSON 快照避免为每个未来字段新增大量列,同时保留历史可重建性。 +- **Alternatives**:把所有历史和计算结果规范化为多张明细表;只存一个最终结果 JSON。 + - 多表模型增加查询和迁移成本;只存结果 JSON 无法稳定做列表排序、权限过滤或金额摘要。 +- **Consequences**:需要定义稳定的 JSON 结构和最大长度;H2/MySQL 集成测试必须验证 JSON 字段读写和唯一约束。 + +### Decision: 计算引擎在 Java 中使用 `BigDecimal`,全国税制与地方政策分层 + +- **Choice**:城市表只存地方社保、公积金和大病参数;全国统一个税税率表、速算扣除数、5,000 元基本减除费用和税率版本作为 Java 常量。计算公式按以下顺序执行: + 1. 根据城市政策截断或采用用户覆盖基数。 + 2. 计算个人养老、医疗、失业、公积金和大病固定费。 + 3. 按 `monthIndex` 推导累计收入、累计个人缴费、累计专项附加扣除和累计基本减除费用。 + 4. 计算累计应纳税所得额、按全国税率表计算累计应纳税额和当月税额。 + 5. 计算到手工资并按两位小数输出。 +- **Rationale**:全国税制不应复制到每个城市;高精度十进制才能避免金额和累计税款的舍入漂移。 +- **Alternatives**:由数据库 SQL 计算;用 `double` 计算。 + - SQL 会混合迁移和算法版本;`double` 不适用于金额和累计税额。 +- **Consequences**:算法和税率版本进入独立单元测试;历史记录保存累计税费和 `taxRulesVersion`,以便变更税率时有审计轨迹。 + +### Decision: 计算请求使用 `expectedPolicyVersion` 做乐观版本校验 + +- **Choice**:计算请求必须携带前端已加载的 `expectedPolicyVersion`。后端读取当前城市配置后先比较版本;不一致时返回 `409/9805`,响应只附最新 `policyVersion` 和 `policyEffectiveDate`,不生成历史。 +- **Rationale**:城市详情和计算请求之间可能发生政策更新;静默使用新参数会让前端展示、用户覆盖和历史解释互相矛盾。 +- **Alternatives**:后端始终使用最新配置;客户端把完整默认参数回传。 + - 静默使用最新版本会产生不可解释的历史;回传完整参数会扩大请求和信任边界。 +- **Consequences**:政策维护流程必须保证任何参数变更都提升版本号;前端需要在版本冲突时重新加载城市详情并重新提交。 + +### Decision: 幂等键和规范化请求指纹在同一事务中处理 + +- **Choice**:`idempotencyKey` 可选、最长 64 字符、空白视为未提供。请求先归一化可选字段、默认值和覆盖项,再计算业务字段指纹;同一用户同键且指纹相同返回原历史,指纹不同返回 `409/9804`。 +- **Rationale**:原始 JSON 的字段顺序、空白和显式 null 不能代表业务请求是否相同;事务和数据库唯一约束必须作为并发最终防线。 +- **Alternatives**:直接比较原始请求体;不提供幂等控制。 + - 原始请求比较误判同义请求;不提供幂等会在网络重试或双击时生成重复历史。 +- **Consequences**:历史表需要 `(student_user_id, idempotency_key)` 唯一约束和请求指纹列;删除历史同时释放对应键。 + +### Decision: 历史接口只暴露摘要和本人快照,删除采用物理删除 + +- **Choice**:列表返回摘要,详情返回完整快照;不提供更新接口。删除本人历史时物理删除记录和幂等键;账号注销完成事务调用薪资历史清理服务。访问他人历史和不存在历史统一返回 `404/9803`。 +- **Rationale**:历史是用户个人试算数据,不应允许覆盖或通过 ID 枚举发现;物理删除符合用户主动清理和账号注销预期。 +- **Alternatives**:软删除、保留历史用于审计、提供备注和编辑。 + - 软删除仍保留可关联个人数据;编辑或备注会破坏不可变快照语义;审计保留没有明确业务需求。 +- **Consequences**:需要在注销完成事务中增加清理调用和对应集成测试;历史记录不可作为审计或财务凭证。 + +### Decision: 城市政策通过 V41 种子交付,并采用城市级与省级回退的分层精度 + +- **Choice**:`V40` 建表,`V41` 为全部目标城市写入可计算记录,并增加 `data_level` 字段: + - `CITY`:已核验到城市级公开参数的记录,使用城市自己的上下限和比例。 + - `PROVINCE_FALLBACK`:无法核验城市级参数的记录,复制所属省级公开参数,并在城市列表、城市详情、计算响应和历史快照中明确标记。 +- **Rationale**:公开资料没有覆盖全部地级行政区的统一城市级结构化数据集;分层精度可以在保持全国目标城市都可计算的同时,避免把省级近似值伪装成城市级官方值。 +- **Alternatives**:等待人工逐城市补齐 300+ 条政策;用省级默认值但不加标识;只发布已核验城市。 + - 逐城市核验会无限期阻塞首期;无标识回退会误导用户;只发布部分城市不满足全国可计算目标。 +- **Consequences**:所有消费方必须展示 `dataLevel`;`PROVINCE_FALLBACK` 结果的解释必须明确说明精度较低。后续核验到城市级参数时,通过新迁移更新该城市记录、提升 `policyVersion` 并更新 `dataLevel`。 + +## Risks and Verification + +- **城市政策数据错误或缺失** → 采用采集截止日基线、目标行政区清单校验、字段范围测试和代表性城市人工抽查 → `SalaryCityPolicySeedTest`、迁移集成测试和 MySQL/Docker 黑盒验收。 +- **累计税额或 rounding 与工资单不一致** → 使用高精度十进制、固定税率版本和明确假设 → 覆盖税率档边界、基数上下限、零缴费、`monthIndex=1/12` 的精确金额测试。 +- **政策更新与计算竞态** → 强制 `expectedPolicyVersion` 校验,冲突不生成历史 → `SalaryPolicyVersionIntegrationTest`。 +- **重复提交产生重复历史** → 规范化指纹、事务和用户+幂等键唯一约束 → `SalaryIdempotencyIntegrationTest`。 +- **跨用户读取或删除历史** → 查询和删除同时限定当前用户,统一隐藏资源存在性 → `SalaryHistoryPrivacyTest`。 +- **账号注销后仍保留薪资历史** → 在注销完成事务中执行物理清理 → `SalaryAccountDeletionIntegrationTest`。 +- **历史 JSON 在 MySQL 与 H2 行为不一致** → 两套集成测试和真实 MySQL 黑盒验收 → `mvn -Dtest=Salary*Test test`、`mvn clean verify`、`python docs/verify_openapi.py`。 + diff --git a/changes/n05-salary-calc/execution-contract.md b/changes/n05-salary-calc/execution-contract.md new file mode 100644 index 0000000..fe51d3b --- /dev/null +++ b/changes/n05-salary-calc/execution-contract.md @@ -0,0 +1,135 @@ +# n05-salary-calc 执行合同 + +## Intent Lock + +- **变更名称**:`n05-salary-calc` +- **要解决的问题**:当前后端没有城市职工社保、公积金、大病固定费和到手工资估算能力,求职者无法基于地级行政区默认参数、个人覆盖项和累计预扣法获得可复核的结果,也无法保存独立的历史快照。 +- **范围内**:新增仅限登录 `STUDENT` 的 `/salary-calculator/**` 后端模块;新增 `V40/V41` 城市政策和历史表;`V41` 采用 `CITY` 与 `PROVINCE_FALLBACK` 分层数据并覆盖全部目标城市;实现城市参数、用户覆盖、工资计算、累计预扣、历史保存/查询/物理删除、政策版本校验、幂等并发、账号注销清理、OpenAPI 和测试。 +- **范围外**:前端页面、ADMIN 政策维护、企业成本、年终奖/股权/多收入来源、年度汇算、非居民税制、薪资参考、Offer 关联、个人数据导出和港澳台政策。 + +## Approved Behavior + +- **已批准需求摘要**:`specs/salary-calculation/spec.md` 与 `specs/salary-history/spec.md` 中的 22 条 `### Requirement` 全部批准,覆盖: + - STUDENT-only 权限和六个接口。 + - 城市政策目录、地级行政区粒度、版本元数据和全量种子。 + - 用户覆盖优先、基数截断、个人五险一金、大病固定费。 + - 累计月份、专项附加扣除、居民个人综合所得累计预扣、到手工资和完整结果拆解。 + - 输入范围、金额精度和 `9801–9805` 错误语义。 + - 自动保存历史、完整可重建快照、幂等并发、个人隔离、分页、不可编辑、物理删除和长期保留。 + - 历史不进业务审计日志或数据导出,账号注销时物理清理。 + - `V40/V41` 迁移、全量城市覆盖、`CITY`/`PROVINCE_FALLBACK` 精度标识、唯一键、索引和完整性约束。 +- **关键场景**:城市政策版本冲突不生成历史;覆盖基数超出城市范围返回 400;`monthIndex` 表示累计计税月份;累计税额不产生负数;相同幂等键只创建一条历史;跨用户历史统一返回 404;城市停用不影响旧历史;账号注销完成删除全部薪资历史。 +- **验收检查**:六个接口的 MockMvc/E2E 场景,城市种子完整性测试,金额边界测试,幂等并发测试,权限与注销测试,OpenAPI 校验,真实 MySQL/Docker 黑盒验收。 +- **未映射需求**:无。22 条需求均在 `tasks.md` 的 Requirement-to-Task Coverage 中有任务覆盖。 + +## Design Constraints + +- **架构约束**:新增 `com.mydreamjob.salarycalculator` 模块,复用 `ApiResponse`、`PageResponse`、`SecurityContext`、`UserRepository`、`DeletionGuardInterceptor` 和现有注销事务;不引入外部服务。 +- **接口约束**:只提供以下接口,并仅在成功请求时创建历史: + - `GET /salary-calculator/cities` + - `GET /salary-calculator/cities/{cityCode}` + - `POST /salary-calculator/calculations` + - `GET /salary-calculator/calculations` + - `GET /salary-calculator/calculations/{id}` + - `DELETE /salary-calculator/calculations/{id}` +- **计算约束**:使用 `BigDecimal`;城市只提供社保/公积金/大病参数;全国税率表、速算扣除数、5,000 元减除费用和税率版本在代码中维护;`expectedPolicyVersion` 必须匹配。 +- **数据约束**:只新增 `salary_city_policy` 和 `salary_calculation_history`;城市编码唯一,历史用户+幂等键唯一;城市 `data_level` 必须为 `CITY` 或 `PROVINCE_FALLBACK`,回退城市使用所属省级参数并在所有相关响应和快照中显式标记;输入、最终参数和结果 JSON 快照必须可独立读取;账号注销物理删除历史。 +- **隐私约束**:日志不得记录金额、基数、税额或快照;历史不写业务审计日志、不进入个人数据导出。 + +## Execution Plan + +- **推荐模式**:`sdd`。任务跨越迁移、查询、计算、历史、权限/注销、测试和文档多个波次,需要显式依赖和 review receipt。 +- **用户确认状态**:DP-4 待 DP-3 通过后由 `build-executor` 重新运行 `ssf execution recommend` 并由用户确认。本合同不把推荐自动视为已选择。 +- **执行计划命令**:待 DP-4 确认后由 `build-executor` 执行 `ssf execution plan --mode sdd --confirm --reason "<用户确认原因>" --wave ...`。 +- **计划 revision / artifact hash**:由 `ssf execution plan` 或状态重建记录。 + +## Execution Waves + +### Wave W1 - 数据基础 + +- **Wave ID**:`W1` +- **任务**:`1`, `2` +- **依赖 wave**:无 +- **策略**:`serial` +- **目标**:完成 `V40` 表结构(含 `data_level`)、领域持久化基础、`V41` 分层城市政策种子及全量种子校验。 +- **输入**:`V39` 现状、官方行政区划清单、公开城市政策资料、实体/Repository 约定。 +- **输出**:两张业务表、实体、Repository、城市种子和迁移/种子测试。 +- **完成标准**:H2/MySQL 迁移成功;目标城市全覆盖、编码唯一、字段合法、日期不晚于采集截止日、版本号非空;每条记录准确标记 `CITY` 或 `PROVINCE_FALLBACK`。 +- **Review gate**:报告 `.superpowers/sdd/reviews/W1.md`;wave 完成时记录 base/head SHA;`ssf execution review` 必须为 `pass`。 + +### Wave W2 - 城市查询与计算引擎 + +- **Wave ID**:`W2` +- **任务**:`3`, `4` +- **依赖 wave**:`W1` +- **策略**:`serial` +- **目标**:实现城市列表/详情接口和精确薪资计算引擎。 +- **输入**:W1 实体/Repository/城市种子、公式约束、DTO 与校验规则。 +- **输出**:城市 Service/Controller/DTO;计算器、税率表、输入/结果 DTO;服务与控制器测试。 +- **完成标准**:STUDENT 可查询城市默认参数;基数截断、覆盖率、个人缴费、累计预扣、税额下限和金额精度测试通过。 +- **Review gate**:报告 `.superpowers/sdd/reviews/W2.md`;wave 完成时记录 base/head SHA;`ssf execution review` 必须为 `pass`。 + +### Wave W3 - 历史、幂等、权限与注销 + +- **Wave ID**:`W3` +- **任务**:`5`, `6`, `7` +- **依赖 wave**:`W2` +- **策略**:`serial` +- **目标**:实现计算并保存、历史 CRUD、政策版本校验、幂等并发、用户隔离、注销守卫和物理清理。 +- **输入**:W2 计算服务和城市接口;历史表/Repository;现有错误码与注销事务。 +- **输出**:计算/历史 Service 与 Controller、`9801–9805`、幂等指纹、注销清理调用及集成测试。 +- **完成标准**:正常计算返回历史 ID;版本冲突返回 409/9805;同键重放返回原历史;跨用户 404;删除物理生效;注销完成无历史残留。 +- **Review gate**:报告 `.superpowers/sdd/reviews/W3.md`;wave 完成时记录 base/head SHA;`ssf execution review` 必须为 `pass`。 + +### Wave W4 - 发布证明与文档 + +- **Wave ID**:`W4` +- **任务**:`8`, `9` +- **依赖 wave**:`W3` +- **策略**:`serial` +- **目标**:补齐模块测试、OpenAPI、API/联调文档、README/路线图,执行全量回归和真实 MySQL/Docker 黑盒验收。 +- **输入**:W1–W3 全部代码和测试;现有 OpenAPI 校验脚本。 +- **输出**:六个接口的静态契约和文档、模块专项测试、全量构建结果、MySQL 黑盒验收记录。 +- **完成标准**:`mvn -Dtest=Salary*Test test`、`mvn clean verify`、`python docs/verify_openapi.py` 通过;真实 MySQL/Docker 迁移与六个接口验收通过。 +- **Review gate**:报告 `.superpowers/sdd/reviews/W4.md`;wave 完成时记录 base/head SHA;`ssf execution review` 必须为 `pass`。 + +## Test Obligations + +- **必须先从失败测试开始的行为**:城市种子完整性和范围校验;基数截断与用户覆盖;个人养老/医疗/失业/公积金/大病费用;累计月份与累计预扣;政策版本冲突;幂等重放/冲突;跨用户历史隔离;物理删除;账号注销清理。 +- **必需的边界情况**:基数下限、上限和区间中值;比例 0;大病固定费为 0;专项附加扣除为 0 和大于月薪;`monthIndex=1` 和 12;税率档边界;重复删除;城市停用后的旧历史;金额超过两位小数;`size=101`。 +- **回归敏感区域**:现有账号注销守卫和完成事务;用户角色读取;`ErrorCode` 新增值;`SecurityConfig` 路由;既有 OpenAPI 路径总数;H2 与 MySQL 对 JSON/唯一约束的行为差异。 +- **测试命令**:专项 `mvn -Dtest=Salary*Test test`;完整 `mvn clean verify`;契约 `python docs/verify_openapi.py`;发布门禁另执行真实 MySQL/Docker 黑盒验收。 + +## Execution Mode + +- **可用方式与推荐**:`inline`、`batch-inline`、`sdd`;推荐 `sdd`。 +- **用户确认的模式**:DP-4 阶段确认,本合同不代替用户选择。 +- **推荐理由 / 项目事实**:9 个任务拆为 4 个有依赖的 wave,跨数据库迁移、计算、API、权限和历史生命周期,必须保留 review receipt。 +- **非推荐选择的风险确认**:不适用;若用户选择非推荐模式,必须由 CLI 记录 `--acknowledge-recommendation`。 +- **执行计划命令**:由 `build-executor` 在 DP-4 后运行。 +- **允许的修订**:仅可保留或升级为 `sdd`;新 revision 必须先刷新 recommendation,并清除旧 receipt。 +- **计划 revision / artifact hash**:执行计划保存后记录。 + +## Verification Dimensions + +| 维度 | 状态 | 发现 | +|------|------|------| +| Completeness | Pending | 等待实现与测试证据 | +| Correctness | Pending | 等待金额、迁移和接口验证 | +| Coherence | Pending | 等待实现与规格逐条对照 | + +**总体结论**:Pending,等待实现和 wave review 证据。 + +## Review Gates + +- **强制审查点**:每个 Execution Wave 完成后必须记录 `ssf execution review` 的 `pass` receipt;W1–W4 的报告路径分别由 wave 完成时生成。 +- **阻塞类别**:依赖 wave 未通过、review receipt 为 `fail`、缺失或过期、合同与 specs 不一致、城市种子不完整。 +- **收口条件**:W1–W4 均有当前 revision 的 `pass` receipt;专项/全量/OpenAPI/MySQL 证据存在;没有未映射需求或未解决的 P1/P2 审查问题。 + +## Escalation Rules + +- **何时回退到 `specifying`**:目标城市定义、计算口径、权限语义、历史生命周期或错误码发生变化;新增或删除已批准需求;必须扩大范围到前端、ADMIN、奖金、年度汇算或薪资参考。 +- **何时回退到 `bridging`**:`execution-contract.md` 与 proposal/spec/design/tasks 不一致;执行 wave 改变依赖或完成标准;城市数据或注销集成方案改变架构约束。 +- **何时不得继续实现**:DP-3 未批准、没有当前 execution plan、依赖 wave 未 pass、review receipt 缺失/过期、V41 无法满足目标城市全量覆盖、发现未处理的安全/隐私回归。 +- **DP-4 规则**:`sdd` 模式仅在重新运行 recommendation 并由用户显式确认后写入 execution plan;不得由 Agent 自动代替用户确认。 + diff --git a/changes/n05-salary-calc/proposal.md b/changes/n05-salary-calc/proposal.md new file mode 100644 index 0000000..e3efec4 --- /dev/null +++ b/changes/n05-salary-calc/proposal.md @@ -0,0 +1,69 @@ +# n05-salary-calc 薪资计算器与历史记录 + +## Why + +路线图将“薪资计算器”列为完全缺失能力,当前后端没有城市社保、公积金政策参数,也没有统一的到手工资计算与历史快照。求职者只能手工套用税率和各地政策,容易出现基数截断、累计预扣、个人缴费比例或大病固定费口径错误,且无法保存和复核自己的估算结果。 + +本变更新增一个面向已登录 `STUDENT` 的后端闭环:按中国大陆地级行政区和省直辖县级市加载当前政策,允许用户覆盖个人缴费参数,按居民个人综合所得累计预扣法估算到手工资,并把每次成功计算保存为独立的不可变历史快照。 + +## What Changes + +- 新增 `salarycalculator` 后端模块与六个接口: + - `GET /salary-calculator/cities` + - `GET /salary-calculator/cities/{cityCode}` + - `POST /salary-calculator/calculations` + - `GET /salary-calculator/calculations` + - `GET /salary-calculator/calculations/{id}` + - `DELETE /salary-calculator/calculations/{id}` +- 新增 `salary_city_policy` 与 `salary_calculation_history` 两张核心表,`V40` 建表,`V41` 写入覆盖全部目标城市的政策种子,并通过 `dataLevel` 区分城市级参数与省级回退参数。 +- 城市政策以六位行政区划代码为主键,覆盖大陆全部地级行政区、直辖市和省直辖县级市;同城不拆分到区县。每条记录标记 `CITY` 或 `PROVINCE_FALLBACK`,不得把省级近似值伪装成城市级官方值。 +- 计算支持城市默认值、用户覆盖项、社保与公积金基数截断、个人养老/医疗/失业/公积金、大病固定费、专项附加扣除、累计月份和居民个人综合所得累计预扣。 +- 历史记录保存输入、城市默认参数、用户覆盖项、最终生效参数、完整结果、城市政策版本、全国税率版本和创建时间;历史不可编辑,支持分页列表、详情和物理删除单条。 +- 计算接口支持可选 `idempotencyKey` 和服务端请求指纹去重,并要求 `expectedPolicyVersion` 与当前城市政策版本一致。 +- 新增 `9801–9805` 错误码,覆盖城市不存在、参数非法、历史不存在、幂等冲突和政策版本冲突。 +- 薪资历史不写入业务审计日志、不进入个人数据导出;账号注销完成时物理删除该用户全部薪资历史。 + +## Scope + +### In Scope + +- 中国大陆地级行政区、直辖市和省直辖县级市的城市政策配置、启用状态、`dataLevel` 数据精度、政策版本与生效日期。 +- 城市列表、城市详情和当前默认参数查询。 +- 税前月薪、累计月份、专项附加扣除和用户覆盖项驱动的到手工资估算。 +- 居民个人综合所得累计预扣、个人五险一金、公积金和大病固定费计算。 +- 计算历史的快照保存、分页查询、详情、物理删除和账号注销清理。 +- `STUDENT` 角色、JWT 身份、用户数据隔离、政策版本冲突和幂等并发处理。 +- `V40/V41` Flyway 迁移、H2 集成测试、真实 MySQL/Docker 黑盒验收、OpenAPI 和前端联调文档。 + +### Out of Scope + +- 前端页面、交互设计、客户端状态管理和支付/工资单集成。 +- 企业用工成本、单位社保和单位公积金视角。 +- 年终奖、13 薪、绩效、提成、股票、期权、副业或多单位收入。 +- 入职、离职、请假导致的天级折算,非居民个人计税和年度汇算清缴。 +- 个税专项附加扣除的项目级明细、家庭成员和年度限额管理。 +- 行业薪资参考、岗位薪资排名、薪资分布和 Offer 综合比较。 +- ADMIN 城市政策维护界面、外部实时政策 API 和自动抓取服务。\n- 不承诺未核验城市参数达到城市级官方精确度;此类城市只能使用明确标记的省级回退参数。 +- 个人数据导出中的薪资历史章节。 +- 港澳台地区和其他不适用内地社保、公积金及税制的地区。 + +## Impact + +- `mydreamjob-server/src/main/resources/db/migration/`:新增 `V40`、`V41`,不修改历史迁移。 +- `mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/`:新增实体、Repository、计算服务、Controller、DTO 和错误处理。 +- `mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java`:新增 `9801–9805`。 +- `mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java`:明确薪资接口的认证入口。 +- `mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java`:在注销完成事务中清理该用户薪资历史。 +- `mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/`:新增单元、MockMvc、持久化、权限、幂等、迁移和端到端测试。 +- `docs/openapi-rc.json`、`docs/salary-calculator-api.md`、`docs/frontend-integration.md`、`docs/backend-development-roadmap.md`、`README.md`:同步接口、错误码、计算口径和状态。 +- 不改变现有 Offer、个人资料、求职偏好、账号注销读接口或既有错误码语义。 + +## Proof of Completion + +- 已登录 `STUDENT` 可通过城市接口加载当前政策,通过计算接口获得包含当月与累计拆解的到手工资估算。 +- 用户覆盖项优先于城市默认值;基数截断、比例、上限、大病固定费、专项扣除和累计税款的边界测试全部通过。 +- 政策版本不匹配返回 `409/9805` 且不生成历史;相同幂等键重放不产生重复历史,同键不同规范化请求返回 `409/9804`。 +- 历史只能由所属用户查询和删除;删除为物理删除,账号注销完成会清理该用户全部薪资历史。 +- `V41` 种子测试证明目标城市全覆盖、城市编码唯一、字段合法、生效日期不晚于采集截止日、版本号非空,并明确区分城市级与省级回退记录。 +- `mvn -Dtest=SalaryCalculator*Test test`、`mvn clean verify`、`python docs/verify_openapi.py` 和真实 MySQL/Docker 黑盒验收通过。 + diff --git a/changes/n05-salary-calc/specs/salary-calculation/spec.md b/changes/n05-salary-calc/specs/salary-calculation/spec.md new file mode 100644 index 0000000..27a23dc --- /dev/null +++ b/changes/n05-salary-calc/specs/salary-calculation/spec.md @@ -0,0 +1,153 @@ +# 薪资计算能力规格 + +## Purpose + +为已登录求职者提供基于城市当前政策、用户显式覆盖和个人综合所得累计预扣法的到手工资估算能力。 + +## ADDED Requirements + +### Requirement: 仅已登录 STUDENT 可访问薪资计算模块 + +The system SHALL 要求薪资城市查询、计算和历史接口均通过 JWT 认证,并且当前用户必须具有 STUDENT 角色。未认证请求 MUST 返回 401;已认证但非 STUDENT 的请求 MUST 返回 403。 + +#### Scenario: 未登录访问城市列表 + +- **WHEN** 客户端未携带有效访问令牌调用 `GET /salary-calculator/cities` +- **THEN** 系统返回 401,且不返回任何城市政策数据 + +#### Scenario: 企业用户调用计算接口 + +- **WHEN** 已登录的 COMPANY 用户调用 `POST /salary-calculator/calculations` +- **THEN** 系统返回 403,且不创建历史记录 + +### Requirement: 查询当前启用的城市政策目录 + +The system SHALL 提供城市列表和城市详情接口。城市列表 MUST 只返回 `enabled=true` 的城市摘要,并按省份编码、城市编码稳定排序;城市详情 MUST 返回城市元数据、政策版本、生效日期和当前整套默认参数。 + +#### Scenario: 获取全部启用城市 + +- **WHEN** STUDENT 调用 `GET /salary-calculator/cities` +- **THEN** 系统按省份编码和城市编码返回所有启用城市摘要,且不包含区县记录 + +#### Scenario: 获取城市默认参数 + +- **WHEN** STUDENT 调用 `GET /salary-calculator/cities/{cityCode}` 且该城市已启用 +- **THEN** 系统返回六位城市编码、省市名称、政策版本、生效日期、`dataLevel`、社保和公积金基数上下限、个人比例及大病固定费 + +### Requirement: 城市配置以地级行政区和省直辖县级市为最小单元 + +The system SHALL 以六位行政区划代码作为城市政策主键,覆盖中国大陆全部地级行政区、直辖市和省直辖县级市,不拆分区县,不包含港澳台。 + +#### Scenario: 直辖市使用一条配置 + +- **WHEN** 客户端查询北京市或重庆市 +- **THEN** 系统返回单一城市级配置,而不是按区县返回多条配置 + +#### Scenario: 市内存在分区差异 + +- **WHEN** 同一地级市不同区县存在政策差异 +- **THEN** 系统仍只使用该地级市的一条当前配置,并在估算声明中提示结果可能受同城差异影响 + +### Requirement: 校验城市政策版本和元数据 + +The system SHALL 要求每条城市政策具有唯一的六位编码、非空且不晚于采集截止日的生效日期、非空政策版本、合法参数以及 `dataLevel`。`CITY` 表示已核验的城市级参数;`PROVINCE_FALLBACK` 表示使用所属省级公开参数的近似配置。任何参数或生效日期变化 MUST 同步更新 `policyVersion`。 + +#### Scenario: 客户端使用已失效的政策版本计算 + +- **WHEN** STUDENT 提交的 `expectedPolicyVersion` 与当前城市版本不一致 +- **THEN** 系统返回 409/9805,响应包含最新 `policyVersion` 和 `policyEffectiveDate`,且不生成历史记录 + +#### Scenario: 城市政策种子存在非法参数 + +- **WHEN** `V41` 中存在上下限倒置、比例超限或缺失必填字段的城市记录 +- **THEN** 迁移或种子集成测试失败,禁止进入验收 + +#### Scenario: 使用省级回退参数的城市 + +- **WHEN** 某目标城市没有可核验的城市级参数,V41 使用所属省级公开参数创建记录 +- **THEN** 该记录必须标记为 PROVINCE_FALLBACK,城市详情、计算响应和历史快照都必须明确返回该精度等级 +- **THEN** 系统不得把该记录展示为 CITY 或声称其达到城市级官方精确度 + +### Requirement: 用户覆盖参数优先于城市默认值 + +The system SHALL 允许用户分别覆盖社保基数、公积金基数、养老比例、医疗比例、失业比例、公积金比例和大病固定费。未覆盖字段 MUST 使用城市默认值;显式覆盖值 MUST 通过城市上下限和全国合理性范围校验。 + +#### Scenario: 只覆盖公积金基数 + +- **WHEN** 用户提交新的公积金基数但未提交比例覆盖 +- **THEN** 系统使用用户公积金基数和城市默认公积金比例,其余项目继续使用城市默认值 + +#### Scenario: 覆盖基数超出城市范围 + +- **WHEN** 用户提交低于社保下限的社保基数 +- **THEN** 系统返回 400/9802,且不生成历史记录 + +### Requirement: 计算个人五险一金和大病固定费 + +The system SHALL 按社保基数计算个人养老、医疗和失业,按公积金基数计算个人公积金,并按城市或用户覆盖值扣除按月大病固定费。工伤和生育保险、单位缴费和企业总成本 MUST 不计入个人到手工资。 + +#### Scenario: 未覆盖任何参数 + +- **WHEN** 用户输入税前月薪且未提供覆盖项 +- **THEN** 社保基数按税前月薪截断到社保区间,公积金基数按税前月薪截断到公积金区间,各个人项目按对应比例计算 + +#### Scenario: 用户将某项比例覆盖为 0 + +- **WHEN** 用户将失业比例覆盖为 0 +- **THEN** 个人失业扣款为 0,其他项目按各自生效参数计算 + +### Requirement: 使用累计月份和专项附加扣除计算应纳税所得额 + +The system SHALL 接受 `monthIndex`(1–12)和月度专项附加扣除。`monthIndex` MUST 表示本年度当前工资口径下已经累计计税的月份数,并假设前序月份的收入、个人缴费和专项附加扣除与本次输入相同。 + +#### Scenario: 首次领薪但自然月为九月 + +- **WHEN** 用户九月首次领薪并提交 `monthIndex=1` +- **THEN** 系统按一个月的累计收入和扣除计算,而不是按九个月计算 + +#### Scenario: 累计月份为十二 + +- **WHEN** 用户提交 `monthIndex=12` +- **THEN** 系统按十二个月的相同工资、个人缴费和专项附加扣除推导全年累计口径 + +### Requirement: 按居民个人综合所得累计预扣法计算个税 + +The system SHALL 使用全国统一的居民个人综合所得年度累进预扣税率表和速算扣除数。基本减除费用 MUST 固定为每月 5,000 元,累计应纳税所得额小于等于 0 时 MUST 按 0 计税,当月税额 MUST 不得为负。 + +#### Scenario: 累计应纳税所得额低于起征线 + +- **WHEN** 累计收入扣除个人缴费、专项附加扣除和 5,000 × monthIndex 后不大于 0 +- **THEN** 累计应纳税额为 0,当月个税为 0 + +#### Scenario: 月薪进入更高累计税率档 + +- **WHEN** 累计应纳税所得额超过当前档位上限 +- **THEN** 系统按完整年度累进税率表计算累计应纳税额,并减去按相同假设推导的累计已预扣税额 + +### Requirement: 输出到手工资和完整计算拆解 + +The system SHALL 返回税前月薪、城市及政策版本、社保基数、公积金基数、个人养老、个人医疗、个人失业、个人公积金、大病固定费、基本减除费用、专项附加扣除、累计月份、累计与当月计税链路、当月个税和到手工资。响应 MUST 标记 `estimate=true`。 + +#### Scenario: 计算成功 + +- **WHEN** STUDENT 提交合法的城市、政策版本和工资参数 +- **THEN** 系统返回完整拆解、`estimate=true`、全国税率版本、城市政策版本与生效日期,并返回可访问的历史 ID + +### Requirement: 校验工资输入并限制金额精度 + +The system SHALL 要求税前月薪大于 0 且不超过 10,000,000 元,专项附加扣除在 0–10,000,000 元之间,所有用户金额输入最多两位小数。比例 MUST 使用小数表示,且个人上限为养老 20%、医疗 20%、失业 10%、公积金 12%;大病固定费不得超过 10,000 元/月。 + +#### Scenario: 金额超过两位小数 + +- **WHEN** 用户提交 `0.001` 元的金额字段 +- **THEN** 系统返回 400/9802,且不执行计算 + +### Requirement: 返回稳定的城市和历史错误语义 + +The system SHALL 使用 `400` 表示参数非法,`404` 表示城市或历史不存在,`409` 表示幂等或政策版本冲突。`9801` MUST 表示城市不存在或停用,`9802` MUST 表示参数非法,`9803` MUST 表示历史不存在或无权访问,`9804` MUST 表示幂等冲突,`9805` MUST 表示政策版本冲突。 + +#### Scenario: 城市停用 + +- **WHEN** 用户提交已停用城市的计算请求 +- **THEN** 系统返回 404/9801,且不生成历史记录 + diff --git a/changes/n05-salary-calc/specs/salary-history/spec.md b/changes/n05-salary-calc/specs/salary-history/spec.md new file mode 100644 index 0000000..f44437f --- /dev/null +++ b/changes/n05-salary-calc/specs/salary-history/spec.md @@ -0,0 +1,127 @@ +# 薪资历史能力规格 + +## Purpose + +保存求职者每次成功薪资估算的可追溯快照,并提供仅限本人访问的分页查询、详情和物理删除能力。 + +## ADDED Requirements + +### Requirement: 成功计算自动保存历史 + +The system SHALL 在计算并保存接口成功完成时自动创建一条历史记录,并在响应中返回历史 ID 和创建时间。系统 MUST NOT 要求调用方再调用第二个保存接口。 + +#### Scenario: 首次成功计算 + +- **WHEN** STUDENT 提交合法且政策版本一致的计算请求 +- **THEN** 系统在同一事务中完成计算并保存历史,返回结果及新历史 ID + +### Requirement: 历史保存完整且可重建的快照 + +The system SHALL 在历史记录中保存原始输入、城市编码与名称、`dataLevel`、城市默认参数、用户覆盖项、最终生效参数、完整结果、`monthIndex`、城市政策版本、政策生效日期、全国税率版本和创建时间。三类 JSON 快照 MUST 能独立重建当时结果。 + +#### Scenario: 城市参数后来变化 + +- **WHEN** 历史创建后城市默认参数或政策版本发生变化 +- **THEN** 历史详情仍返回创建时的输入、政策、覆盖项和完整结果,不重新读取当前城市配置,包括当时的 `CITY` 或 `PROVINCE_FALLBACK` 精度标识 + +### Requirement: 计算保存使用幂等键防重 + +The system SHALL 支持可选 `idempotencyKey`,最长 64 字符,空白值按未提供处理。同一用户相同幂等键且规范化业务字段相同 MUST 返回原历史;相同幂等键但规范化字段不同 MUST 返回 409/9804。 + +#### Scenario: 相同请求网络重试 + +- **WHEN** 客户端使用相同用户、相同幂等键和相同规范化业务字段重试计算 +- **THEN** 系统返回同一条历史和相同计算结果,不新增数据库行 + +#### Scenario: 相同幂等键不同请求体 + +- **WHEN** 客户端复用幂等键但修改税前月薪或政策版本 +- **THEN** 系统返回 409/9804,且不创建第二条历史 + +### Requirement: 同一幂等键并发只能生成一条历史 + +The system SHALL 使用事务和数据库唯一约束处理并发。相同用户相同幂等键并发请求 MUST 最多生成一条历史;同键不同请求并发时,一方成功,另一方 MUST 返回 409/9804。 + +#### Scenario: 并发相同请求 + +- **WHEN** 两个请求同时提交相同用户、幂等键和规范化业务字段 +- **THEN** 数据库中最多存在一条历史,两个成功响应指向同一历史 ID + +### Requirement: 历史列表和详情仅返回本人数据 + +The system SHALL 提供按创建时间倒序、ID 倒序的分页列表和详情接口。列表 MUST 只返回摘要;详情 MUST 返回完整快照。查询必须同时按当前登录用户和历史 ID 过滤。 + +#### Scenario: 查询本人历史 + +- **WHEN** STUDENT 调用历史列表或详情接口 +- **THEN** 系统只返回该 STUDENT 的历史,按最新创建时间排序 + +#### Scenario: 猜测他人历史 ID + +- **WHEN** STUDENT 请求不属于自己的历史 ID +- **THEN** 系统返回 404/9803,不泄露该记录是否存在 + +### Requirement: 历史不可编辑且可以物理删除单条 + +The system SHALL 不提供历史更新接口。历史创建后不可修改;用户删除本人历史时 MUST 物理删除该行及其幂等键,后续可以为该幂等键创建新历史。 + +#### Scenario: 删除本人历史 + +- **WHEN** STUDENT 删除自己的历史记录 +- **THEN** 系统物理删除该记录并返回成功响应,后续详情查询返回 404/9803 + +#### Scenario: 重复删除历史 + +- **WHEN** STUDENT 再次删除已经删除的历史 +- **THEN** 系统返回 404/9803,不返回成功状态 + +### Requirement: 历史列表采用统一分页且不提供额外筛选 + +The system SHALL 使用 `page=0`、`size=20` 的分页默认值,`size` 最大为 100;首期 MUST NOT 增加城市、日期范围或薪资范围筛选。 + +#### Scenario: 超出分页上限 + +- **WHEN** 客户端请求 `size=101` +- **THEN** 系统返回 400/9802,不返回历史列表 + +### Requirement: 历史数据按用户长期保留 + +The system SHALL 允许历史记录长期保留直至用户主动删除,不设置自动过期、定时清理或业务数量配额。城市停用、政策更新或历史数量增长 MUST NOT 影响历史详情读取。 + +#### Scenario: 城市停用后查看旧历史 + +- **WHEN** 历史所属城市后来被停用 +- **THEN** STUDENT 仍可查看该历史详情 + +### Requirement: 历史不进入审计日志或个人数据导出 + +The system SHALL 不因薪资计算或单条历史删除写入业务审计日志,也不得把税前工资、基数、税额、到手工资或 JSON 快照写入应用日志。薪资历史 MUST NOT 纳入现有个人数据导出 ZIP。 + +#### Scenario: 计算成功后的日志检查 + +- **WHEN** 系统完成一次薪资计算和历史保存 +- **THEN** 日志仅包含请求 ID、用户 ID、历史 ID、城市编码、状态和耗时,不包含金额或参数快照 + +### Requirement: 账号注销清理薪资历史 + +The system SHALL 在账号注销完成事务中物理删除该用户全部薪资历史。注销处于 `PENDING` 时 MUST 禁止计算和删除写入,`COMPLETED` 时所有薪资接口 MUST 返回 409/9303。 + +#### Scenario: 注销完成后清理历史 + +- **WHEN** 用户账号注销完成 +- **THEN** 数据库中不存在该用户的 `salary_calculation_history` 行 + +#### Scenario: 注销冷静期尝试计算 + +- **WHEN** 用户处于 `PENDING` 状态调用计算接口 +- **THEN** 现有注销守卫返回 409/9302,且不创建历史 + +### Requirement: 数据库迁移保证城市政策和历史完整性 + +The system SHALL 通过 `V40` 创建 `salary_city_policy` 与 `salary_calculation_history`,通过 `V41` 写入目标城市种子。城市编码 MUST 唯一,历史幂等键 MUST 按用户唯一,历史快照 MUST 保留且不依赖后续城市更新。 + +#### Scenario: 迁移初始化 + +- **WHEN** 在 MySQL 或 H2 MySQL 模式执行 `V40` 和 `V41` +- **THEN** 城市政策表、历史表、主外键、唯一键和索引创建成功,目标城市种子导入成功 + diff --git a/changes/n05-salary-calc/tasks.md b/changes/n05-salary-calc/tasks.md new file mode 100644 index 0000000..9f1cd27 --- /dev/null +++ b/changes/n05-salary-calc/tasks.md @@ -0,0 +1,58 @@ +# n05-salary-calc 实施任务 + +## Delivery / Proof Map + +| 交付批次 | 交付结果 | 依赖 | 证明 | +|---|---|---|---| +| 1 数据基础 | 两张表、实体、Repository、全量城市种子可迁移 | 无 | 迁移与种子集成测试 | +| 2 查询与计算 | 城市政策接口和精确计算引擎可用 | 1 | Service/Controller 测试与金额边界测试 | +| 3 历史与安全 | 计算落库、幂等、历史 CRUD、权限和注销清理闭环 | 2 | 持久化、MockMvc、E2E、账号注销测试 | +| 4 发布证明 | OpenAPI、联调文档、全量回归和 MySQL 黑盒验收通过 | 3 | Maven、OpenAPI 校验、MySQL/Docker 验收记录 | + +## Tasks + +- [x] 1 新增 V40 表结构和薪资领域持久化基础**:修改 `mydreamjob-server/src/main/resources/db/migration/V40__salary_calculator_schema.sql`,新增城市政策与历史表、`data_level` 精度字段、索引和唯一约束;在 `mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/` 新增实体和 Repository。完成后 H2/MySQL 可创建两张表,并能按城市编码和用户历史分页查询。证明:`cd mydreamjob-server; mvn -Dtest=SalaryMigrationTest,SalaryRepositoryIntegrationTest test`。依赖:无。 +- [x] 2 新增 V41 全量城市政策种子和完整性校验**:修改 `mydreamjob-server/src/main/resources/db/migration/V41__salary_city_policy_seed.sql`,按采集截止日官方行政区划清单写入大陆目标城市政策;在 `mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/` 新增种子测试。完成后目标城市全量覆盖,编码唯一,字段、比例、上下限、日期和版本均合法。证明:`cd mydreamjob-server; mvn -Dtest=SalaryCityPolicySeedTest,SalaryMigrationTest test`。依赖:1。 +- [x] 3 实现城市政策查询接口**:在 `mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/` 新增城市查询 Service、Controller 和 DTO;修改 `SecurityConfig.java` 明确 `/salary-calculator/**` 认证入口。完成后 `GET /salary-calculator/cities` 返回全部启用城市摘要,`GET /salary-calculator/cities/{cityCode}` 返回完整默认参数,并仅允许 STUDENT。证明:`cd mydreamjob-server; mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest test`。依赖:1,2。 +- [x] 4 实现 BigDecimal 薪资计算引擎**:在 `mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/` 新增计算器、税率表、输入/结果 DTO 和校验器。完成后基数截断、覆盖优先、个人缴费、大病费用、累计月份、专项附加扣除、累计预扣、税额下限和两位小数结果均有精确单元测试。证明:`cd mydreamjob-server; mvn -Dtest=SalaryCalculationEngineTest test`。依赖:1。 +- [x] 5 实现计算并保存接口及政策版本校验**:在 `mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/` 新增计算 Service、Controller、幂等指纹和事务逻辑;在 `ErrorCode.java` 新增 `9801–9805`。完成后合法请求返回完整结果和历史 ID,版本不匹配返回 `409/9805`,同键重放返回原历史,同键不同请求返回 `409/9804`。证明:`cd mydreamjob-server; mvn -Dtest=SalaryCalculationServiceTest,SalaryCalculationControllerTest,SalaryPolicyVersionIntegrationTest,SalaryIdempotencyIntegrationTest test`。依赖:2,3,4。 +- [x] 6 实现历史列表、详情和物理删除**:在 `mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/` 新增历史 Service、Controller 和响应 DTO。完成后本人历史上限分页、摘要列表、完整详情、物理删除和跨用户 404 隔离均可验证,城市停用不影响旧历史。证明:`cd mydreamjob-server; mvn -Dtest=SalaryHistoryServiceTest,SalaryHistoryControllerTest,SalaryHistoryPrivacyTest test`。依赖:5。 +- [x] 7 接入权限、日志和账号注销清理**:修改 `mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/` 和 `mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java`,复用 STUDENT 角色检查、注销守卫和物理清理。完成后非 STUDENT 被拒绝,PENDING 写操作被现有守卫阻止,COMPLETED 时历史物理删除且日志不含金额。证明:`cd mydreamjob-server; mvn -Dtest=SalaryAuthorizationIntegrationTest,SalaryAccountDeletionIntegrationTest,SalaryLoggingPrivacyTest test`。依赖:3,5,6。 +- [x] 8 完成模块测试与 OpenAPI/联调文档**:在 `mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/` 补齐迁移、金额、权限、幂等、并发、历史快照、删除和 E2E 测试;修改 `docs/openapi-rc.json`、`docs/salary-calculator-api.md`、`docs/frontend-integration.md`。完成后六个接口、请求响应、错误码、版本冲突、幂等和计算假设与运行时一致。证明:`cd mydreamjob-server; mvn -Dtest=Salary*Test test`;`python docs/verify_openapi.py`。依赖:1,2,3,4,5,6,7。 +- [x] 9 执行全量回归和真实 MySQL/Docker 黑盒验收并更新路线图**:修改 `docs/backend-development-roadmap.md`、`README.md`;在真实 MySQL 环境执行 Flyway、六个接口和账号注销清理验收。完成后全量构建通过,路线图状态更新为已实现/待验收,真实数据库行为与 H2 一致。证明:`cd mydreamjob-server; mvn clean verify`;MySQL/Docker 黑盒脚本与验收记录。依赖:8。 + +## Requirement-to-Task Coverage + +| Spec Requirement | Covered By | +|---|---| +| 仅已登录 STUDENT 可访问薪资计算模块 | 3,5,6,7 | +| 查询当前启用的城市政策目录 | 2,3 | +| 城市配置以地级行政区和省直辖县级市为最小单元 | 2 | +| 校验城市政策版本和元数据 | 2,5 | +| 用户覆盖参数优先于城市默认值 | 4,5 | +| 计算个人五险一金和大病固定费 | 4,5 | +| 使用累计月份和专项附加扣除计算应纳税所得额 | 4,5 | +| 按居民个人综合所得累计预扣法计算个税 | 4,5 | +| 输出到手工资和完整计算拆解 | 4,5,6 | +| 校验工资输入并限制金额精度 | 4,5 | +| 返回稳定的城市和历史错误语义 | 5,6,7 | +| 成功计算自动保存历史 | 5 | +| 历史保存完整且可重建的快照 | 1,5,6 | +| 计算保存使用幂等键防重 | 5 | +| 同一幂等键并发只能生成一条历史 | 5 | +| 历史列表和详情仅返回本人数据 | 6 | +| 历史不可编辑且可以物理删除单条 | 1,6 | +| 历史列表采用统一分页且不提供额外筛选 | 6 | +| 历史数据按用户长期保留 | 6 | +| 历史不进入审计日志或个人数据导出 | 7,8 | +| 账号注销清理薪资历史 | 7 | +| 数据库迁移保证城市政策和历史完整性 | 1,2,8 | + +## Completion Gate + +- `V41` 目标城市全量覆盖、字段合法且采集截止日基线明确;不得以示例城市或缺失城市代替正式发布。 +- 六个接口、计算边界、政策版本冲突、幂等并发、用户隔离、物理删除和账号注销清理全部有自动化证据。 +- `mvn -Dtest=Salary*Test test`、`mvn clean verify`、`python docs/verify_openapi.py` 和真实 MySQL/Docker 黑盒验收通过后才能进入 release-archivist。 + + + diff --git a/changes/n05-salary-calc/verification-report.md b/changes/n05-salary-calc/verification-report.md new file mode 100644 index 0000000..34aa55c --- /dev/null +++ b/changes/n05-salary-calc/verification-report.md @@ -0,0 +1,31 @@ +# N05 salary calculator verification report + +## Verification dimensions + +| Dimension | Status | Findings | +|---|---|---| +| Completeness | PASS | All 9 tasks checked; W1-W4 review receipts pass; 22 approved requirements have task coverage and implementation evidence. | +| Correctness | PASS | Salary*Test 24/24; disposable Docker MySQL 8.4 black-box PASS; Flyway V40/V41 and 359 city rows verified. | +| Coherence | PASS | Design decisions match code: current city policy + snapshots, BigDecimal engine, dataLevel propagation, idempotency, account-deletion cleanup. | +| Scope | WARN | Shared ErrorCode, security route and account-deletion worker changed as declared in the contract; no unrelated production files changed. | +| Full regression | WARN | `mvn clean verify`: 1516 tests, 2 failures / 9 errors, all in pre-existing non-salary modules. | + +## Evidence + +- `mvn -Dtest=Salary*Test test` -> 24 tests, 0 failures, 0 errors. +- `python docs/verify_openapi.py` -> 139 paths / 178 operations, all checks passed. +- Disposable Docker MySQL 8.4 + containerized backend black-box -> PASS. +- Flyway versions 40,41; city policy count 359; CITY=12; PROVINCE_FALLBACK=347. +- Temporary MySQL/backend containers and network destroyed after verification. +- Delta specs synced through `ssf sync`; publication receipt recorded. + +## Verdict + +**CONDITIONAL PASS for N05**: all salary-calculator requirements, focused tests, OpenAPI checks and isolated MySQL black-box acceptance pass. The only release warning is the repository-wide pre-existing regression failures outside this change: + +- `CareerProgressServiceTest.fullAggregation_returnsCorrectSummaries` +- `JobReviewDimensionControllerTest.listReviews_shouldReturnDimensionScores` +- `JobReviewDimensionServiceTest.listReviews_shouldReturnDimensionScoresWithoutN1` +- 9 `LocalResumeStorageServiceTest` extension-context errors + +No salarycalculator tests fail. Closing requires explicit user acceptance of this conditional release outcome. diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 3543b39..dcbfbb4 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -26,21 +26,21 @@ | 项目 | 当前状态 | |---|---| -| 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/文档更新和一次性 Docker MySQL 黑盒验收;全量测试仍受既有非薪资模块失败阻塞,N04 仍待真实 MySQL/Docker 验收 | | 本规划范围 | 代码修复、新功能开发、接口契约、数据权限和治理能力 | ### 2.0 模块状态分类 | 类别 | 模块 | |---|---| -| 已完成并已验收 | 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,全量回归待清除既有失败) | +| 已完成并已验收 | 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 统一求职进度聚合、N05 薪资计算器(一次性 Docker MySQL 黑盒通过) | +| 已完成、待真实 MySQL/Docker 验收 | N04 入职清单与公司知识介绍(V38/V39,9 个接口;专项测试 96/96) | | 已设计未编码 | 测评系统(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,7 @@ 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/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index ae3b69c..d8dac91 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -178,7 +178,14 @@ public enum ErrorCode { ONBOARDING_ITEM_LIMIT_EXCEEDED(9703, "onboarding checklist item limit exceeded"), ONBOARDING_IDEMPOTENCY_CONFLICT(9704, "idempotency key conflict: request body differs"), ONBOARDING_PROGRESS_CONCURRENT_UPDATE(9705, "onboarding progress was modified concurrently"), - ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE(9706, "company knowledge was modified concurrently"); + ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE(9706, "company knowledge was modified concurrently"), + + // 薪资计算器 98xx + SALARY_CITY_NOT_FOUND(9801, "salary city not found or disabled"), + SALARY_INVALID_PARAMETER(9802, "invalid salary calculation parameter"), + SALARY_HISTORY_NOT_FOUND(9803, "salary calculation history not found"), + SALARY_IDEMPOTENCY_CONFLICT(9804, "salary idempotency key conflict: request body differs"), + SALARY_POLICY_VERSION_CONFLICT(9805, "salary city policy version conflict"); private final int code; private final String message; @@ -196,3 +203,4 @@ public enum ErrorCode { return message; } } + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index 7743c3e..0310ce2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.mydreamjob.common.exception; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.salarycalculator.SalaryPolicyVersionConflictException; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; @@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; /** @@ -77,7 +79,9 @@ public class GlobalExceptionHandler { || errorCode == ErrorCode.CONVERSATION_NOT_FOUND.getCode() || errorCode == ErrorCode.APPEAL_NOT_FOUND.getCode() || errorCode == ErrorCode.DATA_EXPORT_TASK_NOT_FOUND.getCode() - || errorCode == ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; + || errorCode == ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND.getCode() + || errorCode == ErrorCode.SALARY_CITY_NOT_FOUND.getCode() + || errorCode == ErrorCode.SALARY_HISTORY_NOT_FOUND.getCode()) return HttpStatus.NOT_FOUND; // 冲突 → 409 if (errorCode == ErrorCode.COMPANY_ALREADY_EXISTS.getCode() || errorCode == ErrorCode.APPLICATION_DUPLICATE.getCode() @@ -169,7 +173,9 @@ public class GlobalExceptionHandler { || errorCode == ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED.getCode() || errorCode == ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT.getCode() || errorCode == ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE.getCode() - || errorCode == ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode()) + || errorCode == ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE.getCode() + || errorCode == ErrorCode.SALARY_IDEMPOTENCY_CONFLICT.getCode() + || errorCode == ErrorCode.SALARY_POLICY_VERSION_CONFLICT.getCode()) return HttpStatus.CONFLICT; // 举报限流 → 429 if (errorCode == ErrorCode.REPORT_RATE_LIMITED.getCode()) @@ -182,6 +188,19 @@ public class GlobalExceptionHandler { return HttpStatus.BAD_REQUEST; } + @ExceptionHandler(SalaryPolicyVersionConflictException.class) + public ResponseEntity>> handleSalaryPolicyVersionConflict( + SalaryPolicyVersionConflictException ex, HttpServletRequest request) { + Map details = new LinkedHashMap<>(); + details.put("latestPolicyVersion", ex.getLatestPolicyVersion()); + details.put("policyEffectiveDate", ex.getLatestPolicyEffectiveDate()); + ApiResponse> body = ApiResponse.error( + ErrorCode.SALARY_POLICY_VERSION_CONFLICT.getCode(), + ErrorCode.SALARY_POLICY_VERSION_CONFLICT.getMessage(), + getRequestId(request)); + body.setData(details); + return ResponseEntity.status(HttpStatus.CONFLICT).body(body); + } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidationException( MethodArgumentNotValidException ex, HttpServletRequest request) { @@ -255,3 +274,8 @@ public class GlobalExceptionHandler { return request.getHeader("X-Request-Id"); } } + + + + + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index 148f8c9..fe2cb40 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -128,6 +128,8 @@ public class SecurityConfig { .requestMatchers("/interviews/**").authenticated() // 入职清单与公司知识(需登录;细粒度角色/所有权在 Service 校验) .requestMatchers("/onboarding/**").authenticated() + // 薪资计算器(需登录;仅 STUDENT 在 Service 校验) + .requestMatchers("/salary-calculator/**").authenticated() // API 信息入口 .requestMatchers("/").permitAll() .anyRequest().authenticated() @@ -157,3 +159,4 @@ public class SecurityConfig { return new BCryptPasswordEncoder(); } } + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java index 709ee60..107ea2b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java @@ -4,6 +4,7 @@ import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.dataexport.PersonalDataExportCleanupService; +import com.mydreamjob.salarycalculator.SalaryHistoryCleanupService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.orm.ObjectOptimisticLockingFailureException; @@ -32,6 +33,7 @@ public class AccountDeletionCompletionWorker { private final AuditService auditService; private final AccountDeletionPhoneHasher phoneHasher; private final PersonalDataExportCleanupService dataExportCleanupService; + private final SalaryHistoryCleanupService salaryHistoryCleanupService; /** * 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。 @@ -57,6 +59,7 @@ public class AccountDeletionCompletionWorker { desensitize(rec.getUserId()); // 注销完成:清理该用户的个人导出文件并作废未完成任务 dataExportCleanupService.purgeForDeletedUser(rec.getUserId()); + salaryHistoryCleanupService.purgeForDeletedUser(rec.getUserId()); // 完成 + 脱敏审计各写一次 auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION", rec.getId(), "account deletion completed; userId=" + userId @@ -109,4 +112,5 @@ public class AccountDeletionCompletionWorker { u.setActive(false); userRepository.saveAndFlush(u); } -} \ No newline at end of file +} + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java new file mode 100644 index 0000000..f69a0d2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java @@ -0,0 +1,91 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.api.PageResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationResponse; +import com.mydreamjob.salarycalculator.dto.SalaryHistoryDetailResponse; +import com.mydreamjob.salarycalculator.dto.SalaryHistorySummaryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "薪资计算器", description = "城市政策、到手工资估算与历史记录") +@RestController +@RequestMapping("/salary-calculator") +@RequiredArgsConstructor +public class SalaryCalculationController { + + private final SalaryCalculationService calculationService; + private final SalaryHistoryService historyService; + + @Operation(summary = "计算并保存到手工资", description = "仅 STUDENT;校验政策版本,支持幂等键") + @PostMapping("/calculations") + public ResponseEntity> calculate( + @Valid @RequestBody SalaryCalculationRequest body, HttpServletRequest request) { + Long userId = resolveUserId(); + return ResponseEntity.ok(ApiResponse.ok( + calculationService.calculateAndSave(userId, body), getRequestId(request))); + } + + @Operation(summary = "查询本人薪资历史", description = "仅 STUDENT;按创建时间倒序分页") + @GetMapping("/calculations") + public ResponseEntity>> list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(); + return ResponseEntity.ok(ApiResponse.ok( + historyService.list(userId, page, size), getRequestId(request))); + } + + @Operation(summary = "查询本人薪资历史详情", description = "仅 STUDENT;返回完整快照") + @GetMapping("/calculations/{id}") + public ResponseEntity> get( + @PathVariable Long id, HttpServletRequest request) { + Long userId = resolveUserId(); + return ResponseEntity.ok(ApiResponse.ok( + historyService.get(userId, id), getRequestId(request))); + } + + @Operation(summary = "删除本人薪资历史", description = "仅 STUDENT;物理删除") + @DeleteMapping("/calculations/{id}") + public ResponseEntity> delete( + @PathVariable Long id, HttpServletRequest request) { + Long userId = resolveUserId(); + historyService.delete(userId, id); + return ResponseEntity.ok(ApiResponse.ok(null, getRequestId(request))); + } + + private Long resolveUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() + || "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + try { + return Long.valueOf(auth.getName()); + } catch (NumberFormatException ex) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationEngine.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationEngine.java new file mode 100644 index 0000000..39b4bbf --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationEngine.java @@ -0,0 +1,262 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; + +@Component +public class SalaryCalculationEngine { + + public static final String TAX_RULES_VERSION = "CN_IIT_2019_V1"; + + private static final BigDecimal ZERO = new BigDecimal("0"); + private static final BigDecimal MONTHLY_BASIC_DEDUCTION = new BigDecimal("5000"); + private static final BigDecimal MAX_INPUT_AMOUNT = new BigDecimal("10000000"); + private static final BigDecimal MAX_ILLNESS_FEE = new BigDecimal("10000"); + private static final BigDecimal MAX_PENSION_RATE = new BigDecimal("0.20"); + private static final BigDecimal MAX_MEDICAL_RATE = new BigDecimal("0.20"); + private static final BigDecimal MAX_UNEMPLOYMENT_RATE = new BigDecimal("0.10"); + private static final BigDecimal MAX_HOUSING_FUND_RATE = new BigDecimal("0.12"); + private static final int MONEY_SCALE = 2; + + private static final List ANNUAL_BRACKETS = List.of( + new TaxBracket(new BigDecimal("36000"), new BigDecimal("0.03"), new BigDecimal("0")), + new TaxBracket(new BigDecimal("144000"), new BigDecimal("0.10"), new BigDecimal("2520")), + new TaxBracket(new BigDecimal("300000"), new BigDecimal("0.20"), new BigDecimal("16920")), + new TaxBracket(new BigDecimal("420000"), new BigDecimal("0.25"), new BigDecimal("31920")), + new TaxBracket(new BigDecimal("660000"), new BigDecimal("0.30"), new BigDecimal("52920")), + new TaxBracket(new BigDecimal("960000"), new BigDecimal("0.35"), new BigDecimal("85920")), + new TaxBracket(null, new BigDecimal("0.45"), new BigDecimal("181920"))); + + public SalaryCalculationResult calculate(SalaryCalculationInput input, SalaryCityPolicy policy) { + if (input == null || policy == null) { + throw invalidParameter("input and city policy are required"); + } + int monthIndex = input.getMonthIndex() == null ? 1 : input.getMonthIndex(); + BigDecimal gross = requiredMoney(input.getGrossMonthlySalary(), "grossMonthlySalary"); + BigDecimal specialDeduction = optionalMoney( + input.getSpecialAdditionalDeduction(), "specialAdditionalDeduction", MAX_INPUT_AMOUNT); + SalaryOverrideParameters overrides = input.getOverrides(); + + if (gross.compareTo(ZERO) <= 0 || gross.compareTo(MAX_INPUT_AMOUNT) > 0) { + throw invalidParameter("grossMonthlySalary must be within (0, 10000000]"); + } + if (monthIndex < 1 || monthIndex > 12) { + throw invalidParameter("monthIndex must be between 1 and 12"); + } + + BigDecimal pensionRate = effectiveRate( + overrides == null ? null : overrides.getPensionRate(), + policy.getPensionRate(), MAX_PENSION_RATE, "pensionRate"); + BigDecimal medicalRate = effectiveRate( + overrides == null ? null : overrides.getMedicalRate(), + policy.getMedicalRate(), MAX_MEDICAL_RATE, "medicalRate"); + BigDecimal unemploymentRate = effectiveRate( + overrides == null ? null : overrides.getUnemploymentRate(), + policy.getUnemploymentRate(), MAX_UNEMPLOYMENT_RATE, "unemploymentRate"); + BigDecimal housingFundRate = effectiveRate( + overrides == null ? null : overrides.getHousingFundRate(), + policy.getHousingFundRate(), MAX_HOUSING_FUND_RATE, "housingFundRate"); + BigDecimal illnessFee = effectiveFee( + overrides == null ? null : overrides.getSeriousIllnessMonthlyFee(), + policy.getSeriousIllnessMonthlyFee()); + + BigDecimal socialSecurityBase = effectiveBase( + overrides == null ? null : overrides.getSocialSecurityBase(), + gross, + policy.getSocialSecurityBaseMin(), + policy.getSocialSecurityBaseMax(), + "socialSecurityBase"); + BigDecimal housingFundBase = effectiveBase( + overrides == null ? null : overrides.getHousingFundBase(), + gross, + policy.getHousingFundBaseMin(), + policy.getHousingFundBaseMax(), + "housingFundBase"); + + if (socialSecurityBase.compareTo(ZERO) == 0 + && (pensionRate.signum() != 0 || medicalRate.signum() != 0 + || unemploymentRate.signum() != 0)) { + throw invalidParameter("socialSecurityBase may be zero only when social insurance rates are zero"); + } + if (housingFundBase.compareTo(ZERO) == 0 && housingFundRate.signum() != 0) { + throw invalidParameter("housingFundBase may be zero only when housingFundRate is zero"); + } + + BigDecimal pension = socialSecurityBase.multiply(pensionRate); + BigDecimal medical = socialSecurityBase.multiply(medicalRate); + BigDecimal unemployment = socialSecurityBase.multiply(unemploymentRate); + BigDecimal housingFund = housingFundBase.multiply(housingFundRate); + BigDecimal monthlySocialInsurance = pension.add(medical).add(unemployment); + + BigDecimal cumulativeGross = gross.multiply(BigDecimal.valueOf(monthIndex)); + BigDecimal cumulativeSocialInsurance = monthlySocialInsurance.multiply(BigDecimal.valueOf(monthIndex)); + BigDecimal cumulativeHousingFund = housingFund.multiply(BigDecimal.valueOf(monthIndex)); + BigDecimal cumulativeIllness = illnessFee.multiply(BigDecimal.valueOf(monthIndex)); + BigDecimal cumulativeBasicDeduction = MONTHLY_BASIC_DEDUCTION.multiply(BigDecimal.valueOf(monthIndex)); + BigDecimal cumulativeSpecialDeduction = specialDeduction.multiply(BigDecimal.valueOf(monthIndex)); + + BigDecimal cumulativeTaxable = nonNegative(cumulativeGross + .subtract(cumulativeSocialInsurance) + .subtract(cumulativeHousingFund) + .subtract(cumulativeIllness) + .subtract(cumulativeBasicDeduction) + .subtract(cumulativeSpecialDeduction)); + BigDecimal cumulativeTaxDue = taxFor(cumulativeTaxable); + + int priorMonths = monthIndex - 1; + BigDecimal priorTaxable = nonNegative(gross.multiply(BigDecimal.valueOf(priorMonths)) + .subtract(monthlySocialInsurance.multiply(BigDecimal.valueOf(priorMonths))) + .subtract(housingFund.multiply(BigDecimal.valueOf(priorMonths))) + .subtract(illnessFee.multiply(BigDecimal.valueOf(priorMonths))) + .subtract(MONTHLY_BASIC_DEDUCTION.multiply(BigDecimal.valueOf(priorMonths))) + .subtract(specialDeduction.multiply(BigDecimal.valueOf(priorMonths)))); + BigDecimal priorTax = taxFor(priorTaxable); + BigDecimal currentTax = nonNegative(cumulativeTaxDue.subtract(priorTax)); + BigDecimal netSalary = nonNegative(gross + .subtract(pension) + .subtract(medical) + .subtract(unemployment) + .subtract(housingFund) + .subtract(illnessFee) + .subtract(currentTax)); + + return SalaryCalculationResult.builder() + .cityCode(policy.getCityCode()) + .cityName(policy.getCityName()) + .dataLevel(policy.getDataLevel()) + .policyVersion(policy.getPolicyVersion()) + .policyEffectiveDate(policy.getPolicyEffectiveDate()) + .taxRulesVersion(TAX_RULES_VERSION) + .estimate(true) + .monthIndex(monthIndex) + .grossMonthlySalary(money(gross)) + .specialAdditionalDeduction(money(specialDeduction)) + .socialSecurityBase(money(socialSecurityBase)) + .housingFundBase(money(housingFundBase)) + .pensionRate(rate(pensionRate)) + .medicalRate(rate(medicalRate)) + .unemploymentRate(rate(unemploymentRate)) + .housingFundRate(rate(housingFundRate)) + .seriousIllnessMonthlyFee(money(illnessFee)) + .pension(money(pension)) + .medical(money(medical)) + .unemployment(money(unemployment)) + .housingFund(money(housingFund)) + .cumulativeGrossIncome(money(cumulativeGross)) + .cumulativeSocialInsurance(money(cumulativeSocialInsurance)) + .cumulativeHousingFund(money(cumulativeHousingFund)) + .cumulativeSeriousIllness(money(cumulativeIllness)) + .cumulativeBasicDeduction(money(cumulativeBasicDeduction)) + .cumulativeSpecialAdditionalDeduction(money(cumulativeSpecialDeduction)) + .cumulativeTaxableIncome(money(cumulativeTaxable)) + .cumulativeTaxDue(money(cumulativeTaxDue)) + .previouslyWithheldTax(money(priorTax)) + .currentMonthTax(money(currentTax)) + .netSalary(money(netSalary)) + .build(); + } + + private BigDecimal effectiveBase( + BigDecimal override, BigDecimal gross, BigDecimal min, BigDecimal max, String field) { + validatePolicyRange(min, max, field); + if (override != null) { + BigDecimal value = requiredMoney(override, field); + if (value.compareTo(ZERO) < 0) { + throw invalidParameter(field + " must not be negative"); + } + if (value.compareTo(ZERO) > 0 && (value.compareTo(min) < 0 || value.compareTo(max) > 0)) { + throw invalidParameter(field + " must be within city range or zero for an exempt contribution"); + } + return value; + } + return clamp(gross, min, max); + } + + private BigDecimal effectiveRate( + BigDecimal override, BigDecimal fallback, BigDecimal max, String field) { + BigDecimal value = override == null ? fallback : requiredRate(override, field); + if (value == null || value.compareTo(ZERO) < 0 || value.compareTo(max) > 0) { + throw invalidParameter(field + " is outside the allowed range"); + } + return value; + } + + private BigDecimal effectiveFee(BigDecimal override, BigDecimal fallback) { + BigDecimal value = override == null ? fallback : requiredMoney(override, "seriousIllnessMonthlyFee"); + if (value == null || value.compareTo(ZERO) < 0 || value.compareTo(MAX_ILLNESS_FEE) > 0) { + throw invalidParameter("seriousIllnessMonthlyFee is outside the allowed range"); + } + return value; + } + + private BigDecimal taxFor(BigDecimal taxableIncome) { + if (taxableIncome.compareTo(ZERO) <= 0) { + return ZERO; + } + for (TaxBracket bracket : ANNUAL_BRACKETS) { + if (bracket.upperBound == null || taxableIncome.compareTo(bracket.upperBound) <= 0) { + return nonNegative(taxableIncome.multiply(bracket.rate).subtract(bracket.quickDeduction)); + } + } + return ZERO; + } + + private BigDecimal clamp(BigDecimal value, BigDecimal min, BigDecimal max) { + if (value.compareTo(min) < 0) { + return min; + } + return value.compareTo(max) > 0 ? max : value; + } + + private void validatePolicyRange(BigDecimal min, BigDecimal max, String field) { + if (min == null || max == null || min.compareTo(ZERO) < 0 || max.compareTo(min) < 0) { + throw invalidParameter(field + " city range is invalid"); + } + } + + private BigDecimal requiredMoney(BigDecimal value, String field) { + if (value == null || value.scale() > MONEY_SCALE) { + throw invalidParameter(field + " must have at most two decimal places"); + } + return value; + } + + private BigDecimal optionalMoney(BigDecimal value, String field, BigDecimal max) { + BigDecimal effective = value == null ? ZERO : requiredMoney(value, field); + if (effective.compareTo(ZERO) < 0 || effective.compareTo(max) > 0) { + throw invalidParameter(field + " is outside the allowed range"); + } + return effective; + } + + private BigDecimal requiredRate(BigDecimal value, String field) { + if (value == null || value.scale() > 6) { + throw invalidParameter(field + " must have at most six decimal places"); + } + return value; + } + + private BigDecimal nonNegative(BigDecimal value) { + return value.compareTo(ZERO) < 0 ? ZERO : value; + } + + private BigDecimal money(BigDecimal value) { + return value.setScale(MONEY_SCALE, RoundingMode.HALF_UP); + } + + private BigDecimal rate(BigDecimal value) { + return value.setScale(6, RoundingMode.HALF_UP); + } + + private BusinessException invalidParameter(String detail) { + return new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, detail); + } + + private record TaxBracket(BigDecimal upperBound, BigDecimal rate, BigDecimal quickDeduction) { + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java new file mode 100644 index 0000000..06cd28e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java @@ -0,0 +1,87 @@ +package com.mydreamjob.salarycalculator; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table( + name = "salary_calculation_history", + uniqueConstraints = @UniqueConstraint( + name = "uk_sch_student_idempotency", + columnNames = {"student_user_id", "idempotency_key"})) +@EntityListeners(AuditingEntityListener.class) +public class SalaryCalculationHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "student_user_id", nullable = false) + private Long studentUserId; + + @Column(name = "city_code", nullable = false, length = 6) + private String cityCode; + + @Column(name = "city_name", nullable = false, length = 80) + private String cityName; + + @Enumerated(EnumType.STRING) + @Column(name = "data_level", nullable = false, length = 30) + private SalaryDataLevel dataLevel; + + @Column(name = "policy_version", nullable = false, length = 64) + private String policyVersion; + + @Column(name = "tax_rules_version", nullable = false, length = 64) + private String taxRulesVersion; + + @Column(name = "month_index", nullable = false) + private Integer monthIndex; + + @Column(name = "gross_monthly_salary", nullable = false, precision = 12, scale = 2) + private BigDecimal grossMonthlySalary; + + @Column(name = "net_salary", nullable = false, precision = 12, scale = 2) + private BigDecimal netSalary; + + @Column(name = "idempotency_key", length = 64) + private String idempotencyKey; + + @Column(name = "request_hash", columnDefinition = "CHAR(64)") + private String requestHash; + + @Column(name = "input_snapshot_json", nullable = false, columnDefinition = "TEXT") + private String inputSnapshotJson; + + @Column(name = "policy_snapshot_json", nullable = false, columnDefinition = "TEXT") + private String policySnapshotJson; + + @Column(name = "result_snapshot_json", nullable = false, columnDefinition = "TEXT") + private String resultSnapshotJson; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; +} + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistoryRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistoryRepository.java new file mode 100644 index 0000000..70a0c48 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistoryRepository.java @@ -0,0 +1,23 @@ +package com.mydreamjob.salarycalculator; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SalaryCalculationHistoryRepository + extends JpaRepository { + + Page findByStudentUserIdOrderByCreatedAtDescIdDesc( + Long studentUserId, Pageable pageable); + + Optional findByIdAndStudentUserId(Long id, Long studentUserId); + + Optional findByStudentUserIdAndIdempotencyKey( + Long studentUserId, String idempotencyKey); + + long deleteByStudentUserId(Long studentUserId); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationInput.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationInput.java new file mode 100644 index 0000000..f7b0f56 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationInput.java @@ -0,0 +1,19 @@ +package com.mydreamjob.salarycalculator; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCalculationInput { + private BigDecimal grossMonthlySalary; + private Integer monthIndex; + private BigDecimal specialAdditionalDeduction; + private SalaryOverrideParameters overrides; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationResult.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationResult.java new file mode 100644 index 0000000..d35fc9c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationResult.java @@ -0,0 +1,53 @@ +package com.mydreamjob.salarycalculator; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCalculationResult { + + private String cityCode; + private String cityName; + private SalaryDataLevel dataLevel; + private String policyVersion; + private LocalDate policyEffectiveDate; + private String taxRulesVersion; + private boolean estimate; + + private Integer monthIndex; + private BigDecimal grossMonthlySalary; + private BigDecimal specialAdditionalDeduction; + + private BigDecimal socialSecurityBase; + private BigDecimal housingFundBase; + private BigDecimal pensionRate; + private BigDecimal medicalRate; + private BigDecimal unemploymentRate; + private BigDecimal housingFundRate; + private BigDecimal seriousIllnessMonthlyFee; + + private BigDecimal pension; + private BigDecimal medical; + private BigDecimal unemployment; + private BigDecimal housingFund; + + private BigDecimal cumulativeGrossIncome; + private BigDecimal cumulativeSocialInsurance; + private BigDecimal cumulativeHousingFund; + private BigDecimal cumulativeSeriousIllness; + private BigDecimal cumulativeBasicDeduction; + private BigDecimal cumulativeSpecialAdditionalDeduction; + private BigDecimal cumulativeTaxableIncome; + private BigDecimal cumulativeTaxDue; + private BigDecimal previouslyWithheldTax; + private BigDecimal currentMonthTax; + private BigDecimal netSalary; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java new file mode 100644 index 0000000..177455c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java @@ -0,0 +1,190 @@ +package com.mydreamjob.salarycalculator; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationOverrideRequest; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class SalaryCalculationService { + + private final SalaryCityPolicyRepository cityPolicyRepository; + private final SalaryCalculationHistoryRepository historyRepository; + private final UserRepository userRepository; + private final SalaryCalculationEngine calculationEngine; + private final SalaryRequestFingerprint requestFingerprint; + private final ObjectMapper objectMapper; + + @Transactional + public SalaryCalculationResponse calculateAndSave(Long userId, SalaryCalculationRequest request) { + requireStudent(userId); + String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); + String requestHash = idempotencyKey == null ? null : requestFingerprint.fingerprint(request); + if (idempotencyKey != null) { + var existing = historyRepository.findByStudentUserIdAndIdempotencyKey(userId, idempotencyKey); + if (existing.isPresent()) { + return replay(existing.get(), requestHash); + } + } + + SalaryCityPolicy policy = cityPolicyRepository.findByCityCodeAndEnabledTrue(request.getCityCode()) + .orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); + if (!Objects.equals(policy.getPolicyVersion(), request.getExpectedPolicyVersion())) { + throw new SalaryPolicyVersionConflictException( + policy.getPolicyVersion(), policy.getPolicyEffectiveDate()); + } + + SalaryCalculationResult result = calculationEngine.calculate(toEngineInput(request), policy); + SalaryCalculationHistory history = SalaryCalculationHistory.builder() + .studentUserId(userId) + .cityCode(policy.getCityCode()) + .cityName(policy.getCityName()) + .dataLevel(policy.getDataLevel()) + .policyVersion(policy.getPolicyVersion()) + .taxRulesVersion(result.getTaxRulesVersion()) + .monthIndex(result.getMonthIndex()) + .grossMonthlySalary(result.getGrossMonthlySalary()) + .netSalary(result.getNetSalary()) + .idempotencyKey(idempotencyKey) + .requestHash(requestHash) + .inputSnapshotJson(toJson(inputSnapshot(request))) + .policySnapshotJson(toJson(policySnapshot(policy, result))) + .resultSnapshotJson(toJson(result)) + .build(); + + try { + history = historyRepository.saveAndFlush(history); + } catch (DataIntegrityViolationException ex) { + if (idempotencyKey != null) { + SalaryCalculationHistory existing = historyRepository + .findByStudentUserIdAndIdempotencyKey(userId, idempotencyKey) + .orElseThrow(() -> ex); + return replay(existing, requestHash); + } + throw ex; + } + return toResponse(history); + } + + private SalaryCalculationResponse replay(SalaryCalculationHistory history, String requestHash) { + if (!Objects.equals(history.getRequestHash(), requestHash)) { + throw new BusinessException(ErrorCode.SALARY_IDEMPOTENCY_CONFLICT); + } + return toResponse(history); + } + + private SalaryCalculationResponse toResponse(SalaryCalculationHistory history) { + try { + SalaryCalculationResult result = objectMapper.readValue( + history.getResultSnapshotJson(), SalaryCalculationResult.class); + return SalaryCalculationResponse.builder() + .historyId(history.getId()) + .createdAt(history.getCreatedAt()) + .result(result) + .build(); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("stored salary result snapshot is invalid", ex); + } + } + + private SalaryCalculationInput toEngineInput(SalaryCalculationRequest request) { + SalaryCalculationOverrideRequest source = request.getOverrides(); + SalaryOverrideParameters overrides = source == null ? null : SalaryOverrideParameters.builder() + .socialSecurityBase(source.getSocialSecurityBase()) + .housingFundBase(source.getHousingFundBase()) + .pensionRate(source.getPensionRate()) + .medicalRate(source.getMedicalRate()) + .unemploymentRate(source.getUnemploymentRate()) + .housingFundRate(source.getHousingFundRate()) + .seriousIllnessMonthlyFee(source.getSeriousIllnessMonthlyFee()) + .build(); + return SalaryCalculationInput.builder() + .grossMonthlySalary(request.getGrossMonthlySalary()) + .monthIndex(request.getMonthIndex()) + .specialAdditionalDeduction(request.getSpecialAdditionalDeduction()) + .overrides(overrides) + .build(); + } + + private Map inputSnapshot(SalaryCalculationRequest request) { + Map input = new LinkedHashMap<>(); + input.put("cityCode", request.getCityCode()); + input.put("grossMonthlySalary", request.getGrossMonthlySalary()); + input.put("monthIndex", request.getMonthIndex() == null ? 1 : request.getMonthIndex()); + input.put("specialAdditionalDeduction", request.getSpecialAdditionalDeduction()); + input.put("expectedPolicyVersion", request.getExpectedPolicyVersion()); + input.put("overrides", request.getOverrides()); + return input; + } + + private Map policySnapshot( + SalaryCityPolicy policy, SalaryCalculationResult result) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("cityCode", policy.getCityCode()); + snapshot.put("cityName", policy.getCityName()); + snapshot.put("dataLevel", policy.getDataLevel()); + snapshot.put("policyVersion", policy.getPolicyVersion()); + snapshot.put("policyEffectiveDate", policy.getPolicyEffectiveDate()); + snapshot.put("defaultParameters", Map.of( + "socialSecurityBaseMin", policy.getSocialSecurityBaseMin(), + "socialSecurityBaseMax", policy.getSocialSecurityBaseMax(), + "housingFundBaseMin", policy.getHousingFundBaseMin(), + "housingFundBaseMax", policy.getHousingFundBaseMax(), + "pensionRate", policy.getPensionRate(), + "medicalRate", policy.getMedicalRate(), + "unemploymentRate", policy.getUnemploymentRate(), + "housingFundRate", policy.getHousingFundRate(), + "seriousIllnessMonthlyFee", policy.getSeriousIllnessMonthlyFee())); + snapshot.put("effectiveParameters", Map.of( + "socialSecurityBase", result.getSocialSecurityBase(), + "housingFundBase", result.getHousingFundBase(), + "pensionRate", result.getPensionRate(), + "medicalRate", result.getMedicalRate(), + "unemploymentRate", result.getUnemploymentRate(), + "housingFundRate", result.getHousingFundRate(), + "seriousIllnessMonthlyFee", result.getSeriousIllnessMonthlyFee())); + return snapshot; + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("unable to serialize salary snapshot", ex); + } + } + + private String normalizeIdempotencyKey(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim(); + if (normalized.length() > 64) { + throw new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, "idempotencyKey is too long"); + } + return normalized; + } + + private void requireStudent(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles() == null + || user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicy.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicy.java new file mode 100644 index 0000000..861b1ba --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicy.java @@ -0,0 +1,99 @@ +package com.mydreamjob.salarycalculator; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "salary_city_policy") +@EntityListeners(AuditingEntityListener.class) +public class SalaryCityPolicy { + + @Id + @Column(name = "city_code", nullable = false, length = 6) + private String cityCode; + + @Column(name = "province_code", nullable = false, length = 6) + private String provinceCode; + + @Column(name = "province_name", nullable = false, length = 40) + private String provinceName; + + @Column(name = "city_name", nullable = false, length = 80) + private String cityName; + + @Column(name = "policy_version", nullable = false, length = 64) + private String policyVersion; + + @Column(name = "policy_effective_date", nullable = false) + private LocalDate policyEffectiveDate; + + @Column(name = "data_source", length = 500) + private String dataSource; + + @Column(name = "social_security_base_min", nullable = false, precision = 12, scale = 2) + private BigDecimal socialSecurityBaseMin; + + @Column(name = "social_security_base_max", nullable = false, precision = 12, scale = 2) + private BigDecimal socialSecurityBaseMax; + + @Column(name = "housing_fund_base_min", nullable = false, precision = 12, scale = 2) + private BigDecimal housingFundBaseMin; + + @Column(name = "housing_fund_base_max", nullable = false, precision = 12, scale = 2) + private BigDecimal housingFundBaseMax; + + @Column(name = "pension_rate", nullable = false, precision = 7, scale = 6) + private BigDecimal pensionRate; + + @Column(name = "medical_rate", nullable = false, precision = 7, scale = 6) + private BigDecimal medicalRate; + + @Column(name = "unemployment_rate", nullable = false, precision = 7, scale = 6) + private BigDecimal unemploymentRate; + + @Column(name = "housing_fund_rate", nullable = false, precision = 7, scale = 6) + private BigDecimal housingFundRate; + + @Column(name = "serious_illness_monthly_fee", nullable = false, precision = 10, scale = 2) + @Builder.Default + private BigDecimal seriousIllnessMonthlyFee = BigDecimal.ZERO; + + @Enumerated(EnumType.STRING) + @Column(name = "data_level", nullable = false, length = 30) + @Builder.Default + private SalaryDataLevel dataLevel = SalaryDataLevel.CITY; + + @Column(nullable = false) + @Builder.Default + private Boolean enabled = true; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} + + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java new file mode 100644 index 0000000..f59d723 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java @@ -0,0 +1,64 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryCityDetailResponse; +import com.mydreamjob.salarycalculator.dto.SalaryCitySummaryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "薪资计算器", description = "城市政策、到手工资估算与历史记录") +@RestController +@RequestMapping("/salary-calculator") +@RequiredArgsConstructor +public class SalaryCityPolicyController { + + private final SalaryCityPolicyService cityPolicyService; + + @Operation(summary = "查询启用城市列表", description = "仅 STUDENT;返回全部启用城市摘要,不分页") + @GetMapping("/cities") + public ResponseEntity>> listCities( + HttpServletRequest request) { + Long userId = resolveUserId(); + return ResponseEntity.ok(ApiResponse.ok( + cityPolicyService.listCities(userId), getRequestId(request))); + } + + @Operation(summary = "查询城市默认参数", description = "仅 STUDENT;返回城市元数据和当前整套默认参数") + @GetMapping("/cities/{cityCode}") + public ResponseEntity> getCity( + @PathVariable String cityCode, HttpServletRequest request) { + Long userId = resolveUserId(); + return ResponseEntity.ok(ApiResponse.ok( + cityPolicyService.getCity(userId, cityCode), getRequestId(request))); + } + + private Long resolveUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() + || "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + try { + return Long.valueOf(auth.getName()); + } catch (NumberFormatException ex) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyRepository.java new file mode 100644 index 0000000..de5fc12 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyRepository.java @@ -0,0 +1,15 @@ +package com.mydreamjob.salarycalculator; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface SalaryCityPolicyRepository extends JpaRepository { + + List findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc(); + + Optional findByCityCodeAndEnabledTrue(String cityCode); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java new file mode 100644 index 0000000..b87c16e --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java @@ -0,0 +1,81 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryCityDetailResponse; +import com.mydreamjob.salarycalculator.dto.SalaryCitySummaryResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class SalaryCityPolicyService { + + private final SalaryCityPolicyRepository cityPolicyRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public List listCities(Long userId) { + requireStudent(userId); + return cityPolicyRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc() + .stream() + .map(this::toSummary) + .toList(); + } + + @Transactional(readOnly = true) + public SalaryCityDetailResponse getCity(Long userId, String cityCode) { + requireStudent(userId); + if (cityCode == null || !cityCode.matches("\\d{6}")) { + throw new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND); + } + SalaryCityPolicy policy = cityPolicyRepository + .findByCityCodeAndEnabledTrue(cityCode) + .orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); + return SalaryCityDetailResponse.builder() + .cityCode(policy.getCityCode()) + .provinceCode(policy.getProvinceCode()) + .provinceName(policy.getProvinceName()) + .cityName(policy.getCityName()) + .policyVersion(policy.getPolicyVersion()) + .policyEffectiveDate(policy.getPolicyEffectiveDate()) + .dataLevel(policy.getDataLevel()) + .dataSource(policy.getDataSource()) + .socialSecurityBaseMin(policy.getSocialSecurityBaseMin()) + .socialSecurityBaseMax(policy.getSocialSecurityBaseMax()) + .housingFundBaseMin(policy.getHousingFundBaseMin()) + .housingFundBaseMax(policy.getHousingFundBaseMax()) + .pensionRate(policy.getPensionRate()) + .medicalRate(policy.getMedicalRate()) + .unemploymentRate(policy.getUnemploymentRate()) + .housingFundRate(policy.getHousingFundRate()) + .seriousIllnessMonthlyFee(policy.getSeriousIllnessMonthlyFee()) + .build(); + } + + private SalaryCitySummaryResponse toSummary(SalaryCityPolicy policy) { + return SalaryCitySummaryResponse.builder() + .cityCode(policy.getCityCode()) + .provinceCode(policy.getProvinceCode()) + .provinceName(policy.getProvinceName()) + .cityName(policy.getCityName()) + .policyVersion(policy.getPolicyVersion()) + .policyEffectiveDate(policy.getPolicyEffectiveDate()) + .dataLevel(policy.getDataLevel()) + .build(); + } + + private void requireStudent(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles() == null + || user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryDataLevel.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryDataLevel.java new file mode 100644 index 0000000..f3e055f --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryDataLevel.java @@ -0,0 +1,6 @@ +package com.mydreamjob.salarycalculator; + +public enum SalaryDataLevel { + CITY, + PROVINCE_FALLBACK +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryCleanupService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryCleanupService.java new file mode 100644 index 0000000..8cbb00a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryCleanupService.java @@ -0,0 +1,18 @@ +package com.mydreamjob.salarycalculator; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SalaryHistoryCleanupService { + + private final SalaryCalculationHistoryRepository historyRepository; + + @Transactional(propagation = Propagation.MANDATORY) + public long purgeForDeletedUser(Long userId) { + return historyRepository.deleteByStudentUserId(userId); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryService.java new file mode 100644 index 0000000..a7da223 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryService.java @@ -0,0 +1,106 @@ +package com.mydreamjob.salarycalculator; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.api.PageResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryHistoryDetailResponse; +import com.mydreamjob.salarycalculator.dto.SalaryHistorySummaryResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SalaryHistoryService { + + private final SalaryCalculationHistoryRepository historyRepository; + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + + @Transactional(readOnly = true) + public PageResponse list(Long userId, int page, int size) { + requireStudent(userId); + if (page < 0 || size < 1 || size > 100) { + throw new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, "invalid pagination"); + } + Page result = historyRepository + .findByStudentUserIdOrderByCreatedAtDescIdDesc(userId, PageRequest.of(page, size)); + return PageResponse.of( + result.getContent().stream().map(this::toSummary).toList(), + result.getTotalElements(), page, size); + } + + @Transactional(readOnly = true) + public SalaryHistoryDetailResponse get(Long userId, Long historyId) { + requireStudent(userId); + SalaryCalculationHistory history = ownHistory(userId, historyId); + return SalaryHistoryDetailResponse.builder() + .id(history.getId()) + .cityCode(history.getCityCode()) + .cityName(history.getCityName()) + .dataLevel(history.getDataLevel()) + .policyVersion(history.getPolicyVersion()) + .taxRulesVersion(history.getTaxRulesVersion()) + .monthIndex(history.getMonthIndex()) + .grossMonthlySalary(history.getGrossMonthlySalary()) + .netSalary(history.getNetSalary()) + .createdAt(history.getCreatedAt()) + .inputSnapshot(readJson(history.getInputSnapshotJson())) + .policySnapshot(readJson(history.getPolicySnapshotJson())) + .resultSnapshot(readJson(history.getResultSnapshotJson())) + .build(); + } + + @Transactional + public void delete(Long userId, Long historyId) { + requireStudent(userId); + SalaryCalculationHistory history = ownHistory(userId, historyId); + historyRepository.delete(history); + historyRepository.flush(); + } + + private SalaryHistorySummaryResponse toSummary(SalaryCalculationHistory history) { + return SalaryHistorySummaryResponse.builder() + .id(history.getId()) + .cityCode(history.getCityCode()) + .cityName(history.getCityName()) + .dataLevel(history.getDataLevel()) + .grossMonthlySalary(history.getGrossMonthlySalary()) + .netSalary(history.getNetSalary()) + .monthIndex(history.getMonthIndex()) + .createdAt(history.getCreatedAt()) + .build(); + } + + private SalaryCalculationHistory ownHistory(Long userId, Long historyId) { + if (historyId == null) { + throw new BusinessException(ErrorCode.SALARY_HISTORY_NOT_FOUND); + } + return historyRepository.findByIdAndStudentUserId(historyId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.SALARY_HISTORY_NOT_FOUND)); + } + + private JsonNode readJson(String value) { + try { + return objectMapper.readTree(value); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("stored salary snapshot is invalid", ex); + } + } + + private void requireStudent(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles() == null + || user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryOverrideParameters.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryOverrideParameters.java new file mode 100644 index 0000000..6ae17a4 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryOverrideParameters.java @@ -0,0 +1,22 @@ +package com.mydreamjob.salarycalculator; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryOverrideParameters { + private BigDecimal socialSecurityBase; + private BigDecimal housingFundBase; + private BigDecimal pensionRate; + private BigDecimal medicalRate; + private BigDecimal unemploymentRate; + private BigDecimal housingFundRate; + private BigDecimal seriousIllnessMonthlyFee; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyVersionConflictException.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyVersionConflictException.java new file mode 100644 index 0000000..4da720c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyVersionConflictException.java @@ -0,0 +1,23 @@ +package com.mydreamjob.salarycalculator; + +import java.time.LocalDate; + +public class SalaryPolicyVersionConflictException extends RuntimeException { + + private final String latestPolicyVersion; + private final LocalDate latestPolicyEffectiveDate; + + public SalaryPolicyVersionConflictException(String latestPolicyVersion, LocalDate latestPolicyEffectiveDate) { + super("salary city policy version conflict"); + this.latestPolicyVersion = latestPolicyVersion; + this.latestPolicyEffectiveDate = latestPolicyEffectiveDate; + } + + public String getLatestPolicyVersion() { + return latestPolicyVersion; + } + + public LocalDate getLatestPolicyEffectiveDate() { + return latestPolicyEffectiveDate; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryRequestFingerprint.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryRequestFingerprint.java new file mode 100644 index 0000000..62fe071 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryRequestFingerprint.java @@ -0,0 +1,68 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.salarycalculator.dto.SalaryCalculationOverrideRequest; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +@Component +public class SalaryRequestFingerprint { + + public String fingerprint(SalaryCalculationRequest request) { + StringBuilder canonical = new StringBuilder(); + append(canonical, normalize(request.getCityCode())); + append(canonical, normalize(request.getExpectedPolicyVersion())); + append(canonical, decimal(request.getGrossMonthlySalary())); + append(canonical, String.valueOf(request.getMonthIndex() == null ? 1 : request.getMonthIndex())); + append(canonical, decimal(request.getSpecialAdditionalDeduction() == null + ? BigDecimal.ZERO : request.getSpecialAdditionalDeduction())); + SalaryCalculationOverrideRequest overrides = request.getOverrides(); + if (overrides == null) { + for (int i = 0; i < 7; i++) { + append(canonical, ""); + } + } else { + append(canonical, decimal(overrides.getSocialSecurityBase())); + append(canonical, decimal(overrides.getHousingFundBase())); + append(canonical, decimal(overrides.getPensionRate())); + append(canonical, decimal(overrides.getMedicalRate())); + append(canonical, decimal(overrides.getUnemploymentRate())); + append(canonical, decimal(overrides.getHousingFundRate())); + append(canonical, decimal(overrides.getSeriousIllnessMonthlyFee())); + } + return sha256(canonical.toString()); + } + + private void append(StringBuilder builder, String value) { + builder.append(value.length()).append(':').append(value).append('|'); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private String decimal(BigDecimal value) { + if (value == null) { + return ""; + } + BigDecimal normalized = value.stripTrailingZeros(); + if (normalized.scale() < 0) { + normalized = normalized.setScale(0); + } + return normalized.toPlainString(); + } + + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is unavailable", ex); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationOverrideRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationOverrideRequest.java new file mode 100644 index 0000000..407f27c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationOverrideRequest.java @@ -0,0 +1,53 @@ +package com.mydreamjob.salarycalculator.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCalculationOverrideRequest { + + @DecimalMin(value = "0.00") + @DecimalMax(value = "10000000.00") + @Digits(integer = 8, fraction = 2) + private BigDecimal socialSecurityBase; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "10000000.00") + @Digits(integer = 8, fraction = 2) + private BigDecimal housingFundBase; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "0.20") + @Digits(integer = 1, fraction = 6) + private BigDecimal pensionRate; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "0.20") + @Digits(integer = 1, fraction = 6) + private BigDecimal medicalRate; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "0.10") + @Digits(integer = 1, fraction = 6) + private BigDecimal unemploymentRate; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "0.12") + @Digits(integer = 1, fraction = 6) + private BigDecimal housingFundRate; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "10000.00") + @Digits(integer = 5, fraction = 2) + private BigDecimal seriousIllnessMonthlyFee; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationRequest.java new file mode 100644 index 0000000..ee141ac --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationRequest.java @@ -0,0 +1,51 @@ +package com.mydreamjob.salarycalculator.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCalculationRequest { + + @NotBlank + private String cityCode; + + @NotNull + @DecimalMin(value = "0.01") + @DecimalMax(value = "10000000.00") + @Digits(integer = 8, fraction = 2) + private BigDecimal grossMonthlySalary; + + @Min(1) + @Max(12) + private Integer monthIndex; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "10000000.00") + @Digits(integer = 8, fraction = 2) + private BigDecimal specialAdditionalDeduction; + + @NotBlank + private String expectedPolicyVersion; + + @Size(max = 64) + private String idempotencyKey; + + @Valid + private SalaryCalculationOverrideRequest overrides; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationResponse.java new file mode 100644 index 0000000..dd3685d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationResponse.java @@ -0,0 +1,19 @@ +package com.mydreamjob.salarycalculator.dto; + +import com.mydreamjob.salarycalculator.SalaryCalculationResult; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCalculationResponse { + private Long historyId; + private LocalDateTime createdAt; + private SalaryCalculationResult result; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCityDetailResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCityDetailResponse.java new file mode 100644 index 0000000..f35c101 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCityDetailResponse.java @@ -0,0 +1,34 @@ +package com.mydreamjob.salarycalculator.dto; + +import com.mydreamjob.salarycalculator.SalaryDataLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCityDetailResponse { + private String cityCode; + private String provinceCode; + private String provinceName; + private String cityName; + private String policyVersion; + private LocalDate policyEffectiveDate; + private SalaryDataLevel dataLevel; + private String dataSource; + private BigDecimal socialSecurityBaseMin; + private BigDecimal socialSecurityBaseMax; + private BigDecimal housingFundBaseMin; + private BigDecimal housingFundBaseMax; + private BigDecimal pensionRate; + private BigDecimal medicalRate; + private BigDecimal unemploymentRate; + private BigDecimal housingFundRate; + private BigDecimal seriousIllnessMonthlyFee; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCitySummaryResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCitySummaryResponse.java new file mode 100644 index 0000000..2d85502 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCitySummaryResponse.java @@ -0,0 +1,23 @@ +package com.mydreamjob.salarycalculator.dto; + +import com.mydreamjob.salarycalculator.SalaryDataLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCitySummaryResponse { + private String cityCode; + private String provinceCode; + private String provinceName; + private String cityName; + private String policyVersion; + private LocalDate policyEffectiveDate; + private SalaryDataLevel dataLevel; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistoryDetailResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistoryDetailResponse.java new file mode 100644 index 0000000..c474f19 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistoryDetailResponse.java @@ -0,0 +1,31 @@ +package com.mydreamjob.salarycalculator.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import com.mydreamjob.salarycalculator.SalaryDataLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryHistoryDetailResponse { + private Long id; + private String cityCode; + private String cityName; + private SalaryDataLevel dataLevel; + private String policyVersion; + private String taxRulesVersion; + private Integer monthIndex; + private BigDecimal grossMonthlySalary; + private BigDecimal netSalary; + private LocalDateTime createdAt; + private JsonNode inputSnapshot; + private JsonNode policySnapshot; + private JsonNode resultSnapshot; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistorySummaryResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistorySummaryResponse.java new file mode 100644 index 0000000..87ea92a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistorySummaryResponse.java @@ -0,0 +1,25 @@ +package com.mydreamjob.salarycalculator.dto; + +import com.mydreamjob.salarycalculator.SalaryDataLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryHistorySummaryResponse { + private Long id; + private String cityCode; + private String cityName; + private SalaryDataLevel dataLevel; + private BigDecimal grossMonthlySalary; + private BigDecimal netSalary; + private Integer monthIndex; + private LocalDateTime createdAt; +} diff --git a/mydreamjob-server/src/main/resources/db/migration/V40__salary_calculator_schema.sql b/mydreamjob-server/src/main/resources/db/migration/V40__salary_calculator_schema.sql new file mode 100644 index 0000000..cd1ab3d --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V40__salary_calculator_schema.sql @@ -0,0 +1,71 @@ +-- Flyway V40: 薪资计算器城市政策与历史快照表结构 +-- V40 只创建结构;目标城市政策由 V41 导入。 + +CREATE TABLE salary_city_policy ( + city_code VARCHAR(6) NOT NULL PRIMARY KEY COMMENT '六位行政区划编码', + province_code VARCHAR(6) NOT NULL COMMENT '六位省级行政区划编码', + province_name VARCHAR(40) NOT NULL COMMENT '省份名称', + city_name VARCHAR(80) NOT NULL COMMENT '城市名称', + policy_version VARCHAR(64) NOT NULL COMMENT '城市政策版本', + policy_effective_date DATE NOT NULL COMMENT '政策实际生效日期', + data_source VARCHAR(500) NULL COMMENT '可选数据来源说明', + social_security_base_min DECIMAL(12,2) NOT NULL COMMENT '社保缴费基数下限', + social_security_base_max DECIMAL(12,2) NOT NULL COMMENT '社保缴费基数上限', + housing_fund_base_min DECIMAL(12,2) NOT NULL COMMENT '公积金缴存基数下限', + housing_fund_base_max DECIMAL(12,2) NOT NULL COMMENT '公积金缴存基数上限', + pension_rate DECIMAL(7,6) NOT NULL COMMENT '养老个人比例', + medical_rate DECIMAL(7,6) NOT NULL COMMENT '医疗个人比例', + unemployment_rate DECIMAL(7,6) NOT NULL COMMENT '失业个人比例', + housing_fund_rate DECIMAL(7,6) NOT NULL COMMENT '公积金个人比例', + serious_illness_monthly_fee DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '大病固定费/月', + data_level VARCHAR(30) NOT NULL DEFAULT 'CITY' COMMENT 'CITY 或 PROVINCE_FALLBACK', + enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT ck_scp_ss_base CHECK ( + social_security_base_min >= 0 + AND social_security_base_max >= social_security_base_min + ), + CONSTRAINT ck_scp_fund_base CHECK ( + housing_fund_base_min >= 0 + AND housing_fund_base_max >= housing_fund_base_min + ), + CONSTRAINT ck_scp_pension_rate CHECK (pension_rate >= 0 AND pension_rate <= 0.20), + CONSTRAINT ck_scp_medical_rate CHECK (medical_rate >= 0 AND medical_rate <= 0.20), + CONSTRAINT ck_scp_unemployment_rate CHECK (unemployment_rate >= 0 AND unemployment_rate <= 0.10), + CONSTRAINT ck_scp_fund_rate CHECK (housing_fund_rate >= 0 AND housing_fund_rate <= 0.12), + CONSTRAINT ck_scp_illness_fee CHECK ( + serious_illness_monthly_fee >= 0 AND serious_illness_monthly_fee <= 10000.00 + ), + CONSTRAINT ck_scp_data_level CHECK (data_level IN ('CITY', 'PROVINCE_FALLBACK')), + INDEX idx_scp_enabled_order (enabled, province_code, city_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='薪资计算城市政策'; + +CREATE TABLE salary_calculation_history ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + student_user_id BIGINT NOT NULL COMMENT '所属学生用户', + city_code VARCHAR(6) NOT NULL COMMENT '城市编码快照', + city_name VARCHAR(80) NOT NULL COMMENT '城市名称快照', + data_level VARCHAR(30) NOT NULL COMMENT 'CITY 或 PROVINCE_FALLBACK', + policy_version VARCHAR(64) NOT NULL COMMENT '城市政策版本', + tax_rules_version VARCHAR(64) NOT NULL COMMENT '全国个税规则版本', + month_index INT NOT NULL COMMENT '累计计税月份', + gross_monthly_salary DECIMAL(12,2) NOT NULL COMMENT '税前月薪', + net_salary DECIMAL(12,2) NOT NULL COMMENT '到手工资', + idempotency_key VARCHAR(64) NULL COMMENT '可选幂等键', + request_hash CHAR(64) NULL COMMENT '规范化请求指纹', + input_snapshot_json TEXT NOT NULL COMMENT '输入快照 JSON', + policy_snapshot_json TEXT NOT NULL COMMENT '政策与最终参数快照 JSON', + result_snapshot_json TEXT NOT NULL COMMENT '完整结果快照 JSON', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT ck_sch_month_index CHECK (month_index BETWEEN 1 AND 12), + CONSTRAINT ck_sch_gross CHECK (gross_monthly_salary > 0 AND gross_monthly_salary <= 10000000.00), + CONSTRAINT ck_sch_net CHECK (net_salary >= 0), + CONSTRAINT uk_sch_student_idempotency UNIQUE (student_user_id, idempotency_key), + CONSTRAINT fk_sch_student FOREIGN KEY (student_user_id) REFERENCES users (id), + CONSTRAINT fk_sch_city FOREIGN KEY (city_code) REFERENCES salary_city_policy (city_code), + INDEX idx_sch_student_created (student_user_id, created_at, id), + INDEX idx_sch_student_city (student_user_id, city_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='薪资计算历史快照'; + + diff --git a/mydreamjob-server/src/main/resources/db/migration/V41__salary_city_policy_seed.sql b/mydreamjob-server/src/main/resources/db/migration/V41__salary_city_policy_seed.sql new file mode 100644 index 0000000..78b5575 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V41__salary_city_policy_seed.sql @@ -0,0 +1,369 @@ +-- Flyway V41: 分层城市政策种子 +-- CITY 为已核验城市级参数;PROVINCE_FALLBACK 为省级公开基线回退,待城市级核验。 +INSERT INTO salary_city_policy ( + city_code, province_code, province_name, city_name, policy_version, policy_effective_date, + data_source, social_security_base_min, social_security_base_max, housing_fund_base_min, + housing_fund_base_max, pension_rate, medical_rate, unemployment_rate, housing_fund_rate, + serious_illness_monthly_fee, data_level, enabled +) VALUES + ('110000', '110000', '北京市', '北京市', '2026-09-20-v1', '2026-07-01', '公开城市参数整理(待发布前复核)', 7270, 36348, 2540, 36348, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('120000', '120000', '天津市', '天津市', '2026-09-20-v1', '2026-07-01', '公开城市参数整理(待发布前复核)', 5180, 25902, 2510, 28917, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('130100', '130000', '河北省', '石家庄市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130200', '130000', '河北省', '唐山市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130300', '130000', '河北省', '秦皇岛市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130400', '130000', '河北省', '邯郸市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130500', '130000', '河北省', '邢台市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130600', '130000', '河北省', '保定市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130700', '130000', '河北省', '张家口市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130800', '130000', '河北省', '承德市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('130900', '130000', '河北省', '沧州市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('131000', '130000', '河北省', '廊坊市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('131100', '130000', '河北省', '衡水市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140100', '140000', '山西省', '太原市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140200', '140000', '山西省', '大同市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140300', '140000', '山西省', '阳泉市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140400', '140000', '山西省', '长治市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140500', '140000', '山西省', '晋城市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140600', '140000', '山西省', '朔州市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140700', '140000', '山西省', '晋中市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140800', '140000', '山西省', '运城市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('140900', '140000', '山西省', '忻州市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('141000', '140000', '山西省', '临汾市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('141100', '140000', '山西省', '吕梁市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150100', '150000', '内蒙古自治区', '呼和浩特市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150200', '150000', '内蒙古自治区', '包头市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150300', '150000', '内蒙古自治区', '乌海市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150400', '150000', '内蒙古自治区', '赤峰市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150500', '150000', '内蒙古自治区', '通辽市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150600', '150000', '内蒙古自治区', '鄂尔多斯市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150700', '150000', '内蒙古自治区', '呼伦贝尔市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150800', '150000', '内蒙古自治区', '巴彦淖尔市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('150900', '150000', '内蒙古自治区', '乌兰察布市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('152200', '150000', '内蒙古自治区', '兴安盟', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('152500', '150000', '内蒙古自治区', '锡林郭勒盟', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('152900', '150000', '内蒙古自治区', '阿拉善盟', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210100', '210000', '辽宁省', '沈阳市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210200', '210000', '辽宁省', '大连市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210300', '210000', '辽宁省', '鞍山市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210400', '210000', '辽宁省', '抚顺市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210500', '210000', '辽宁省', '本溪市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210600', '210000', '辽宁省', '丹东市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210700', '210000', '辽宁省', '锦州市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210800', '210000', '辽宁省', '营口市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('210900', '210000', '辽宁省', '阜新市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('211000', '210000', '辽宁省', '辽阳市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('211100', '210000', '辽宁省', '盘锦市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('211200', '210000', '辽宁省', '铁岭市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('211300', '210000', '辽宁省', '朝阳市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('211400', '210000', '辽宁省', '葫芦岛市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220100', '220000', '吉林省', '长春市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220200', '220000', '吉林省', '吉林市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220300', '220000', '吉林省', '四平市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220400', '220000', '吉林省', '辽源市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220500', '220000', '吉林省', '通化市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220600', '220000', '吉林省', '白山市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220700', '220000', '吉林省', '松原市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('220800', '220000', '吉林省', '白城市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('222400', '220000', '吉林省', '延边朝鲜族自治州', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230100', '230000', '黑龙江省', '哈尔滨市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230200', '230000', '黑龙江省', '齐齐哈尔市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230300', '230000', '黑龙江省', '鸡西市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230400', '230000', '黑龙江省', '鹤岗市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230500', '230000', '黑龙江省', '双鸭山市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230600', '230000', '黑龙江省', '大庆市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230700', '230000', '黑龙江省', '伊春市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230800', '230000', '黑龙江省', '佳木斯市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('230900', '230000', '黑龙江省', '七台河市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('231000', '230000', '黑龙江省', '牡丹江市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('231100', '230000', '黑龙江省', '黑河市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('231200', '230000', '黑龙江省', '绥化市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('232700', '230000', '黑龙江省', '大兴安岭地区', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('310000', '310000', '上海市', '上海市', '2026-09-20-v1', '2026-07-01', '公开城市参数整理(待发布前复核)', 7546, 37731, 2740, 37731, 0.08, 0.02, 0.005, 0.07, 0, 'CITY', TRUE), + ('320100', '320000', '江苏省', '南京市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('320200', '320000', '江苏省', '无锡市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('320300', '320000', '江苏省', '徐州市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('320400', '320000', '江苏省', '常州市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('320500', '320000', '江苏省', '苏州市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4952, 24762, 4952, 40600, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('320600', '320000', '江苏省', '南通市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('320700', '320000', '江苏省', '连云港市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('320800', '320000', '江苏省', '淮安市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('320900', '320000', '江苏省', '盐城市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('321000', '320000', '江苏省', '扬州市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('321100', '320000', '江苏省', '镇江市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('321200', '320000', '江苏省', '泰州市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('321300', '320000', '江苏省', '宿迁市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330100', '330000', '浙江省', '杭州市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('330200', '330000', '浙江省', '宁波市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330300', '330000', '浙江省', '温州市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330400', '330000', '浙江省', '嘉兴市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330500', '330000', '浙江省', '湖州市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330600', '330000', '浙江省', '绍兴市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330700', '330000', '浙江省', '金华市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330800', '330000', '浙江省', '衢州市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('330900', '330000', '浙江省', '舟山市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('331000', '330000', '浙江省', '台州市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('331100', '330000', '浙江省', '丽水市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340100', '340000', '安徽省', '合肥市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340200', '340000', '安徽省', '芜湖市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340300', '340000', '安徽省', '蚌埠市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340400', '340000', '安徽省', '淮南市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340500', '340000', '安徽省', '马鞍山市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340600', '340000', '安徽省', '淮北市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340700', '340000', '安徽省', '铜陵市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('340800', '340000', '安徽省', '安庆市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341000', '340000', '安徽省', '黄山市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341100', '340000', '安徽省', '滁州市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341200', '340000', '安徽省', '阜阳市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341300', '340000', '安徽省', '宿州市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341500', '340000', '安徽省', '六安市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341600', '340000', '安徽省', '亳州市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341700', '340000', '安徽省', '池州市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('341800', '340000', '安徽省', '宣城市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350100', '350000', '福建省', '福州市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350200', '350000', '福建省', '厦门市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350300', '350000', '福建省', '莆田市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350400', '350000', '福建省', '三明市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350500', '350000', '福建省', '泉州市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350600', '350000', '福建省', '漳州市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350700', '350000', '福建省', '南平市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350800', '350000', '福建省', '龙岩市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('350900', '350000', '福建省', '宁德市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360100', '360000', '江西省', '南昌市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360200', '360000', '江西省', '景德镇市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360300', '360000', '江西省', '萍乡市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360400', '360000', '江西省', '九江市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360500', '360000', '江西省', '新余市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360600', '360000', '江西省', '鹰潭市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360700', '360000', '江西省', '赣州市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360800', '360000', '江西省', '吉安市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('360900', '360000', '江西省', '宜春市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('361000', '360000', '江西省', '抚州市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('361100', '360000', '江西省', '上饶市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370100', '370000', '山东省', '济南市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370200', '370000', '山东省', '青岛市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370300', '370000', '山东省', '淄博市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370400', '370000', '山东省', '枣庄市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370500', '370000', '山东省', '东营市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370600', '370000', '山东省', '烟台市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370700', '370000', '山东省', '潍坊市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370800', '370000', '山东省', '济宁市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('370900', '370000', '山东省', '泰安市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371000', '370000', '山东省', '威海市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371100', '370000', '山东省', '日照市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371300', '370000', '山东省', '临沂市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371400', '370000', '山东省', '德州市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371500', '370000', '山东省', '聊城市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371600', '370000', '山东省', '滨州市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('371700', '370000', '山东省', '菏泽市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410100', '410000', '河南省', '郑州市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('410200', '410000', '河南省', '开封市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410300', '410000', '河南省', '洛阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410400', '410000', '河南省', '平顶山市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410500', '410000', '河南省', '安阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410600', '410000', '河南省', '鹤壁市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410700', '410000', '河南省', '新乡市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410800', '410000', '河南省', '焦作市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('410900', '410000', '河南省', '濮阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411000', '410000', '河南省', '许昌市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411100', '410000', '河南省', '漯河市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411200', '410000', '河南省', '三门峡市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411300', '410000', '河南省', '南阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411400', '410000', '河南省', '商丘市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411500', '410000', '河南省', '信阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411600', '410000', '河南省', '周口市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('411700', '410000', '河南省', '驻马店市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('419001', '410000', '河南省', '济源市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420100', '420000', '湖北省', '武汉市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('420200', '420000', '湖北省', '黄石市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420300', '420000', '湖北省', '十堰市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420500', '420000', '湖北省', '宜昌市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420600', '420000', '湖北省', '襄阳市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420700', '420000', '湖北省', '鄂州市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420800', '420000', '湖北省', '荆门市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('420900', '420000', '湖北省', '孝感市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('421000', '420000', '湖北省', '荆州市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('421100', '420000', '湖北省', '黄冈市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('421200', '420000', '湖北省', '咸宁市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('421300', '420000', '湖北省', '随州市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('422800', '420000', '湖北省', '恩施土家族苗族自治州', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('429004', '420000', '湖北省', '仙桃市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('429005', '420000', '湖北省', '潜江市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('429006', '420000', '湖北省', '天门市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430100', '430000', '湖南省', '长沙市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430200', '430000', '湖南省', '株洲市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430300', '430000', '湖南省', '湘潭市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430400', '430000', '湖南省', '衡阳市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430500', '430000', '湖南省', '邵阳市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430600', '430000', '湖南省', '岳阳市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430700', '430000', '湖南省', '常德市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430800', '430000', '湖南省', '张家界市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('430900', '430000', '湖南省', '益阳市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('431000', '430000', '湖南省', '郴州市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('431100', '430000', '湖南省', '永州市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('431200', '430000', '湖南省', '怀化市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('431300', '430000', '湖南省', '娄底市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('433100', '430000', '湖南省', '湘西土家族苗族自治州', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440100', '440000', '广东省', '广州市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('440200', '440000', '广东省', '韶关市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440300', '440000', '广东省', '深圳市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 5510, 27549, 2520, 41190, 0.08, 0.02, 0.005, 0.05, 0, 'CITY', TRUE), + ('440400', '440000', '广东省', '珠海市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440500', '440000', '广东省', '汕头市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440600', '440000', '广东省', '佛山市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440700', '440000', '广东省', '江门市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440800', '440000', '广东省', '湛江市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('440900', '440000', '广东省', '茂名市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441200', '440000', '广东省', '肇庆市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441300', '440000', '广东省', '惠州市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441400', '440000', '广东省', '梅州市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441500', '440000', '广东省', '汕尾市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441600', '440000', '广东省', '河源市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441700', '440000', '广东省', '阳江市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441800', '440000', '广东省', '清远市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('441900', '440000', '广东省', '东莞市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('442000', '440000', '广东省', '中山市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('445100', '440000', '广东省', '潮州市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('445200', '440000', '广东省', '揭阳市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('445300', '440000', '广东省', '云浮市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450100', '450000', '广西壮族自治区', '南宁市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450200', '450000', '广西壮族自治区', '柳州市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450300', '450000', '广西壮族自治区', '桂林市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450400', '450000', '广西壮族自治区', '梧州市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450500', '450000', '广西壮族自治区', '北海市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450600', '450000', '广西壮族自治区', '防城港市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450700', '450000', '广西壮族自治区', '钦州市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450800', '450000', '广西壮族自治区', '贵港市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('450900', '450000', '广西壮族自治区', '玉林市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('451000', '450000', '广西壮族自治区', '百色市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('451100', '450000', '广西壮族自治区', '贺州市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('451200', '450000', '广西壮族自治区', '河池市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('451300', '450000', '广西壮族自治区', '来宾市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('451400', '450000', '广西壮族自治区', '崇左市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('460100', '460000', '海南省', '海口市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('460200', '460000', '海南省', '三亚市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('460300', '460000', '海南省', '三沙市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('460400', '460000', '海南省', '儋州市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('469001', '460000', '海南省', '五指山市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('469002', '460000', '海南省', '琼海市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('469005', '460000', '海南省', '文昌市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('469006', '460000', '海南省', '万宁市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('469007', '460000', '海南省', '东方市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('500000', '500000', '重庆市', '重庆市', '2026-09-20-v1', '2025-07-01', '重庆市省级公开参数回退基线,待城市级核验', 4404, 22017, 2000, 22017, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('500200', '500000', '重庆市', '县', '2026-09-20-v1', '2025-07-01', '重庆市省级公开参数回退基线,待城市级核验', 4404, 22017, 2000, 22017, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510100', '510000', '四川省', '成都市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('510300', '510000', '四川省', '自贡市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510400', '510000', '四川省', '攀枝花市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510500', '510000', '四川省', '泸州市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510600', '510000', '四川省', '德阳市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510700', '510000', '四川省', '绵阳市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510800', '510000', '四川省', '广元市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('510900', '510000', '四川省', '遂宁市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511000', '510000', '四川省', '内江市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511100', '510000', '四川省', '乐山市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511300', '510000', '四川省', '南充市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511400', '510000', '四川省', '眉山市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511500', '510000', '四川省', '宜宾市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511600', '510000', '四川省', '广安市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511700', '510000', '四川省', '达州市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511800', '510000', '四川省', '雅安市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('511900', '510000', '四川省', '巴中市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('512000', '510000', '四川省', '资阳市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('513200', '510000', '四川省', '阿坝藏族羌族自治州', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('513300', '510000', '四川省', '甘孜藏族自治州', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('513400', '510000', '四川省', '凉山彝族自治州', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('520100', '520000', '贵州省', '贵阳市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('520200', '520000', '贵州省', '六盘水市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('520300', '520000', '贵州省', '遵义市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('520400', '520000', '贵州省', '安顺市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('520500', '520000', '贵州省', '毕节市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('520600', '520000', '贵州省', '铜仁市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('522300', '520000', '贵州省', '黔西南布依族苗族自治州', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('522600', '520000', '贵州省', '黔东南苗族侗族自治州', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('522700', '520000', '贵州省', '黔南布依族苗族自治州', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530100', '530000', '云南省', '昆明市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530300', '530000', '云南省', '曲靖市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530400', '530000', '云南省', '玉溪市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530500', '530000', '云南省', '保山市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530600', '530000', '云南省', '昭通市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530700', '530000', '云南省', '丽江市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530800', '530000', '云南省', '普洱市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('530900', '530000', '云南省', '临沧市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('532300', '530000', '云南省', '楚雄彝族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('532500', '530000', '云南省', '红河哈尼族彝族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('532600', '530000', '云南省', '文山壮族苗族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('532800', '530000', '云南省', '西双版纳傣族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('532900', '530000', '云南省', '大理白族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('533100', '530000', '云南省', '德宏傣族景颇族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('533300', '530000', '云南省', '怒江傈僳族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('533400', '530000', '云南省', '迪庆藏族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('540100', '540000', '西藏自治区', '拉萨市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('540200', '540000', '西藏自治区', '日喀则市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('540300', '540000', '西藏自治区', '昌都市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('540400', '540000', '西藏自治区', '林芝市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('540500', '540000', '西藏自治区', '山南市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('540600', '540000', '西藏自治区', '那曲市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('542500', '540000', '西藏自治区', '阿里地区', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610100', '610000', '陕西省', '西安市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), + ('610200', '610000', '陕西省', '铜川市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610300', '610000', '陕西省', '宝鸡市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610400', '610000', '陕西省', '咸阳市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610500', '610000', '陕西省', '渭南市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610600', '610000', '陕西省', '延安市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610700', '610000', '陕西省', '汉中市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610800', '610000', '陕西省', '榆林市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('610900', '610000', '陕西省', '安康市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('611000', '610000', '陕西省', '商洛市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620100', '620000', '甘肃省', '兰州市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620200', '620000', '甘肃省', '嘉峪关市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620300', '620000', '甘肃省', '金昌市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620400', '620000', '甘肃省', '白银市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620500', '620000', '甘肃省', '天水市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620600', '620000', '甘肃省', '武威市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620700', '620000', '甘肃省', '张掖市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620800', '620000', '甘肃省', '平凉市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('620900', '620000', '甘肃省', '酒泉市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('621000', '620000', '甘肃省', '庆阳市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('621100', '620000', '甘肃省', '定西市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('621200', '620000', '甘肃省', '陇南市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('622900', '620000', '甘肃省', '临夏回族自治州', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('623000', '620000', '甘肃省', '甘南藏族自治州', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('630100', '630000', '青海省', '西宁市', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('630200', '630000', '青海省', '海东市', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('632200', '630000', '青海省', '海北藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('632300', '630000', '青海省', '黄南藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('632500', '630000', '青海省', '海南藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('632600', '630000', '青海省', '果洛藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('632700', '630000', '青海省', '玉树藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('632800', '630000', '青海省', '海西蒙古族藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('640100', '640000', '宁夏回族自治区', '银川市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('640200', '640000', '宁夏回族自治区', '石嘴山市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('640300', '640000', '宁夏回族自治区', '吴忠市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('640400', '640000', '宁夏回族自治区', '固原市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('640500', '640000', '宁夏回族自治区', '中卫市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('650100', '650000', '新疆维吾尔自治区', '乌鲁木齐市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('650200', '650000', '新疆维吾尔自治区', '克拉玛依市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('650400', '650000', '新疆维吾尔自治区', '吐鲁番市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('650500', '650000', '新疆维吾尔自治区', '哈密市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('652300', '650000', '新疆维吾尔自治区', '昌吉回族自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('652700', '650000', '新疆维吾尔自治区', '博尔塔拉蒙古自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('652800', '650000', '新疆维吾尔自治区', '巴音郭楞蒙古自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('652900', '650000', '新疆维吾尔自治区', '阿克苏地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('653000', '650000', '新疆维吾尔自治区', '克孜勒苏柯尔克孜自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('653100', '650000', '新疆维吾尔自治区', '喀什地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('653200', '650000', '新疆维吾尔自治区', '和田地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('654000', '650000', '新疆维吾尔自治区', '伊犁哈萨克自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('654200', '650000', '新疆维吾尔自治区', '塔城地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('654300', '650000', '新疆维吾尔自治区', '阿勒泰地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659001', '650000', '新疆维吾尔自治区', '石河子市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659002', '650000', '新疆维吾尔自治区', '阿拉尔市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659003', '650000', '新疆维吾尔自治区', '图木舒克市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659004', '650000', '新疆维吾尔自治区', '五家渠市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659005', '650000', '新疆维吾尔自治区', '北屯市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659006', '650000', '新疆维吾尔自治区', '铁门关市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659007', '650000', '新疆维吾尔自治区', '双河市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659008', '650000', '新疆维吾尔自治区', '可克达拉市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659009', '650000', '新疆维吾尔自治区', '昆玉市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659010', '650000', '新疆维吾尔自治区', '胡杨河市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659011', '650000', '新疆维吾尔自治区', '新星市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), + ('659012', '650000', '新疆维吾尔自治区', '白杨市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE) +; + diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAccountDeletionIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAccountDeletionIntegrationTest.java new file mode 100644 index 0000000..4b9eb03 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAccountDeletionIntegrationTest.java @@ -0,0 +1,81 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.deletion.AccountDeletion; +import com.mydreamjob.deletion.AccountDeletionCompletionWorker; +import com.mydreamjob.deletion.AccountDeletionRepository; +import com.mydreamjob.deletion.DeletionStatus; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_deletion;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class SalaryAccountDeletionIntegrationTest { + + private static final AtomicLong PHONE = new AtomicLong(970_000_000L); + + @Autowired private UserRepository userRepository; + @Autowired private SalaryCalculationHistoryRepository historyRepository; + @Autowired private AccountDeletionRepository accountDeletionRepository; + @Autowired private AccountDeletionCompletionWorker completionWorker; + + @MockBean private RedisTemplate redisTemplate; + + @Test + void completedDeletionPurgesSalaryHistory() { + User user = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(User.Role.STUDENT)) + .role(User.Role.STUDENT) + .active(true) + .build()); + historyRepository.save(history(user.getId())); + accountDeletionRepository.save(AccountDeletion.builder() + .userId(user.getId()) + .status(DeletionStatus.PENDING) + .requestedAt(LocalDateTime.now().minusDays(31)) + .effectiveAt(LocalDateTime.now().minusMinutes(1)) + .version(0L) + .build()); + + assertThat(completionWorker.completeIfExpired(user.getId())).isTrue(); + + assertThat(historyRepository.findByStudentUserIdOrderByCreatedAtDescIdDesc( + user.getId(), PageRequest.of(0, 20)).getTotalElements()).isZero(); + assertThat(userRepository.findById(user.getId()).orElseThrow().getPhone()) + .startsWith("deleted_"); + } + + private SalaryCalculationHistory history(Long userId) { + return SalaryCalculationHistory.builder() + .studentUserId(userId) + .cityCode("110000") + .cityName("北京市") + .dataLevel(SalaryDataLevel.CITY) + .policyVersion("2026-09-20-v1") + .taxRulesVersion("CN_IIT_2019_V1") + .monthIndex(1) + .grossMonthlySalary(new BigDecimal("10000.00")) + .netSalary(new BigDecimal("7667.50")) + .inputSnapshotJson("{}") + .policySnapshotJson("{}") + .resultSnapshotJson("{}") + .build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAuthorizationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAuthorizationIntegrationTest.java new file mode 100644 index 0000000..4afb135 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAuthorizationIntegrationTest.java @@ -0,0 +1,82 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_auth;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class SalaryAuthorizationIntegrationTest { + + private static final AtomicLong PHONE = new AtomicLong(980_000_000L); + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private SalaryCalculationHistoryRepository historyRepository; + + @MockBean private RedisTemplate redisTemplate; + + @Test + void anonymousCannotUseSalaryCalculator() throws Exception { + mockMvc.perform(get("/salary-calculator/calculations")) + .andExpect(status().isUnauthorized()); + } + + @Test + void companyCannotCalculateOrReadHistory() throws Exception { + User company = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(User.Role.COMPANY)) + .role(User.Role.COMPANY) + .active(true) + .build()); + String token = token(company.getId()); + + String body = "{\"cityCode\":\"110000\",\"grossMonthlySalary\":10000," + + "\"monthIndex\":1,\"specialAdditionalDeduction\":0," + + "\"expectedPolicyVersion\":\"2026-09-20-v1\"}"; + + mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + + mockMvc.perform(get("/salary-calculator/calculations") + .header("Authorization", token)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + + assertThat(historyRepository.count()).isZero(); + } + + private String token(long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken( + userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "COMPANY")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java new file mode 100644 index 0000000..492f857 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java @@ -0,0 +1,168 @@ +package com.mydreamjob.salarycalculator; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_e2e;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class SalaryCalculationEndToEndIntegrationTest { + + private static final AtomicLong PHONE = new AtomicLong(960_000_000L); + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private SalaryCalculationHistoryRepository historyRepository; + private final ObjectMapper mapper = new ObjectMapper(); + + @MockBean private RedisTemplate redisTemplate; + + @Test + void calculateReplayConflictHistoryAndDelete() throws Exception { + long uid = createUser(User.Role.STUDENT); + String token = token(uid); + String request = requestJson("110000", "10000", 1, "0", "key-1"); + + String first = mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.result.cityCode").value("110000")) + .andExpect(jsonPath("$.data.result.dataLevel").value("CITY")) + .andExpect(jsonPath("$.data.result.estimate").value(true)) + .andExpect(jsonPath("$.data.result.netSalary").value(7667.50)) + .andReturn().getResponse().getContentAsString(); + + long historyId = mapper.readTree(first).path("data").path("historyId").asLong(); + assertThat(historyRepository.count()).isEqualTo(1); + + String replay = mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertThat(mapper.readTree(replay).path("data").path("historyId").asLong()) + .isEqualTo(historyId); + assertThat(historyRepository.count()).isEqualTo(1); + + mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson("110000", "12000", 1, "0", "key-1"))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9804)); + + mockMvc.perform(get("/salary-calculator/calculations") + .header("Authorization", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value(historyId)); + + mockMvc.perform(get("/salary-calculator/calculations/{id}", historyId) + .header("Authorization", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.inputSnapshot.cityCode").value("110000")) + .andExpect(jsonPath("$.data.policySnapshot.dataLevel").value("CITY")) + .andExpect(jsonPath("$.data.resultSnapshot.netSalary").value(7667.50)); + + long otherId = createUser(User.Role.STUDENT); + mockMvc.perform(get("/salary-calculator/calculations/{id}", historyId) + .header("Authorization", token(otherId))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(9803)); + + mockMvc.perform(delete("/salary-calculator/calculations/{id}", historyId) + .header("Authorization", token)) + .andExpect(status().isOk()); + assertThat(historyRepository.findById(historyId)).isEmpty(); + } + + @Test + void policyVersionConflictReturnsLatestVersionWithoutHistory() throws Exception { + long uid = createUser(User.Role.STUDENT); + String request = requestJson("110000", "10000", 1, "0", "version-key") + .replace("2026-09-20-v1", "stale-version"); + + mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9805)) + .andExpect(jsonPath("$.data.latestPolicyVersion").value("2026-09-20-v1")) + .andExpect(jsonPath("$.data.policyEffectiveDate").value("2026-07-01")); + + assertThat(historyRepository.count()).isZero(); + } + + @Test + void invalidOverrideReturns9802() throws Exception { + long uid = createUser(User.Role.STUDENT); + String request = "{\"cityCode\":\"110000\",\"grossMonthlySalary\":10000," + + "\"monthIndex\":1,\"specialAdditionalDeduction\":0," + + "\"expectedPolicyVersion\":\"2026-09-20-v1\"," + + "\"idempotencyKey\":\"bad-override\"," + + "\"overrides\":{\"socialSecurityBase\":1000}}"; + + mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(9802)); + } + + private String requestJson( + String cityCode, String gross, int monthIndex, String special, String idempotencyKey) { + return "{\"cityCode\":\"" + cityCode + "\"," + + "\"grossMonthlySalary\":" + gross + "," + + "\"monthIndex\":" + monthIndex + "," + + "\"specialAdditionalDeduction\":" + special + "," + + "\"expectedPolicyVersion\":\"2026-09-20-v1\"," + + "\"idempotencyKey\":\"" + idempotencyKey + "\"," + + "\"overrides\":null}"; + } + + private long createUser(User.Role role) { + User user = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(role)) + .role(role) + .active(true) + .build()); + return user.getId(); + } + + private String token(long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken( + userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEngineTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEngineTest.java new file mode 100644 index 0000000..67c037a --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEngineTest.java @@ -0,0 +1,150 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class SalaryCalculationEngineTest { + + private final SalaryCalculationEngine engine = new SalaryCalculationEngine(); + + @Test + void calculatesBaseCaseWithCumulativeWithholding() { + SalaryCalculationResult result = engine.calculate(input("10000", 1, "0", null), policy()); + + assertThat(result.getSocialSecurityBase()).isEqualByComparingTo("10000.00"); + assertThat(result.getHousingFundBase()).isEqualByComparingTo("10000.00"); + assertThat(result.getPension()).isEqualByComparingTo("800.00"); + assertThat(result.getMedical()).isEqualByComparingTo("200.00"); + assertThat(result.getUnemployment()).isEqualByComparingTo("50.00"); + assertThat(result.getHousingFund()).isEqualByComparingTo("1200.00"); + assertThat(result.getCurrentMonthTax()).isEqualByComparingTo("82.50"); + assertThat(result.getNetSalary()).isEqualByComparingTo("7667.50"); + assertThat(result.isEstimate()).isTrue(); + assertThat(result.getDataLevel()).isEqualTo(SalaryDataLevel.CITY); + } + + @Test + void clampsBothBasesToFloorAndCeiling() { + SalaryCalculationResult low = engine.calculate(input("3000", 1, "0", null), policy()); + SalaryCalculationResult high = engine.calculate(input("30000", 1, "0", null), policy()); + + assertThat(low.getSocialSecurityBase()).isEqualByComparingTo("5000.00"); + assertThat(low.getHousingFundBase()).isEqualByComparingTo("5000.00"); + assertThat(low.getCurrentMonthTax()).isEqualByComparingTo("0.00"); + assertThat(low.getNetSalary()).isEqualByComparingTo("1875.00"); + + assertThat(high.getSocialSecurityBase()).isEqualByComparingTo("20000.00"); + assertThat(high.getHousingFundBase()).isEqualByComparingTo("20000.00"); + assertThat(high.getCurrentMonthTax()).isEqualByComparingTo("615.00"); + assertThat(high.getNetSalary()).isEqualByComparingTo("24885.00"); + } + + @Test + void usesPriorCumulativeTaxForTwelfthMonth() { + SalaryCalculationResult result = engine.calculate(input("20000", 12, "0", null), policy()); + + assertThat(result.getCumulativeTaxableIncome()).isEqualByComparingTo("126000.00"); + assertThat(result.getCumulativeTaxDue()).isEqualByComparingTo("10080.00"); + assertThat(result.getPreviouslyWithheldTax()).isEqualByComparingTo("9030.00"); + assertThat(result.getCurrentMonthTax()).isEqualByComparingTo("1050.00"); + assertThat(result.getNetSalary()).isEqualByComparingTo("14450.00"); + } + + @Test + void appliesSpecialDeductionAndOverrides() { + SalaryOverrideParameters overrides = SalaryOverrideParameters.builder() + .socialSecurityBase(new BigDecimal("8000.00")) + .housingFundBase(new BigDecimal("10000.00")) + .pensionRate(new BigDecimal("0.08")) + .medicalRate(new BigDecimal("0.02")) + .unemploymentRate(new BigDecimal("0.005")) + .housingFundRate(new BigDecimal("0.12")) + .seriousIllnessMonthlyFee(BigDecimal.ZERO) + .build(); + + SalaryCalculationResult result = engine.calculate( + input("10000", 1, "2000", overrides), policy()); + + assertThat(result.getSocialSecurityBase()).isEqualByComparingTo("8000.00"); + assertThat(result.getPension()).isEqualByComparingTo("640.00"); + assertThat(result.getCurrentMonthTax()).isEqualByComparingTo("28.80"); + assertThat(result.getNetSalary()).isEqualByComparingTo("7931.20"); + } + + @Test + void rejectsOverrideOutsideCityBaseRange() { + SalaryOverrideParameters overrides = SalaryOverrideParameters.builder() + .socialSecurityBase(new BigDecimal("4999.99")) + .build(); + + assertThatThrownBy(() -> engine.calculate(input("10000", 1, "0", overrides), policy())) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.SALARY_INVALID_PARAMETER.getCode()); + } + + @Test + void zeroContributionRateAllowsZeroBase() { + SalaryOverrideParameters overrides = SalaryOverrideParameters.builder() + .socialSecurityBase(BigDecimal.ZERO) + .housingFundBase(BigDecimal.ZERO) + .pensionRate(BigDecimal.ZERO) + .medicalRate(BigDecimal.ZERO) + .unemploymentRate(BigDecimal.ZERO) + .housingFundRate(BigDecimal.ZERO) + .seriousIllnessMonthlyFee(BigDecimal.ZERO) + .build(); + + SalaryCalculationResult result = engine.calculate( + input("10000", 1, "0", overrides), policy()); + + assertThat(result.getPension()).isEqualByComparingTo("0.00"); + assertThat(result.getMedical()).isEqualByComparingTo("0.00"); + assertThat(result.getUnemployment()).isEqualByComparingTo("0.00"); + assertThat(result.getHousingFund()).isEqualByComparingTo("0.00"); + assertThat(result.getCurrentMonthTax()).isEqualByComparingTo("150.00"); + assertThat(result.getNetSalary()).isEqualByComparingTo("9850.00"); + } + + private SalaryCalculationInput input( + String gross, int monthIndex, String specialDeduction, + SalaryOverrideParameters overrides) { + return SalaryCalculationInput.builder() + .grossMonthlySalary(new BigDecimal(gross)) + .monthIndex(monthIndex) + .specialAdditionalDeduction(new BigDecimal(specialDeduction)) + .overrides(overrides) + .build(); + } + + private SalaryCityPolicy policy() { + return SalaryCityPolicy.builder() + .cityCode("110000") + .provinceCode("110000") + .provinceName("北京市") + .cityName("北京市") + .policyVersion("2026-09-20-v1") + .policyEffectiveDate(LocalDate.of(2026, 7, 1)) + .socialSecurityBaseMin(new BigDecimal("5000.00")) + .socialSecurityBaseMax(new BigDecimal("20000.00")) + .housingFundBaseMin(new BigDecimal("5000.00")) + .housingFundBaseMax(new BigDecimal("20000.00")) + .pensionRate(new BigDecimal("0.080000")) + .medicalRate(new BigDecimal("0.020000")) + .unemploymentRate(new BigDecimal("0.005000")) + .housingFundRate(new BigDecimal("0.120000")) + .seriousIllnessMonthlyFee(BigDecimal.ZERO) + .dataLevel(SalaryDataLevel.CITY) + .enabled(true) + .build(); + } +} + + diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java new file mode 100644 index 0000000..48f5129 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java @@ -0,0 +1,107 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +class SalaryCityPolicyControllerTest { + + private static final AtomicLong PHONE = new AtomicLong(950_000_000L); + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + + @MockBean private RedisTemplate redisTemplate; + + @Test + void cities_returnsAllSeededCitiesForStudent() throws Exception { + long uid = createUser(User.Role.STUDENT); + + mockMvc.perform(get("/salary-calculator/cities") + .header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.data.length()").value(359)) + .andExpect(jsonPath("$.data[0].cityCode").value("110000")) + .andExpect(jsonPath("$.data[0].dataLevel").value("CITY")); + } + + @Test + void cityDetail_returnsDefaultsAndFallbackLevel() throws Exception { + long uid = createUser(User.Role.STUDENT); + + mockMvc.perform(get("/salary-calculator/cities/110000") + .header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.cityCode").value("110000")) + .andExpect(jsonPath("$.data.dataLevel").value("CITY")) + .andExpect(jsonPath("$.data.socialSecurityBaseMin").value(7270.00)) + .andExpect(jsonPath("$.data.housingFundRate").value(0.120000)); + + mockMvc.perform(get("/salary-calculator/cities/130100") + .header("Authorization", token(uid))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.dataLevel").value("PROVINCE_FALLBACK")); + } + + @Test + void missingCity_returns404And9801() throws Exception { + long uid = createUser(User.Role.STUDENT); + + mockMvc.perform(get("/salary-calculator/cities/999999") + .header("Authorization", token(uid))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(9801)); + } + + @Test + void companyUser_returns403() throws Exception { + long uid = createUser(User.Role.COMPANY); + + mockMvc.perform(get("/salary-calculator/cities") + .header("Authorization", token(uid))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + } + + @Test + void anonymous_returns401() throws Exception { + mockMvc.perform(get("/salary-calculator/cities")) + .andExpect(status().isUnauthorized()); + } + + private long createUser(User.Role role) { + User user = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(role)) + .role(role) + .active(true) + .build()); + return user.getId(); + } + + private String token(long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken( + userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicySeedTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicySeedTest.java new file mode 100644 index 0000000..2eb2959 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicySeedTest.java @@ -0,0 +1,84 @@ +package com.mydreamjob.salarycalculator; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_seed;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +class SalaryCityPolicySeedTest { + + private static final int EXPECTED_CITY_COUNT = 359; + private static final LocalDate CUTOFF_DATE = LocalDate.of(2026, 9, 20); + + private static final Set PROVINCE_DIRECT_CITY_CODES = Set.of( + "419001", "429004", "429005", "429006", + "469001", "469002", "469005", "469006", "469007", + "659001", "659002", "659003", "659004", "659005", + "659006", "659007", "659008", "659009", "659010", + "659011", "659012"); + + @Autowired + private SalaryCityPolicyRepository repository; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void seedCoversAllTargetCitiesWithLayeredDataLevels() { + List policies = repository.findAll(); + + assertThat(policies).hasSize(EXPECTED_CITY_COUNT); + assertThat(policies).extracting(SalaryCityPolicy::getCityCode).doesNotHaveDuplicates(); + assertThat(policies).extracting(SalaryCityPolicy::getCityCode) + .contains("110000", "120000", "310000", "500000"); + assertThat(policies).extracting(SalaryCityPolicy::getCityCode) + .containsAll(PROVINCE_DIRECT_CITY_CODES); + assertThat(policies).filteredOn(p -> p.getDataLevel() == SalaryDataLevel.CITY) + .hasSizeGreaterThanOrEqualTo(12); + assertThat(policies).filteredOn(p -> p.getDataLevel() == SalaryDataLevel.PROVINCE_FALLBACK) + .isNotEmpty(); + + for (SalaryCityPolicy p : policies) { + assertThat(p.getCityCode()).matches("\\d{6}"); + assertThat(p.getProvinceCode()).matches("\\d{6}"); + assertThat(p.getProvinceName()).isNotBlank(); + assertThat(p.getCityName()).isNotBlank(); + assertThat(p.getPolicyVersion()).isNotBlank(); + assertThat(p.getPolicyEffectiveDate()).isNotNull().isBeforeOrEqualTo(CUTOFF_DATE); + assertThat(p.getDataLevel()).isNotNull(); + assertThat(p.getEnabled()).isTrue(); + assertValidRange(p.getSocialSecurityBaseMin(), p.getSocialSecurityBaseMax()); + assertValidRange(p.getHousingFundBaseMin(), p.getHousingFundBaseMax()); + assertRate(p.getPensionRate(), new BigDecimal("0.20")); + assertRate(p.getMedicalRate(), new BigDecimal("0.20")); + assertRate(p.getUnemploymentRate(), new BigDecimal("0.10")); + assertRate(p.getHousingFundRate(), new BigDecimal("0.12")); + assertThat(p.getSeriousIllnessMonthlyFee()).isNotNull() + .isBetween(BigDecimal.ZERO, new BigDecimal("10000.00")); + } + } + + private void assertValidRange(BigDecimal min, BigDecimal max) { + assertThat(min).isNotNull().isGreaterThanOrEqualTo(BigDecimal.ZERO); + assertThat(max).isNotNull().isGreaterThanOrEqualTo(min); + } + + private void assertRate(BigDecimal rate, BigDecimal max) { + assertThat(rate).isNotNull().isGreaterThanOrEqualTo(BigDecimal.ZERO) + .isLessThanOrEqualTo(max); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java new file mode 100644 index 0000000..bf2fe8d --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java @@ -0,0 +1,106 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryCityDetailResponse; +import com.mydreamjob.salarycalculator.dto.SalaryCitySummaryResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SalaryCityPolicyServiceTest { + + @Mock private SalaryCityPolicyRepository cityRepository; + @Mock private UserRepository userRepository; + @InjectMocks private SalaryCityPolicyService service; + + @Test + void listCities_returnsStableEnabledSummariesForStudent() { + when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.STUDENT))); + when(cityRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc()) + .thenReturn(List.of(policy("110000", "北京市", SalaryDataLevel.CITY), + policy("130100", "石家庄市", SalaryDataLevel.PROVINCE_FALLBACK))); + + List result = service.listCities(10L); + + assertThat(result).extracting(SalaryCitySummaryResponse::getCityCode) + .containsExactly("110000", "130100"); + assertThat(result).extracting(SalaryCitySummaryResponse::getDataLevel) + .containsExactly(SalaryDataLevel.CITY, SalaryDataLevel.PROVINCE_FALLBACK); + } + + @Test + void getCity_returnsDefaultsAndDataLevel() { + when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.STUDENT))); + when(cityRepository.findByCityCodeAndEnabledTrue("110000")) + .thenReturn(Optional.of(policy("110000", "北京市", SalaryDataLevel.CITY))); + + SalaryCityDetailResponse result = service.getCity(10L, "110000"); + + assertThat(result.getCityCode()).isEqualTo("110000"); + assertThat(result.getDataLevel()).isEqualTo(SalaryDataLevel.CITY); + assertThat(result.getSocialSecurityBaseMin()).isEqualByComparingTo("5000.00"); + } + + @Test + void nonStudent_isForbidden() { + when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.COMPANY))); + + assertThatThrownBy(() -> service.listCities(10L)) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.FORBIDDEN.getCode()); + } + + @Test + void missingOrDisabledCity_returns9801() { + when(userRepository.findById(10L)).thenReturn(Optional.of(user(10L, User.Role.STUDENT))); + when(cityRepository.findByCityCodeAndEnabledTrue("999999")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getCity(10L, "999999")) + .isInstanceOf(BusinessException.class) + .extracting(ex -> ((BusinessException) ex).getErrorCodeInt()) + .isEqualTo(ErrorCode.SALARY_CITY_NOT_FOUND.getCode()); + } + + private User user(Long id, User.Role role) { + return User.builder().id(id).roles(Set.of(role)).role(role).build(); + } + + private SalaryCityPolicy policy(String code, String name, SalaryDataLevel level) { + return SalaryCityPolicy.builder() + .cityCode(code) + .provinceCode("110000") + .provinceName("测试省") + .cityName(name) + .policyVersion("2026-09-20-v1") + .policyEffectiveDate(LocalDate.of(2026, 7, 1)) + .socialSecurityBaseMin(new BigDecimal("5000.00")) + .socialSecurityBaseMax(new BigDecimal("25000.00")) + .housingFundBaseMin(new BigDecimal("2000.00")) + .housingFundBaseMax(new BigDecimal("30000.00")) + .pensionRate(new BigDecimal("0.080000")) + .medicalRate(new BigDecimal("0.020000")) + .unemploymentRate(new BigDecimal("0.005000")) + .housingFundRate(new BigDecimal("0.120000")) + .seriousIllnessMonthlyFee(BigDecimal.ZERO) + .dataLevel(level) + .enabled(true) + .build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java new file mode 100644 index 0000000..3246a49 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java @@ -0,0 +1,152 @@ +package com.mydreamjob.salarycalculator; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_migration;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +class SalaryMigrationTest { + + @Autowired + private DataSource dataSource; + + @MockBean + private RedisTemplate redisTemplate; + + @Test + void migrationCreatesSalaryPolicyAndHistoryTables() throws SQLException { + assertTableAndColumns("salary_city_policy", + "city_code", "province_code", "province_name", "city_name", "policy_version", + "policy_effective_date", "data_source", "social_security_base_min", + "social_security_base_max", "housing_fund_base_min", "housing_fund_base_max", + "pension_rate", "medical_rate", "unemployment_rate", "housing_fund_rate", + "serious_illness_monthly_fee", "data_level", "enabled", "created_at", "updated_at"); + assertTableAndColumns("salary_calculation_history", + "id", "student_user_id", "city_code", "city_name", "data_level", "policy_version", + "tax_rules_version", "month_index", "gross_monthly_salary", "net_salary", + "idempotency_key", "request_hash", "input_snapshot_json", "policy_snapshot_json", + "result_snapshot_json", "created_at"); + + assertUniqueIndex("salary_calculation_history", "student_user_id", "idempotency_key"); + assertForeignKey("salary_calculation_history", "student_user_id", "users", "id"); + assertForeignKey("salary_calculation_history", "city_code", "salary_city_policy", "city_code"); + assertIndex("salary_calculation_history", "student_user_id", "created_at", "id"); + } + + private void assertTableAndColumns(String table, String... columns) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualTable = findTable(metadata, table); + if (actualTable == null) { + throw new AssertionError("Missing table " + table); + } + List actualColumns = new ArrayList<>(); + try (ResultSet resultSet = metadata.getColumns(null, null, actualTable, null)) { + while (resultSet.next()) { + actualColumns.add(resultSet.getString("COLUMN_NAME").toLowerCase()); + } + } + for (String column : columns) { + assertTrue(actualColumns.contains(column.toLowerCase()), + "Missing column " + table + "." + column + "; actual=" + actualColumns); + } + } + } + + private void assertUniqueIndex(String table, String... columns) throws SQLException { + assertTrue(hasIndex(table, true, columns), + "Missing unique index on " + table + "(" + String.join(", ", columns) + ")"); + } + + private void assertIndex(String table, String... columns) throws SQLException { + assertTrue(hasIndex(table, false, columns), + "Missing index on " + table + "(" + String.join(", ", columns) + ")"); + } + + private boolean hasIndex(String table, boolean unique, String... expectedColumns) + throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualTable = findTable(metadata, table); + if (actualTable == null) { + return false; + } + Map> indexes = new LinkedHashMap<>(); + try (ResultSet resultSet = metadata.getIndexInfo(null, null, actualTable, false, false)) { + while (resultSet.next()) { + String indexName = resultSet.getString("INDEX_NAME"); + if (indexName == null || resultSet.getBoolean("NON_UNIQUE") == unique) { + continue; + } + indexes.computeIfAbsent(indexName, ignored -> new TreeMap<>()) + .put(resultSet.getShort("ORDINAL_POSITION"), + resultSet.getString("COLUMN_NAME").toLowerCase()); + } + } + List expected = new ArrayList<>(); + for (String column : expectedColumns) { + expected.add(column.toLowerCase()); + } + return indexes.values().stream() + .map(TreeMap::values) + .map(values -> new ArrayList<>(values)) + .anyMatch(expected::equals); + } + } + + private void assertForeignKey(String childTable, String childColumn, + String parentTable, String parentColumn) throws SQLException { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metadata = connection.getMetaData(); + String actualChildTable = findTable(metadata, childTable); + String actualParentTable = findTable(metadata, parentTable); + if (actualChildTable == null || actualParentTable == null) { + throw new AssertionError("Missing table for foreign key " + childTable + "." + childColumn); + } + try (ResultSet resultSet = metadata.getImportedKeys(null, null, actualChildTable)) { + while (resultSet.next()) { + if (actualParentTable.equalsIgnoreCase(resultSet.getString("PKTABLE_NAME")) + && childColumn.equalsIgnoreCase(resultSet.getString("FKCOLUMN_NAME")) + && parentColumn.equalsIgnoreCase(resultSet.getString("PKCOLUMN_NAME"))) { + return; + } + } + } + } + throw new AssertionError("Missing foreign key " + childTable + "." + childColumn + + " -> " + parentTable + "." + parentColumn); + } + + private String findTable(DatabaseMetaData metadata, String expected) throws SQLException { + try (ResultSet resultSet = metadata.getTables(null, null, null, new String[]{"TABLE"})) { + while (resultSet.next()) { + if (expected.equalsIgnoreCase(resultSet.getString("TABLE_NAME"))) { + return resultSet.getString("TABLE_NAME"); + } + } + } + return null; + } +} + + diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryRepositoryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryRepositoryIntegrationTest.java new file mode 100644 index 0000000..8082be8 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryRepositoryIntegrationTest.java @@ -0,0 +1,138 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_repository;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +@ActiveProfiles("integration-test") +@Transactional +class SalaryRepositoryIntegrationTest { + + private static final AtomicLong PHONE_SEQ = new AtomicLong(18100); + + @Autowired private SalaryCityPolicyRepository cityPolicyRepository; + @Autowired private SalaryCalculationHistoryRepository historyRepository; + @Autowired private UserRepository userRepository; + + @MockBean private RedisTemplate redisTemplate; + + @BeforeEach + void clearData() { + historyRepository.deleteAll(); + cityPolicyRepository.deleteAll(); + userRepository.deleteAll(); + } + + @Test + void persistsCityPolicyAndUserScopedHistory() { + SalaryCityPolicy beijing = cityPolicyRepository.save(cityPolicy("110000", "北京", "北京市")); + cityPolicyRepository.save(cityPolicy("120000", "天津", "天津市")); + Long studentId = createUser(User.Role.STUDENT, "薪资学生"); + Long otherId = createUser(User.Role.STUDENT, "其他学生"); + + SalaryCalculationHistory first = historyRepository.save(history( + studentId, beijing.getCityCode(), "北京", "key-1", new BigDecimal("10000.00"))); + SalaryCalculationHistory second = historyRepository.save(history( + studentId, beijing.getCityCode(), "北京", "key-2", new BigDecimal("12000.00"))); + + List cities = + cityPolicyRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc(); + assertThat(cities).extracting(SalaryCityPolicy::getCityCode) + .containsExactly("110000", "120000"); + + Page page = historyRepository + .findByStudentUserIdOrderByCreatedAtDescIdDesc(studentId, PageRequest.of(0, 20)); + assertThat(page.getTotalElements()).isEqualTo(2); + assertThat(page.getContent()).extracting(SalaryCalculationHistory::getId) + .containsExactly(second.getId(), first.getId()); + + assertThat(historyRepository.findByIdAndStudentUserId(first.getId(), studentId)).isPresent(); + assertThat(historyRepository.findByIdAndStudentUserId(first.getId(), otherId)).isEmpty(); + assertThat(historyRepository.findByStudentUserIdAndIdempotencyKey(studentId, "key-1")) + .isPresent(); + } + + private SalaryCityPolicy cityPolicy(String cityCode, String provinceName, String cityName) { + return SalaryCityPolicy.builder() + .cityCode(cityCode) + .provinceCode(cityCode.substring(0, 2) + "0000") + .provinceName(provinceName) + .cityName(cityName) + .dataLevel(SalaryDataLevel.CITY) + .policyVersion("2026-09-20-v1") + .policyEffectiveDate(LocalDate.of(2026, 7, 1)) + .socialSecurityBaseMin(new BigDecimal("6821.00")) + .socialSecurityBaseMax(new BigDecimal("35811.00")) + .housingFundBaseMin(new BigDecimal("2420.00")) + .housingFundBaseMax(new BigDecimal("35811.00")) + .pensionRate(new BigDecimal("0.080000")) + .medicalRate(new BigDecimal("0.020000")) + .unemploymentRate(new BigDecimal("0.002000")) + .housingFundRate(new BigDecimal("0.120000")) + .seriousIllnessMonthlyFee(new BigDecimal("3.00")) + .dataLevel(SalaryDataLevel.CITY) + .enabled(true) + .build(); + } + + private SalaryCalculationHistory history( + Long studentId, String cityCode, String cityName, String idempotencyKey, BigDecimal gross) { + return SalaryCalculationHistory.builder() + .studentUserId(studentId) + .cityCode(cityCode) + .cityName(cityName) + .dataLevel(SalaryDataLevel.CITY) + .policyVersion("2026-09-20-v1") + .taxRulesVersion("CN_IIT_2019_V1") + .monthIndex(1) + .grossMonthlySalary(gross) + .netSalary(gross.subtract(new BigDecimal("500.00"))) + .idempotencyKey(idempotencyKey) + .requestHash("a".repeat(64)) + .inputSnapshotJson("{\"grossMonthlySalary\":10000.00}") + .policySnapshotJson("{\"cityCode\":\"110000\"}") + .resultSnapshotJson("{\"netSalary\":9500.00}") + .createdAt(LocalDateTime.now()) + .build(); + } + + private Long createUser(User.Role role, String nickname) { + User user = userRepository.save(User.builder() + .phone("176" + String.format("%08d", PHONE_SEQ.incrementAndGet())) + .nickname(nickname) + .role(role) + .roles(Set.of(role)) + .active(true) + .verified(true) + .build()); + return user.getId(); + } +} + + + + + + diff --git a/scripts/verify_salary_calculator_blackbox.py b/scripts/verify_salary_calculator_blackbox.py new file mode 100644 index 0000000..2ce50bc --- /dev/null +++ b/scripts/verify_salary_calculator_blackbox.py @@ -0,0 +1,132 @@ +"""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 float(first.json()["data"]["result"]["netSalary"]) == 7667.5, 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 + diff --git a/specs/salary-calculation/spec.md b/specs/salary-calculation/spec.md new file mode 100644 index 0000000..1ccac01 --- /dev/null +++ b/specs/salary-calculation/spec.md @@ -0,0 +1,152 @@ +# salary-calculation + +## Purpose + +为已登录求职者提供基于城市当前政策、用户显式覆盖和个人综合所得累计预扣法的到手工资估算能力。 + +## Requirements + +### Requirement: 仅已登录 STUDENT 可访问薪资计算模块 + +The system SHALL 要求薪资城市查询、计算和历史接口均通过 JWT 认证,并且当前用户必须具有 STUDENT 角色。未认证请求 MUST 返回 401;已认证但非 STUDENT 的请求 MUST 返回 403。 + +#### Scenario: 未登录访问城市列表 + +- **WHEN** 客户端未携带有效访问令牌调用 `GET /salary-calculator/cities` +- **THEN** 系统返回 401,且不返回任何城市政策数据 + +#### Scenario: 企业用户调用计算接口 + +- **WHEN** 已登录的 COMPANY 用户调用 `POST /salary-calculator/calculations` +- **THEN** 系统返回 403,且不创建历史记录 + +### Requirement: 查询当前启用的城市政策目录 + +The system SHALL 提供城市列表和城市详情接口。城市列表 MUST 只返回 `enabled=true` 的城市摘要,并按省份编码、城市编码稳定排序;城市详情 MUST 返回城市元数据、政策版本、生效日期和当前整套默认参数。 + +#### Scenario: 获取全部启用城市 + +- **WHEN** STUDENT 调用 `GET /salary-calculator/cities` +- **THEN** 系统按省份编码和城市编码返回所有启用城市摘要,且不包含区县记录 + +#### Scenario: 获取城市默认参数 + +- **WHEN** STUDENT 调用 `GET /salary-calculator/cities/{cityCode}` 且该城市已启用 +- **THEN** 系统返回六位城市编码、省市名称、政策版本、生效日期、`dataLevel`、社保和公积金基数上下限、个人比例及大病固定费 + +### Requirement: 城市配置以地级行政区和省直辖县级市为最小单元 + +The system SHALL 以六位行政区划代码作为城市政策主键,覆盖中国大陆全部地级行政区、直辖市和省直辖县级市,不拆分区县,不包含港澳台。 + +#### Scenario: 直辖市使用一条配置 + +- **WHEN** 客户端查询北京市或重庆市 +- **THEN** 系统返回单一城市级配置,而不是按区县返回多条配置 + +#### Scenario: 市内存在分区差异 + +- **WHEN** 同一地级市不同区县存在政策差异 +- **THEN** 系统仍只使用该地级市的一条当前配置,并在估算声明中提示结果可能受同城差异影响 + +### Requirement: 校验城市政策版本和元数据 + +The system SHALL 要求每条城市政策具有唯一的六位编码、非空且不晚于采集截止日的生效日期、非空政策版本、合法参数以及 `dataLevel`。`CITY` 表示已核验的城市级参数;`PROVINCE_FALLBACK` 表示使用所属省级公开参数的近似配置。任何参数或生效日期变化 MUST 同步更新 `policyVersion`。 + +#### Scenario: 客户端使用已失效的政策版本计算 + +- **WHEN** STUDENT 提交的 `expectedPolicyVersion` 与当前城市版本不一致 +- **THEN** 系统返回 409/9805,响应包含最新 `policyVersion` 和 `policyEffectiveDate`,且不生成历史记录 + +#### Scenario: 城市政策种子存在非法参数 + +- **WHEN** `V41` 中存在上下限倒置、比例超限或缺失必填字段的城市记录 +- **THEN** 迁移或种子集成测试失败,禁止进入验收 + +#### Scenario: 使用省级回退参数的城市 + +- **WHEN** 某目标城市没有可核验的城市级参数,V41 使用所属省级公开参数创建记录 +- **THEN** 该记录必须标记为 PROVINCE_FALLBACK,城市详情、计算响应和历史快照都必须明确返回该精度等级 +- **THEN** 系统不得把该记录展示为 CITY 或声称其达到城市级官方精确度 + +### Requirement: 用户覆盖参数优先于城市默认值 + +The system SHALL 允许用户分别覆盖社保基数、公积金基数、养老比例、医疗比例、失业比例、公积金比例和大病固定费。未覆盖字段 MUST 使用城市默认值;显式覆盖值 MUST 通过城市上下限和全国合理性范围校验。 + +#### Scenario: 只覆盖公积金基数 + +- **WHEN** 用户提交新的公积金基数但未提交比例覆盖 +- **THEN** 系统使用用户公积金基数和城市默认公积金比例,其余项目继续使用城市默认值 + +#### Scenario: 覆盖基数超出城市范围 + +- **WHEN** 用户提交低于社保下限的社保基数 +- **THEN** 系统返回 400/9802,且不生成历史记录 + +### Requirement: 计算个人五险一金和大病固定费 + +The system SHALL 按社保基数计算个人养老、医疗和失业,按公积金基数计算个人公积金,并按城市或用户覆盖值扣除按月大病固定费。工伤和生育保险、单位缴费和企业总成本 MUST 不计入个人到手工资。 + +#### Scenario: 未覆盖任何参数 + +- **WHEN** 用户输入税前月薪且未提供覆盖项 +- **THEN** 社保基数按税前月薪截断到社保区间,公积金基数按税前月薪截断到公积金区间,各个人项目按对应比例计算 + +#### Scenario: 用户将某项比例覆盖为 0 + +- **WHEN** 用户将失业比例覆盖为 0 +- **THEN** 个人失业扣款为 0,其他项目按各自生效参数计算 + +### Requirement: 使用累计月份和专项附加扣除计算应纳税所得额 + +The system SHALL 接受 `monthIndex`(1–12)和月度专项附加扣除。`monthIndex` MUST 表示本年度当前工资口径下已经累计计税的月份数,并假设前序月份的收入、个人缴费和专项附加扣除与本次输入相同。 + +#### Scenario: 首次领薪但自然月为九月 + +- **WHEN** 用户九月首次领薪并提交 `monthIndex=1` +- **THEN** 系统按一个月的累计收入和扣除计算,而不是按九个月计算 + +#### Scenario: 累计月份为十二 + +- **WHEN** 用户提交 `monthIndex=12` +- **THEN** 系统按十二个月的相同工资、个人缴费和专项附加扣除推导全年累计口径 + +### Requirement: 按居民个人综合所得累计预扣法计算个税 + +The system SHALL 使用全国统一的居民个人综合所得年度累进预扣税率表和速算扣除数。基本减除费用 MUST 固定为每月 5,000 元,累计应纳税所得额小于等于 0 时 MUST 按 0 计税,当月税额 MUST 不得为负。 + +#### Scenario: 累计应纳税所得额低于起征线 + +- **WHEN** 累计收入扣除个人缴费、专项附加扣除和 5,000 × monthIndex 后不大于 0 +- **THEN** 累计应纳税额为 0,当月个税为 0 + +#### Scenario: 月薪进入更高累计税率档 + +- **WHEN** 累计应纳税所得额超过当前档位上限 +- **THEN** 系统按完整年度累进税率表计算累计应纳税额,并减去按相同假设推导的累计已预扣税额 + +### Requirement: 输出到手工资和完整计算拆解 + +The system SHALL 返回税前月薪、城市及政策版本、社保基数、公积金基数、个人养老、个人医疗、个人失业、个人公积金、大病固定费、基本减除费用、专项附加扣除、累计月份、累计与当月计税链路、当月个税和到手工资。响应 MUST 标记 `estimate=true`。 + +#### Scenario: 计算成功 + +- **WHEN** STUDENT 提交合法的城市、政策版本和工资参数 +- **THEN** 系统返回完整拆解、`estimate=true`、全国税率版本、城市政策版本与生效日期,并返回可访问的历史 ID + +### Requirement: 校验工资输入并限制金额精度 + +The system SHALL 要求税前月薪大于 0 且不超过 10,000,000 元,专项附加扣除在 0–10,000,000 元之间,所有用户金额输入最多两位小数。比例 MUST 使用小数表示,且个人上限为养老 20%、医疗 20%、失业 10%、公积金 12%;大病固定费不得超过 10,000 元/月。 + +#### Scenario: 金额超过两位小数 + +- **WHEN** 用户提交 `0.001` 元的金额字段 +- **THEN** 系统返回 400/9802,且不执行计算 + +### Requirement: 返回稳定的城市和历史错误语义 + +The system SHALL 使用 `400` 表示参数非法,`404` 表示城市或历史不存在,`409` 表示幂等或政策版本冲突。`9801` MUST 表示城市不存在或停用,`9802` MUST 表示参数非法,`9803` MUST 表示历史不存在或无权访问,`9804` MUST 表示幂等冲突,`9805` MUST 表示政策版本冲突。 + +#### Scenario: 城市停用 + +- **WHEN** 用户提交已停用城市的计算请求 +- **THEN** 系统返回 404/9801,且不生成历史记录 diff --git a/specs/salary-history/spec.md b/specs/salary-history/spec.md new file mode 100644 index 0000000..f1ecf3e --- /dev/null +++ b/specs/salary-history/spec.md @@ -0,0 +1,126 @@ +# salary-history + +## Purpose + +保存求职者每次成功薪资估算的可追溯快照,并提供仅限本人访问的分页查询、详情和物理删除能力。 + +## Requirements + +### Requirement: 成功计算自动保存历史 + +The system SHALL 在计算并保存接口成功完成时自动创建一条历史记录,并在响应中返回历史 ID 和创建时间。系统 MUST NOT 要求调用方再调用第二个保存接口。 + +#### Scenario: 首次成功计算 + +- **WHEN** STUDENT 提交合法且政策版本一致的计算请求 +- **THEN** 系统在同一事务中完成计算并保存历史,返回结果及新历史 ID + +### Requirement: 历史保存完整且可重建的快照 + +The system SHALL 在历史记录中保存原始输入、城市编码与名称、`dataLevel`、城市默认参数、用户覆盖项、最终生效参数、完整结果、`monthIndex`、城市政策版本、政策生效日期、全国税率版本和创建时间。三类 JSON 快照 MUST 能独立重建当时结果。 + +#### Scenario: 城市参数后来变化 + +- **WHEN** 历史创建后城市默认参数或政策版本发生变化 +- **THEN** 历史详情仍返回创建时的输入、政策、覆盖项和完整结果,不重新读取当前城市配置,包括当时的 `CITY` 或 `PROVINCE_FALLBACK` 精度标识 + +### Requirement: 计算保存使用幂等键防重 + +The system SHALL 支持可选 `idempotencyKey`,最长 64 字符,空白值按未提供处理。同一用户相同幂等键且规范化业务字段相同 MUST 返回原历史;相同幂等键但规范化字段不同 MUST 返回 409/9804。 + +#### Scenario: 相同请求网络重试 + +- **WHEN** 客户端使用相同用户、相同幂等键和相同规范化业务字段重试计算 +- **THEN** 系统返回同一条历史和相同计算结果,不新增数据库行 + +#### Scenario: 相同幂等键不同请求体 + +- **WHEN** 客户端复用幂等键但修改税前月薪或政策版本 +- **THEN** 系统返回 409/9804,且不创建第二条历史 + +### Requirement: 同一幂等键并发只能生成一条历史 + +The system SHALL 使用事务和数据库唯一约束处理并发。相同用户相同幂等键并发请求 MUST 最多生成一条历史;同键不同请求并发时,一方成功,另一方 MUST 返回 409/9804。 + +#### Scenario: 并发相同请求 + +- **WHEN** 两个请求同时提交相同用户、幂等键和规范化业务字段 +- **THEN** 数据库中最多存在一条历史,两个成功响应指向同一历史 ID + +### Requirement: 历史列表和详情仅返回本人数据 + +The system SHALL 提供按创建时间倒序、ID 倒序的分页列表和详情接口。列表 MUST 只返回摘要;详情 MUST 返回完整快照。查询必须同时按当前登录用户和历史 ID 过滤。 + +#### Scenario: 查询本人历史 + +- **WHEN** STUDENT 调用历史列表或详情接口 +- **THEN** 系统只返回该 STUDENT 的历史,按最新创建时间排序 + +#### Scenario: 猜测他人历史 ID + +- **WHEN** STUDENT 请求不属于自己的历史 ID +- **THEN** 系统返回 404/9803,不泄露该记录是否存在 + +### Requirement: 历史不可编辑且可以物理删除单条 + +The system SHALL 不提供历史更新接口。历史创建后不可修改;用户删除本人历史时 MUST 物理删除该行及其幂等键,后续可以为该幂等键创建新历史。 + +#### Scenario: 删除本人历史 + +- **WHEN** STUDENT 删除自己的历史记录 +- **THEN** 系统物理删除该记录并返回成功响应,后续详情查询返回 404/9803 + +#### Scenario: 重复删除历史 + +- **WHEN** STUDENT 再次删除已经删除的历史 +- **THEN** 系统返回 404/9803,不返回成功状态 + +### Requirement: 历史列表采用统一分页且不提供额外筛选 + +The system SHALL 使用 `page=0`、`size=20` 的分页默认值,`size` 最大为 100;首期 MUST NOT 增加城市、日期范围或薪资范围筛选。 + +#### Scenario: 超出分页上限 + +- **WHEN** 客户端请求 `size=101` +- **THEN** 系统返回 400/9802,不返回历史列表 + +### Requirement: 历史数据按用户长期保留 + +The system SHALL 允许历史记录长期保留直至用户主动删除,不设置自动过期、定时清理或业务数量配额。城市停用、政策更新或历史数量增长 MUST NOT 影响历史详情读取。 + +#### Scenario: 城市停用后查看旧历史 + +- **WHEN** 历史所属城市后来被停用 +- **THEN** STUDENT 仍可查看该历史详情 + +### Requirement: 历史不进入审计日志或个人数据导出 + +The system SHALL 不因薪资计算或单条历史删除写入业务审计日志,也不得把税前工资、基数、税额、到手工资或 JSON 快照写入应用日志。薪资历史 MUST NOT 纳入现有个人数据导出 ZIP。 + +#### Scenario: 计算成功后的日志检查 + +- **WHEN** 系统完成一次薪资计算和历史保存 +- **THEN** 日志仅包含请求 ID、用户 ID、历史 ID、城市编码、状态和耗时,不包含金额或参数快照 + +### Requirement: 账号注销清理薪资历史 + +The system SHALL 在账号注销完成事务中物理删除该用户全部薪资历史。注销处于 `PENDING` 时 MUST 禁止计算和删除写入,`COMPLETED` 时所有薪资接口 MUST 返回 409/9303。 + +#### Scenario: 注销完成后清理历史 + +- **WHEN** 用户账号注销完成 +- **THEN** 数据库中不存在该用户的 `salary_calculation_history` 行 + +#### Scenario: 注销冷静期尝试计算 + +- **WHEN** 用户处于 `PENDING` 状态调用计算接口 +- **THEN** 现有注销守卫返回 409/9302,且不创建历史 + +### Requirement: 数据库迁移保证城市政策和历史完整性 + +The system SHALL 通过 `V40` 创建 `salary_city_policy` 与 `salary_calculation_history`,通过 `V41` 写入目标城市种子。城市编码 MUST 唯一,历史幂等键 MUST 按用户唯一,历史快照 MUST 保留且不依赖后续城市更新。 + +#### Scenario: 迁移初始化 + +- **WHEN** 在 MySQL 或 H2 MySQL 模式执行 `V40` 和 `V41` +- **THEN** 城市政策表、历史表、主外键、唯一键和索引创建成功,目标城市种子导入成功