# 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`。