Browse Source

feat: add salary city APIs and calculation engine

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
4b42a624d7
  1. 2
      changes/n05-salary-calc/.spec-superflow.yaml
  2. 37
      changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/2.md
  3. 10
      changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzE.json
  4. 23
      changes/n05-salary-calc/.superpowers/sdd/progress.md
  5. 7
      changes/n05-salary-calc/.superpowers/sdd/reviews/T3.md
  6. 7
      changes/n05-salary-calc/.superpowers/sdd/reviews/T4.md
  7. 10
      changes/n05-salary-calc/.superpowers/sdd/reviews/VzE.json
  8. 8
      changes/n05-salary-calc/.superpowers/sdd/reviews/W2.md
  9. 10
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  10. 12
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  11. 3
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  12. 262
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationEngine.java
  13. 19
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationInput.java
  14. 53
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationResult.java
  15. 64
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyController.java
  16. 81
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCityPolicyService.java
  17. 22
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryOverrideParameters.java
  18. 34
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCityDetailResponse.java
  19. 23
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCitySummaryResponse.java
  20. 150
      mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEngineTest.java
  21. 107
      mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyControllerTest.java
  22. 106
      mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCityPolicyServiceTest.java

2
changes/n05-salary-calc/.spec-superflow.yaml

@ -14,7 +14,7 @@ contract_hash: sha256:c85abc5d5ddd79b03da8ff42816436f4389bb99a5739d4fcb2a960c3c8
execution_mode: sdd
execution_plan_hash: sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5
execution_plan_revision: 1
batches_completed: 0
batches_completed: 1
test_result: null
spec_merged: false
spec_publication_receipt: null

37
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

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

23
changes/n05-salary-calc/.superpowers/sdd/progress.md

@ -3,14 +3,29 @@
## Task 1 - V40 表结构和薪资领域持久化基础
- 状态:完成
- 变更:新增 `salary_city_policy`、`salary_calculation_history`、`data_level`、实体、Repository、迁移与持久化测试。
- 验证:`mvn -Dtest=SalaryMigrationTest,SalaryRepositoryIntegrationTest test`,2/2 通过。
- 提交:`837c0632c66e6cb9a00e522944f73045258557d6`。
- 风险:无。
## Task 2 - V41 分层城市政策种子
- 状态:完成
- 变更:新增 359 条目标城市政策;12 条 `CITY`,347 条 `PROVINCE_FALLBACK`;覆盖 31 个大陆省级行政区、直辖市和省直辖县级市。
- 结果:359 条目标城市政策;12 条 `CITY`,347 条 `PROVINCE_FALLBACK`。
- 验证:`mvn -Dtest=SalaryCityPolicySeedTest,SalaryMigrationTest,SalaryRepositoryIntegrationTest test`,3/3 通过。
- 风险:347 条回退记录使用省级基线,必须在接口和前端明确展示 `PROVINCE_FALLBACK`;后续城市级核验需新迁移。
- 提交:`2d315f90e46982e9658da6483f46e2acbe21bcca`。
## Task 3 - 城市政策查询接口
- 状态:完成
- 结果:`GET /salary-calculator/cities` 与 `GET /salary-calculator/cities/{cityCode}`;仅 STUDENT;返回 `dataLevel`。
- 验证:`mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest test`,9/9 通过。
## Task 4 - BigDecimal 薪资计算引擎
- 状态:完成
- 结果:基数截断、覆盖、五险一金、大病费、累计月份、专项扣除、累计预扣、税档和错误码。
- 验证:`mvn -Dtest=SalaryCalculationEngineTest test`,6/6 通过。
## Wave W2
- 状态:完成待 review
- 证据:`mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest,SalaryCalculationEngineTest test`,15/15 通过。

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

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

10
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:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5",
"plan_revision": 1,
"recorded_at": "2026-09-20T08:23:35.131Z"
}

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

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

12
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java

@ -77,7 +77,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 +171,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())
@ -255,3 +259,7 @@ public class GlobalExceptionHandler {
return request.getHeader("X-Request-Id");
}
}

3
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();
}
}

262
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<TaxBracket> 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) {
}
}

19
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;
}

53
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;
}

64
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<ApiResponse<List<SalaryCitySummaryResponse>>> 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<ApiResponse<SalaryCityDetailResponse>> 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");
}
}

81
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<SalaryCitySummaryResponse> 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);
}
}
}

22
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;
}

34
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;
}

23
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;
}

150
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();
}
}

107
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<String, Object> 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"));
}
}

106
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<SalaryCitySummaryResponse> 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();
}
}
Loading…
Cancel
Save