diff --git a/changes/n05-salary-calc/.spec-superflow.yaml b/changes/n05-salary-calc/.spec-superflow.yaml new file mode 100644 index 0000000..d3e6c61 --- /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: exploring +workflow: auto +revision: null + +# === Hashes (fast staleness detection) === +artifacts_hash: null +contract_hash: null + +# === Execution progress === +execution_mode: null +execution_plan_hash: null +execution_plan_revision: null +batches_completed: 0 +test_result: null +spec_merged: false +spec_publication_receipt: null + +# === Metadata === +change_name: n05-salary-calc +last_transition: 2026-09-20T07:28:30.273Z +last_transition_from: null +last_transition_to: null + +# === Decision points === +dp_0_decisions: null +dp_0_result: null +dp_0_confirmed: null +dp_0_timestamp: null +dp_1_result: null +dp_1_timestamp: null +dp_2_result: null +dp_2_timestamp: null +dp_3_result: null +dp_3_timestamp: null +dp_4_result: null +dp_4_timestamp: null +dp_5_result: null +dp_5_timestamp: null +dp_5_decisions: null +dp_5_confirmed: null +dp_6_result: null +dp_6_timestamp: null +dp_7_result: null +dp_7_timestamp: null 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..731820a --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/execution-plan.json @@ -0,0 +1,155 @@ +{ + "mode": "sdd", + "source": "user-confirmed", + "rationale": "用户确认采用 SDD;按 W1-W4 串行执行并以 review receipt 控制依赖", + "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:1f79edb313023fb4cb069ee751d14107c3a770b840f580cfdb8eb411d42e0eb1", + "contract_hash": "sha256:ce264c979b71f9f9b4d95881dd1f3fd8a1d62c3d848fd508e26cc5ce5b76d3c0", + "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:1f79edb313023fb4cb069ee751d14107c3a770b840f580cfdb8eb411d42e0eb1", + "contract_hash": "sha256:ce264c979b71f9f9b4d95881dd1f3fd8a1d62c3d848fd508e26cc5ce5b76d3c0", + "workflow": "full", + "execution_plan_revision_at_recommendation": null, + "created_at": "2026-09-20T07:51:06.272Z", + "hash": "sha256:493fc777a6cd5437d94a6b034432cc1d9379156bba780d40baf954e56c447deb" + }, + "selection": { + "confirmed": true, + "followed_recommendation": true, + "acknowledged_non_recommendation": false + }, + "hash": "sha256:bae5d637af45064d6ed6290c047ac7ce5e9304d36d509952e2aa81159a357f79" +} 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..1f710f6 --- /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:1f79edb313023fb4cb069ee751d14107c3a770b840f580cfdb8eb411d42e0eb1", + "contract_hash": null, + "workflow": "full", + "execution_plan_revision_at_recommendation": null, + "created_at": "2026-09-20T07:37:33.952Z", + "hash": "sha256:94a0df29fc6848ea7aa5c978fdbbcf7543590e84886acc40bb833b702de785f9" +} 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..b1d74b8 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/progress.md @@ -0,0 +1,10 @@ +# n05-salary-calc 执行进度 + +## Task 1 - V40 表结构和薪资领域持久化基础 + +- 状态:完成待提交 +- 变更:新增 `salary_city_policy`、`salary_calculation_history`、实体、Repository、迁移测试和持久化测试。 +- 验证:`mvn -Dtest=SalaryMigrationTest,SalaryRepositoryIntegrationTest test`,2/2 通过。 +- 自检:表、列、唯一键、外键、索引和用户隔离查询符合 Task 1 完成标准。 +- 风险:无。 + 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/design.md b/changes/n05-salary-calc/design.md new file mode 100644 index 0000000..34ff5a0 --- /dev/null +++ b/changes/n05-salary-calc/design.md @@ -0,0 +1,104 @@ +# 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` 写入按官方公开资料整理的目标城市政策;采集截止日为实际开始整理数据的日期。种子发布前执行目标行政区全覆盖、编码唯一、字段非空、上下限正确、比例合法、生效日期不晚于截止日、版本号非空测试。 +- **Rationale**:数据量和维护频率较小,迁移是可审查、可版本化的交付单元;全量覆盖是产品可使用的必要条件。 +- **Alternatives**:运行时调用第三方接口;只在测试环境维护示例城市。 + - 第三方接口存在授权、稳定性和网络依赖;示例数据无法满足全国估算目标。 +- **Consequences**:政策更新必须新增迁移,不能静默覆盖历史种子;来源字段可选,但数据整理仍需以官方公开资料为依据。 + +## 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..242a92d --- /dev/null +++ b/changes/n05-salary-calc/execution-contract.md @@ -0,0 +1,134 @@ +# n05-salary-calc 执行合同 + +## Intent Lock + +- **变更名称**:`n05-salary-calc` +- **要解决的问题**:当前后端没有城市职工社保、公积金、大病固定费和到手工资估算能力,求职者无法基于地级行政区默认参数、个人覆盖项和累计预扣法获得可复核的结果,也无法保存独立的历史快照。 +- **范围内**:新增仅限登录 `STUDENT` 的 `/salary-calculator/**` 后端模块;新增 `V40/V41` 城市政策和历史表;实现城市参数、用户覆盖、工资计算、累计预扣、历史保存/查询/物理删除、政策版本校验、幂等并发、账号注销清理、OpenAPI 和测试。 +- **范围外**:前端页面、ADMIN 政策维护、企业成本、年终奖/股权/多收入来源、年度汇算、非居民税制、薪资参考、Offer 关联、个人数据导出和港澳台政策。 + +## Approved Behavior + +- **已批准需求摘要**:`specs/salary-calculation/spec.md` 与 `specs/salary-history/spec.md` 中的 22 条 `### Requirement` 全部批准,覆盖: + - STUDENT-only 权限和六个接口。 + - 城市政策目录、地级行政区粒度、版本元数据和全量种子。 + - 用户覆盖优先、基数截断、个人五险一金、大病固定费。 + - 累计月份、专项附加扣除、居民个人综合所得累计预扣、到手工资和完整结果拆解。 + - 输入范围、金额精度和 `9801–9805` 错误语义。 + - 自动保存历史、完整可重建快照、幂等并发、个人隔离、分页、不可编辑、物理删除和长期保留。 + - 历史不进业务审计日志或数据导出,账号注销时物理清理。 + - `V40/V41` 迁移、全量城市覆盖、唯一键、索引和完整性约束。 +- **关键场景**:城市政策版本冲突不生成历史;覆盖基数超出城市范围返回 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`;城市编码唯一,历史用户+幂等键唯一;输入、最终参数和结果 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` 表结构、领域持久化基础、`V41` 城市政策种子及全量种子校验。 +- **输入**:`V39` 现状、官方行政区划清单、公开城市政策资料、实体/Repository 约定。 +- **输出**:两张业务表、实体、Repository、城市种子和迁移/种子测试。 +- **完成标准**:H2/MySQL 迁移成功;目标城市全覆盖、编码唯一、字段合法、日期不晚于采集截止日、版本号非空。 +- **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..9b7498a --- /dev/null +++ b/changes/n05-salary-calc/proposal.md @@ -0,0 +1,68 @@ +# 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` 写入按官方公开资料整理的全量城市政策种子。 +- 城市政策以六位行政区划代码为主键,覆盖大陆全部地级行政区、直辖市和省直辖县级市;同城不拆分到区县。 +- 计算支持城市默认值、用户覆盖项、社保与公积金基数截断、个人养老/医疗/失业/公积金、大病固定费、专项附加扣除、累计月份和居民个人综合所得累计预扣。 +- 历史记录保存输入、城市默认参数、用户覆盖项、最终生效参数、完整结果、城市政策版本、全国税率版本和创建时间;历史不可编辑,支持分页列表、详情和物理删除单条。 +- 计算接口支持可选 `idempotencyKey` 和服务端请求指纹去重,并要求 `expectedPolicyVersion` 与当前城市政策版本一致。 +- 新增 `9801–9805` 错误码,覆盖城市不存在、参数非法、历史不存在、幂等冲突和政策版本冲突。 +- 薪资历史不写入业务审计日志、不进入个人数据导出;账号注销完成时物理删除该用户全部薪资历史。 + +## Scope + +### In Scope + +- 中国大陆地级行政区、直辖市和省直辖县级市的城市政策配置、启用状态、政策版本与生效日期。 +- 城市列表、城市详情和当前默认参数查询。 +- 税前月薪、累计月份、专项附加扣除和用户覆盖项驱动的到手工资估算。 +- 居民个人综合所得累计预扣、个人五险一金、公积金和大病固定费计算。 +- 计算历史的快照保存、分页查询、详情、物理删除和账号注销清理。 +- `STUDENT` 角色、JWT 身份、用户数据隔离、政策版本冲突和幂等并发处理。 +- `V40/V41` Flyway 迁移、H2 集成测试、真实 MySQL/Docker 黑盒验收、OpenAPI 和前端联调文档。 + +### Out of Scope + +- 前端页面、交互设计、客户端状态管理和支付/工资单集成。 +- 企业用工成本、单位社保和单位公积金视角。 +- 年终奖、13 薪、绩效、提成、股票、期权、副业或多单位收入。 +- 入职、离职、请假导致的天级折算,非居民个人计税和年度汇算清缴。 +- 个税专项附加扣除的项目级明细、家庭成员和年度限额管理。 +- 行业薪资参考、岗位薪资排名、薪资分布和 Offer 综合比较。 +- ADMIN 城市政策维护界面、外部实时政策 API 和自动抓取服务。 +- 个人数据导出中的薪资历史章节。 +- 港澳台地区和其他不适用内地社保、公积金及税制的地区。 + +## 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..bdda678 --- /dev/null +++ b/changes/n05-salary-calc/specs/salary-calculation/spec.md @@ -0,0 +1,146 @@ +# 薪资计算能力规格 + +## 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** 系统返回六位城市编码、省市名称、政策版本、生效日期、社保和公积金基数上下限、个人比例及大病固定费 + +### Requirement: 城市配置以地级行政区和省直辖县级市为最小单元 + +The system SHALL 以六位行政区划代码作为城市政策主键,覆盖中国大陆全部地级行政区、直辖市和省直辖县级市,不拆分区县,不包含港澳台。 + +#### Scenario: 直辖市使用一条配置 + +- **WHEN** 客户端查询北京市或重庆市 +- **THEN** 系统返回单一城市级配置,而不是按区县返回多条配置 + +#### Scenario: 市内存在分区差异 + +- **WHEN** 同一地级市不同区县存在政策差异 +- **THEN** 系统仍只使用该地级市的一条当前配置,并在估算声明中提示结果可能受同城差异影响 + +### Requirement: 校验城市政策版本和元数据 + +The system SHALL 要求每条城市政策具有唯一的六位编码、非空且不晚于采集截止日的生效日期、非空政策版本及合法参数。任何参数或生效日期变化 MUST 同步更新 `policyVersion`。 + +#### Scenario: 客户端使用已失效的政策版本计算 + +- **WHEN** STUDENT 提交的 `expectedPolicyVersion` 与当前城市版本不一致 +- **THEN** 系统返回 409/9805,响应包含最新 `policyVersion` 和 `policyEffectiveDate`,且不生成历史记录 + +#### Scenario: 城市政策种子存在非法参数 + +- **WHEN** `V41` 中存在上下限倒置、比例超限或缺失必填字段的城市记录 +- **THEN** 迁移或种子集成测试失败,禁止进入验收 + +### 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..364351e --- /dev/null +++ b/changes/n05-salary-calc/specs/salary-history/spec.md @@ -0,0 +1,126 @@ +# 薪资历史能力规格 + +## Purpose + +保存求职者每次成功薪资估算的可追溯快照,并提供仅限本人访问的分页查询、详情和物理删除能力。 + +## ADDED Requirements + +### Requirement: 成功计算自动保存历史 + +The system SHALL 在计算并保存接口成功完成时自动创建一条历史记录,并在响应中返回历史 ID 和创建时间。系统 MUST NOT 要求调用方再调用第二个保存接口。 + +#### Scenario: 首次成功计算 + +- **WHEN** STUDENT 提交合法且政策版本一致的计算请求 +- **THEN** 系统在同一事务中完成计算并保存历史,返回结果及新历史 ID + +### Requirement: 历史保存完整且可重建的快照 + +The system SHALL 在历史记录中保存原始输入、城市编码与名称、城市默认参数、用户覆盖项、最终生效参数、完整结果、`monthIndex`、城市政策版本、政策生效日期、全国税率版本和创建时间。三类 JSON 快照 MUST 能独立重建当时结果。 + +#### Scenario: 城市参数后来变化 + +- **WHEN** 历史创建后城市默认参数或政策版本发生变化 +- **THEN** 历史详情仍返回创建时的输入、政策、覆盖项和完整结果,不重新读取当前城市配置 + +### 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..f659b47 --- /dev/null +++ b/changes/n05-salary-calc/tasks.md @@ -0,0 +1,55 @@ +# 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 + +- [ ] **1. 新增 V40 表结构和薪资领域持久化基础**:修改 `mydreamjob-server/src/main/resources/db/migration/V40__salary_calculator_schema.sql`,新增城市政策与历史表、索引和唯一约束;在 `mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/` 新增实体和 Repository。完成后 H2/MySQL 可创建两张表,并能按城市编码和用户历史分页查询。证明:`cd mydreamjob-server; mvn -Dtest=SalaryMigrationTest,SalaryRepositoryIntegrationTest test`。依赖:无。 +- [ ] **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。 +- [ ] **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。 +- [ ] **4. 实现 BigDecimal 薪资计算引擎**:在 `mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/` 新增计算器、税率表、输入/结果 DTO 和校验器。完成后基数截断、覆盖优先、个人缴费、大病费用、累计月份、专项附加扣除、累计预扣、税额下限和两位小数结果均有精确单元测试。证明:`cd mydreamjob-server; mvn -Dtest=SalaryCalculationEngineTest test`。依赖:1。 +- [ ] **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。 +- [ ] **6. 实现历史列表、详情和物理删除**:在 `mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/` 新增历史 Service、Controller 和响应 DTO。完成后本人历史上限分页、摘要列表、完整详情、物理删除和跨用户 404 隔离均可验证,城市停用不影响旧历史。证明:`cd mydreamjob-server; mvn -Dtest=SalaryHistoryServiceTest,SalaryHistoryControllerTest,SalaryHistoryPrivacyTest test`。依赖:5。 +- [ ] **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。 +- [ ] **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。 +- [ ] **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/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..fb9cf1b --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java @@ -0,0 +1,80 @@ +package com.mydreamjob.salarycalculator; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import 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; + + @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/SalaryCityPolicy.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicy.java new file mode 100644 index 0000000..91f16d5 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicy.java @@ -0,0 +1,90 @@ +package com.mydreamjob.salarycalculator; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +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; + + @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/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/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..66012c0 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V40__salary_calculator_schema.sql @@ -0,0 +1,66 @@ +-- 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 '大病固定费/月', + 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 + ), + 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 '城市名称快照', + 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/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java new file mode 100644 index 0000000..8b26d86 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java @@ -0,0 +1,150 @@ +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", "enabled", "created_at", "updated_at"); + assertTableAndColumns("salary_calculation_history", + "id", "student_user_id", "city_code", "city_name", "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..70586eb --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryRepositoryIntegrationTest.java @@ -0,0 +1,129 @@ +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) + .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")) + .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) + .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(); + } +}