Browse Source

feat: persist salary calculations and histories

backup/master-local-20261012
pwklppg 1 month ago
parent
commit
27dbc095e0
  1. 2
      changes/n05-salary-calc/.spec-superflow.yaml
  2. 37
      changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/3.md
  3. 37
      changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/4.md
  4. 10
      changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzI.json
  5. 41
      changes/n05-salary-calc/.superpowers/sdd/progress.md
  6. 7
      changes/n05-salary-calc/.superpowers/sdd/reviews/T5.md
  7. 7
      changes/n05-salary-calc/.superpowers/sdd/reviews/T6.md
  8. 7
      changes/n05-salary-calc/.superpowers/sdd/reviews/T7.md
  9. 10
      changes/n05-salary-calc/.superpowers/sdd/reviews/VzI.json
  10. 9
      changes/n05-salary-calc/.superpowers/sdd/reviews/W3.md
  11. 16
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java
  12. 4
      mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java
  13. 91
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java
  14. 7
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java
  15. 190
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java
  16. 18
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryCleanupService.java
  17. 106
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryService.java
  18. 23
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyVersionConflictException.java
  19. 68
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryRequestFingerprint.java
  20. 53
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationOverrideRequest.java
  21. 51
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationRequest.java
  22. 19
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationResponse.java
  23. 31
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistoryDetailResponse.java
  24. 25
      mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistorySummaryResponse.java
  25. 2
      mydreamjob-server/src/main/resources/db/migration/V40__salary_calculator_schema.sql
  26. 81
      mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAccountDeletionIntegrationTest.java
  27. 82
      mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAuthorizationIntegrationTest.java
  28. 168
      mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java
  29. 3
      mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java
  30. 1
      mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryRepositoryIntegrationTest.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: 1
batches_completed: 2
test_result: null
spec_merged: false
spec_publication_receipt: null

37
changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/3.md

@ -0,0 +1,37 @@
---
task_id: "3"
task_hash: "sha256:b5aaa1f3ea977fa289582c0e7e29adc5990fc4c2f46a5e64c6f142b391dec368"
next: "Task 4: implement BigDecimal salary calculation engine"
completed: "City list/detail APIs with STUDENT authorization and dataLevel"
evidence: "mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest test: 9/9 passed"
review: ".superpowers/sdd/reviews/T3.md"
risk: "Fallback city detail must remain visibly marked"
commit_start: "2d315f90e46982e9658da6483f46e2acbe21bcca"
commit_end: "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7"
created_at: "2026-09-20T08:29:09.441Z"
plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5"
plan_revision: 1
---
# Checkpoint: 3
## Next
Task 4: implement BigDecimal salary calculation engine
## Completed
City list/detail APIs with STUDENT authorization and dataLevel
## Evidence
mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest test: 9/9 passed
## Review
.superpowers/sdd/reviews/T3.md
## Risk
Fallback city detail must remain visibly marked
## Commit Start
2d315f90e46982e9658da6483f46e2acbe21bcca
## Commit End
4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7

37
changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/4.md

@ -0,0 +1,37 @@
---
task_id: "4"
task_hash: "sha256:d571027295959410fd431fe86fc37d769aa15e71d4e14fcb4e5c624b8960c193"
next: "Wave W3 task 5: calculation persistence and policy version validation"
completed: "BigDecimal calculation engine with cumulative withholding and override validation"
evidence: "mvn -Dtest=SalaryCalculationEngineTest test: 6/6 passed"
review: ".superpowers/sdd/reviews/T4.md"
risk: "Tax anchor cases will be expanded in W4"
commit_start: "2d315f90e46982e9658da6483f46e2acbe21bcca"
commit_end: "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7"
created_at: "2026-09-20T08:29:09.537Z"
plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5"
plan_revision: 1
---
# Checkpoint: 4
## Next
Wave W3 task 5: calculation persistence and policy version validation
## Completed
BigDecimal calculation engine with cumulative withholding and override validation
## Evidence
mvn -Dtest=SalaryCalculationEngineTest test: 6/6 passed
## Review
.superpowers/sdd/reviews/T4.md
## Risk
Tax anchor cases will be expanded in W4
## Commit Start
2d315f90e46982e9658da6483f46e2acbe21bcca
## Commit End
4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7

10
changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzI.json

@ -0,0 +1,10 @@
{
"status": "pass",
"base": "2d315f90e46982e9658da6483f46e2acbe21bcca",
"head": "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7",
"report": ".superpowers\\sdd\\reviews\\W2.md",
"report_sha256": "sha256:2e4ac6255d2c08931102f75b414b24552c6fe2272b3bc139a91e8c4c77215bbe",
"plan_hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5",
"plan_revision": 1,
"recorded_at": "2026-09-20T08:29:10.010Z"
}

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

@ -1,31 +1,14 @@
# n05-salary-calc 执行进度
## Task 1 - V40 表结构和薪资领域持久化基础
- 状态:完成
- 验证:`mvn -Dtest=SalaryMigrationTest,SalaryRepositoryIntegrationTest test`,2/2 通过。
- 提交:`837c0632c66e6cb9a00e522944f73045258557d6`。
## Task 2 - V41 分层城市政策种子
- 状态:完成
- 结果:359 条目标城市政策;12 条 `CITY`,347 条 `PROVINCE_FALLBACK`。
- 验证:`mvn -Dtest=SalaryCityPolicySeedTest,SalaryMigrationTest,SalaryRepositoryIntegrationTest test`,3/3 通过。
- 提交:`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 通过。
## W1 - 数据基础:完成
- V40、实体、Repository、359 城分层 V41、迁移和种子测试;3/3 通过。
- W1 review receipt:pass。
## W2 - 城市查询与计算引擎:完成
- 城市列表/详情、STUDENT 权限、dataLevel 和 BigDecimal 计算引擎;15/15 通过。
- W2 review receipt:pass。
## W3 - 历史、幂等、权限与注销:完成待 review
- 计算并保存、政策版本冲突、幂等重放/冲突、历史列表/详情/物理删除、跨用户隔离和账号注销清理。
- 验证:`mvn -Dtest=SalaryAuthorizationIntegrationTest,SalaryCalculationEndToEndIntegrationTest,SalaryAccountDeletionIntegrationTest test`,6/6 通过。
- 隐私:薪资包无直接日志写入;API 不返回 requestHash、idempotencyKey 或 studentUserId。

7
changes/n05-salary-calc/.superpowers/sdd/reviews/T5.md

@ -0,0 +1,7 @@
# Task 5 self-review
- Verdict: pass
- Scope: calculate-and-save API, policy version validation, normalized request fingerprint and idempotent replay.
- Evidence: `SalaryCalculationEndToEndIntegrationTest` covers success, replay, same-key conflict, stale policy and invalid override. W3 suite 6/6 passed.
- Contract check: no history on version conflict; replay returns same history; structured 9805 data includes latest version/date.
- Blockers: none.

7
changes/n05-salary-calc/.superpowers/sdd/reviews/T6.md

@ -0,0 +1,7 @@
# Task 6 self-review
- Verdict: pass
- Scope: history list/detail/physical delete, pagination, user isolation and snapshot JSON.
- Evidence: `SalaryCalculationEndToEndIntegrationTest` covers list, detail, cross-user 404 and physical delete; W3 suite 6/6 passed.
- Contract check: list sorted by repository query; details return input/policy/result snapshots; deleted rows are absent.
- Blockers: none.

7
changes/n05-salary-calc/.superpowers/sdd/reviews/T7.md

@ -0,0 +1,7 @@
# Task 7 self-review
- Verdict: pass
- Scope: STUDENT authorization, privacy boundaries and account-deletion purge.
- Evidence: `SalaryAuthorizationIntegrationTest` and `SalaryAccountDeletionIntegrationTest`; W3 suite 6/6 passed.
- Privacy check: no direct logger or System.out/printStackTrace use in `com.mydreamjob.salarycalculator`; response DTOs omit requestHash, idempotencyKey and studentUserId.
- Blockers: none.

10
changes/n05-salary-calc/.superpowers/sdd/reviews/VzI.json

@ -0,0 +1,10 @@
{
"status": "pass",
"base": "2d315f90e46982e9658da6483f46e2acbe21bcca",
"head": "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7",
"report": ".superpowers\\sdd\\reviews\\W2.md",
"report_sha256": "sha256:2e4ac6255d2c08931102f75b414b24552c6fe2272b3bc139a91e8c4c77215bbe",
"plan_hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5",
"plan_revision": 1,
"recorded_at": "2026-09-20T08:29:10.010Z"
}

9
changes/n05-salary-calc/.superpowers/sdd/reviews/W3.md

@ -0,0 +1,9 @@
# Wave W3 review
- Verdict: pass
- Scope: calculation persistence, policy version, idempotency, history CRUD, user isolation and account-deletion cleanup.
- Tasks: 5, 6, 7.
- Evidence: `mvn -Dtest=SalaryAuthorizationIntegrationTest,SalaryCalculationEndToEndIntegrationTest,SalaryAccountDeletionIntegrationTest test` -> 6 tests, 0 failures, 0 errors.
- Requirements covered: calculate and save, exact response breakdown, policy conflict, idempotency replay/conflict, pagination, history privacy, physical delete, account deletion purge and STUDENT-only authorization.
- Residual risk: concurrency race behavior still needs full W4 regression and real MySQL validation.
- Review blockers: none.

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

@ -1,6 +1,7 @@
package com.mydreamjob.common.exception;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.salarycalculator.SalaryPolicyVersionConflictException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
@ -186,6 +188,19 @@ public class GlobalExceptionHandler {
return HttpStatus.BAD_REQUEST;
}
@ExceptionHandler(SalaryPolicyVersionConflictException.class)
public ResponseEntity<ApiResponse<Map<String, Object>>> handleSalaryPolicyVersionConflict(
SalaryPolicyVersionConflictException ex, HttpServletRequest request) {
Map<String, Object> details = new LinkedHashMap<>();
details.put("latestPolicyVersion", ex.getLatestPolicyVersion());
details.put("policyEffectiveDate", ex.getLatestPolicyEffectiveDate());
ApiResponse<Map<String, Object>> body = ApiResponse.error(
ErrorCode.SALARY_POLICY_VERSION_CONFLICT.getCode(),
ErrorCode.SALARY_POLICY_VERSION_CONFLICT.getMessage(),
getRequestId(request));
body.setData(details);
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(
MethodArgumentNotValidException ex, HttpServletRequest request) {
@ -263,3 +278,4 @@ public class GlobalExceptionHandler {

4
mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java

@ -4,6 +4,7 @@ import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.dataexport.PersonalDataExportCleanupService;
import com.mydreamjob.salarycalculator.SalaryHistoryCleanupService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
@ -32,6 +33,7 @@ public class AccountDeletionCompletionWorker {
private final AuditService auditService;
private final AccountDeletionPhoneHasher phoneHasher;
private final PersonalDataExportCleanupService dataExportCleanupService;
private final SalaryHistoryCleanupService salaryHistoryCleanupService;
/**
* 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。
@ -57,6 +59,7 @@ public class AccountDeletionCompletionWorker {
desensitize(rec.getUserId());
// 注销完成:清理该用户的个人导出文件并作废未完成任务
dataExportCleanupService.purgeForDeletedUser(rec.getUserId());
salaryHistoryCleanupService.purgeForDeletedUser(rec.getUserId());
// 完成 + 脱敏审计各写一次
auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION",
rec.getId(), "account deletion completed; userId=" + userId
@ -110,3 +113,4 @@ public class AccountDeletionCompletionWorker {
userRepository.saveAndFlush(u);
}
}

91
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java

@ -0,0 +1,91 @@
package com.mydreamjob.salarycalculator;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest;
import com.mydreamjob.salarycalculator.dto.SalaryCalculationResponse;
import com.mydreamjob.salarycalculator.dto.SalaryHistoryDetailResponse;
import com.mydreamjob.salarycalculator.dto.SalaryHistorySummaryResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "薪资计算器", description = "城市政策、到手工资估算与历史记录")
@RestController
@RequestMapping("/salary-calculator")
@RequiredArgsConstructor
public class SalaryCalculationController {
private final SalaryCalculationService calculationService;
private final SalaryHistoryService historyService;
@Operation(summary = "计算并保存到手工资", description = "仅 STUDENT;校验政策版本,支持幂等键")
@PostMapping("/calculations")
public ResponseEntity<ApiResponse<SalaryCalculationResponse>> calculate(
@Valid @RequestBody SalaryCalculationRequest body, HttpServletRequest request) {
Long userId = resolveUserId();
return ResponseEntity.ok(ApiResponse.ok(
calculationService.calculateAndSave(userId, body), getRequestId(request)));
}
@Operation(summary = "查询本人薪资历史", description = "仅 STUDENT;按创建时间倒序分页")
@GetMapping("/calculations")
public ResponseEntity<ApiResponse<PageResponse<SalaryHistorySummaryResponse>>> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest request) {
Long userId = resolveUserId();
return ResponseEntity.ok(ApiResponse.ok(
historyService.list(userId, page, size), getRequestId(request)));
}
@Operation(summary = "查询本人薪资历史详情", description = "仅 STUDENT;返回完整快照")
@GetMapping("/calculations/{id}")
public ResponseEntity<ApiResponse<SalaryHistoryDetailResponse>> get(
@PathVariable Long id, HttpServletRequest request) {
Long userId = resolveUserId();
return ResponseEntity.ok(ApiResponse.ok(
historyService.get(userId, id), getRequestId(request)));
}
@Operation(summary = "删除本人薪资历史", description = "仅 STUDENT;物理删除")
@DeleteMapping("/calculations/{id}")
public ResponseEntity<ApiResponse<Void>> delete(
@PathVariable Long id, HttpServletRequest request) {
Long userId = resolveUserId();
historyService.delete(userId, id);
return ResponseEntity.ok(ApiResponse.ok(null, getRequestId(request)));
}
private Long resolveUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()
|| "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
try {
return Long.valueOf(auth.getName());
} catch (NumberFormatException ex) {
throw new BusinessException(ErrorCode.UNAUTHORIZED);
}
}
private String getRequestId(HttpServletRequest request) {
return request.getHeader("X-Request-Id");
}
}

7
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java

@ -2,6 +2,8 @@ package com.mydreamjob.salarycalculator;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
@ -44,6 +46,10 @@ public class SalaryCalculationHistory {
@Column(name = "city_name", nullable = false, length = 80)
private String cityName;
@Enumerated(EnumType.STRING)
@Column(name = "data_level", nullable = false, length = 30)
private SalaryDataLevel dataLevel;
@Column(name = "policy_version", nullable = false, length = 64)
private String policyVersion;
@ -78,3 +84,4 @@ public class SalaryCalculationHistory {
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

190
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java

@ -0,0 +1,190 @@
package com.mydreamjob.salarycalculator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.salarycalculator.dto.SalaryCalculationOverrideRequest;
import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest;
import com.mydreamjob.salarycalculator.dto.SalaryCalculationResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
@Service
@RequiredArgsConstructor
public class SalaryCalculationService {
private final SalaryCityPolicyRepository cityPolicyRepository;
private final SalaryCalculationHistoryRepository historyRepository;
private final UserRepository userRepository;
private final SalaryCalculationEngine calculationEngine;
private final SalaryRequestFingerprint requestFingerprint;
private final ObjectMapper objectMapper;
@Transactional
public SalaryCalculationResponse calculateAndSave(Long userId, SalaryCalculationRequest request) {
requireStudent(userId);
String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey());
String requestHash = idempotencyKey == null ? null : requestFingerprint.fingerprint(request);
if (idempotencyKey != null) {
var existing = historyRepository.findByStudentUserIdAndIdempotencyKey(userId, idempotencyKey);
if (existing.isPresent()) {
return replay(existing.get(), requestHash);
}
}
SalaryCityPolicy policy = cityPolicyRepository.findByCityCodeAndEnabledTrue(request.getCityCode())
.orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND));
if (!Objects.equals(policy.getPolicyVersion(), request.getExpectedPolicyVersion())) {
throw new SalaryPolicyVersionConflictException(
policy.getPolicyVersion(), policy.getPolicyEffectiveDate());
}
SalaryCalculationResult result = calculationEngine.calculate(toEngineInput(request), policy);
SalaryCalculationHistory history = SalaryCalculationHistory.builder()
.studentUserId(userId)
.cityCode(policy.getCityCode())
.cityName(policy.getCityName())
.dataLevel(policy.getDataLevel())
.policyVersion(policy.getPolicyVersion())
.taxRulesVersion(result.getTaxRulesVersion())
.monthIndex(result.getMonthIndex())
.grossMonthlySalary(result.getGrossMonthlySalary())
.netSalary(result.getNetSalary())
.idempotencyKey(idempotencyKey)
.requestHash(requestHash)
.inputSnapshotJson(toJson(inputSnapshot(request)))
.policySnapshotJson(toJson(policySnapshot(policy, result)))
.resultSnapshotJson(toJson(result))
.build();
try {
history = historyRepository.saveAndFlush(history);
} catch (DataIntegrityViolationException ex) {
if (idempotencyKey != null) {
SalaryCalculationHistory existing = historyRepository
.findByStudentUserIdAndIdempotencyKey(userId, idempotencyKey)
.orElseThrow(() -> ex);
return replay(existing, requestHash);
}
throw ex;
}
return toResponse(history);
}
private SalaryCalculationResponse replay(SalaryCalculationHistory history, String requestHash) {
if (!Objects.equals(history.getRequestHash(), requestHash)) {
throw new BusinessException(ErrorCode.SALARY_IDEMPOTENCY_CONFLICT);
}
return toResponse(history);
}
private SalaryCalculationResponse toResponse(SalaryCalculationHistory history) {
try {
SalaryCalculationResult result = objectMapper.readValue(
history.getResultSnapshotJson(), SalaryCalculationResult.class);
return SalaryCalculationResponse.builder()
.historyId(history.getId())
.createdAt(history.getCreatedAt())
.result(result)
.build();
} catch (JsonProcessingException ex) {
throw new IllegalStateException("stored salary result snapshot is invalid", ex);
}
}
private SalaryCalculationInput toEngineInput(SalaryCalculationRequest request) {
SalaryCalculationOverrideRequest source = request.getOverrides();
SalaryOverrideParameters overrides = source == null ? null : SalaryOverrideParameters.builder()
.socialSecurityBase(source.getSocialSecurityBase())
.housingFundBase(source.getHousingFundBase())
.pensionRate(source.getPensionRate())
.medicalRate(source.getMedicalRate())
.unemploymentRate(source.getUnemploymentRate())
.housingFundRate(source.getHousingFundRate())
.seriousIllnessMonthlyFee(source.getSeriousIllnessMonthlyFee())
.build();
return SalaryCalculationInput.builder()
.grossMonthlySalary(request.getGrossMonthlySalary())
.monthIndex(request.getMonthIndex())
.specialAdditionalDeduction(request.getSpecialAdditionalDeduction())
.overrides(overrides)
.build();
}
private Map<String, Object> inputSnapshot(SalaryCalculationRequest request) {
Map<String, Object> input = new LinkedHashMap<>();
input.put("cityCode", request.getCityCode());
input.put("grossMonthlySalary", request.getGrossMonthlySalary());
input.put("monthIndex", request.getMonthIndex() == null ? 1 : request.getMonthIndex());
input.put("specialAdditionalDeduction", request.getSpecialAdditionalDeduction());
input.put("expectedPolicyVersion", request.getExpectedPolicyVersion());
input.put("overrides", request.getOverrides());
return input;
}
private Map<String, Object> policySnapshot(
SalaryCityPolicy policy, SalaryCalculationResult result) {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("cityCode", policy.getCityCode());
snapshot.put("cityName", policy.getCityName());
snapshot.put("dataLevel", policy.getDataLevel());
snapshot.put("policyVersion", policy.getPolicyVersion());
snapshot.put("policyEffectiveDate", policy.getPolicyEffectiveDate());
snapshot.put("defaultParameters", Map.of(
"socialSecurityBaseMin", policy.getSocialSecurityBaseMin(),
"socialSecurityBaseMax", policy.getSocialSecurityBaseMax(),
"housingFundBaseMin", policy.getHousingFundBaseMin(),
"housingFundBaseMax", policy.getHousingFundBaseMax(),
"pensionRate", policy.getPensionRate(),
"medicalRate", policy.getMedicalRate(),
"unemploymentRate", policy.getUnemploymentRate(),
"housingFundRate", policy.getHousingFundRate(),
"seriousIllnessMonthlyFee", policy.getSeriousIllnessMonthlyFee()));
snapshot.put("effectiveParameters", Map.of(
"socialSecurityBase", result.getSocialSecurityBase(),
"housingFundBase", result.getHousingFundBase(),
"pensionRate", result.getPensionRate(),
"medicalRate", result.getMedicalRate(),
"unemploymentRate", result.getUnemploymentRate(),
"housingFundRate", result.getHousingFundRate(),
"seriousIllnessMonthlyFee", result.getSeriousIllnessMonthlyFee()));
return snapshot;
}
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("unable to serialize salary snapshot", ex);
}
}
private String normalizeIdempotencyKey(String value) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.trim();
if (normalized.length() > 64) {
throw new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, "idempotencyKey is too long");
}
return normalized;
}
private void requireStudent(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles() == null
|| user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
}

18
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryCleanupService.java

@ -0,0 +1,18 @@
package com.mydreamjob.salarycalculator;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class SalaryHistoryCleanupService {
private final SalaryCalculationHistoryRepository historyRepository;
@Transactional(propagation = Propagation.MANDATORY)
public long purgeForDeletedUser(Long userId) {
return historyRepository.deleteByStudentUserId(userId);
}
}

106
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryService.java

@ -0,0 +1,106 @@
package com.mydreamjob.salarycalculator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.common.api.PageResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.salarycalculator.dto.SalaryHistoryDetailResponse;
import com.mydreamjob.salarycalculator.dto.SalaryHistorySummaryResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class SalaryHistoryService {
private final SalaryCalculationHistoryRepository historyRepository;
private final UserRepository userRepository;
private final ObjectMapper objectMapper;
@Transactional(readOnly = true)
public PageResponse<SalaryHistorySummaryResponse> list(Long userId, int page, int size) {
requireStudent(userId);
if (page < 0 || size < 1 || size > 100) {
throw new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, "invalid pagination");
}
Page<SalaryCalculationHistory> result = historyRepository
.findByStudentUserIdOrderByCreatedAtDescIdDesc(userId, PageRequest.of(page, size));
return PageResponse.of(
result.getContent().stream().map(this::toSummary).toList(),
result.getTotalElements(), page, size);
}
@Transactional(readOnly = true)
public SalaryHistoryDetailResponse get(Long userId, Long historyId) {
requireStudent(userId);
SalaryCalculationHistory history = ownHistory(userId, historyId);
return SalaryHistoryDetailResponse.builder()
.id(history.getId())
.cityCode(history.getCityCode())
.cityName(history.getCityName())
.dataLevel(history.getDataLevel())
.policyVersion(history.getPolicyVersion())
.taxRulesVersion(history.getTaxRulesVersion())
.monthIndex(history.getMonthIndex())
.grossMonthlySalary(history.getGrossMonthlySalary())
.netSalary(history.getNetSalary())
.createdAt(history.getCreatedAt())
.inputSnapshot(readJson(history.getInputSnapshotJson()))
.policySnapshot(readJson(history.getPolicySnapshotJson()))
.resultSnapshot(readJson(history.getResultSnapshotJson()))
.build();
}
@Transactional
public void delete(Long userId, Long historyId) {
requireStudent(userId);
SalaryCalculationHistory history = ownHistory(userId, historyId);
historyRepository.delete(history);
historyRepository.flush();
}
private SalaryHistorySummaryResponse toSummary(SalaryCalculationHistory history) {
return SalaryHistorySummaryResponse.builder()
.id(history.getId())
.cityCode(history.getCityCode())
.cityName(history.getCityName())
.dataLevel(history.getDataLevel())
.grossMonthlySalary(history.getGrossMonthlySalary())
.netSalary(history.getNetSalary())
.monthIndex(history.getMonthIndex())
.createdAt(history.getCreatedAt())
.build();
}
private SalaryCalculationHistory ownHistory(Long userId, Long historyId) {
if (historyId == null) {
throw new BusinessException(ErrorCode.SALARY_HISTORY_NOT_FOUND);
}
return historyRepository.findByIdAndStudentUserId(historyId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.SALARY_HISTORY_NOT_FOUND));
}
private JsonNode readJson(String value) {
try {
return objectMapper.readTree(value);
} catch (JsonProcessingException ex) {
throw new IllegalStateException("stored salary snapshot is invalid", ex);
}
}
private void requireStudent(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if (user.getRoles() == null
|| user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
}

23
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyVersionConflictException.java

@ -0,0 +1,23 @@
package com.mydreamjob.salarycalculator;
import java.time.LocalDate;
public class SalaryPolicyVersionConflictException extends RuntimeException {
private final String latestPolicyVersion;
private final LocalDate latestPolicyEffectiveDate;
public SalaryPolicyVersionConflictException(String latestPolicyVersion, LocalDate latestPolicyEffectiveDate) {
super("salary city policy version conflict");
this.latestPolicyVersion = latestPolicyVersion;
this.latestPolicyEffectiveDate = latestPolicyEffectiveDate;
}
public String getLatestPolicyVersion() {
return latestPolicyVersion;
}
public LocalDate getLatestPolicyEffectiveDate() {
return latestPolicyEffectiveDate;
}
}

68
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryRequestFingerprint.java

@ -0,0 +1,68 @@
package com.mydreamjob.salarycalculator;
import com.mydreamjob.salarycalculator.dto.SalaryCalculationOverrideRequest;
import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
@Component
public class SalaryRequestFingerprint {
public String fingerprint(SalaryCalculationRequest request) {
StringBuilder canonical = new StringBuilder();
append(canonical, normalize(request.getCityCode()));
append(canonical, normalize(request.getExpectedPolicyVersion()));
append(canonical, decimal(request.getGrossMonthlySalary()));
append(canonical, String.valueOf(request.getMonthIndex() == null ? 1 : request.getMonthIndex()));
append(canonical, decimal(request.getSpecialAdditionalDeduction() == null
? BigDecimal.ZERO : request.getSpecialAdditionalDeduction()));
SalaryCalculationOverrideRequest overrides = request.getOverrides();
if (overrides == null) {
for (int i = 0; i < 7; i++) {
append(canonical, "");
}
} else {
append(canonical, decimal(overrides.getSocialSecurityBase()));
append(canonical, decimal(overrides.getHousingFundBase()));
append(canonical, decimal(overrides.getPensionRate()));
append(canonical, decimal(overrides.getMedicalRate()));
append(canonical, decimal(overrides.getUnemploymentRate()));
append(canonical, decimal(overrides.getHousingFundRate()));
append(canonical, decimal(overrides.getSeriousIllnessMonthlyFee()));
}
return sha256(canonical.toString());
}
private void append(StringBuilder builder, String value) {
builder.append(value.length()).append(':').append(value).append('|');
}
private String normalize(String value) {
return value == null ? "" : value.trim();
}
private String decimal(BigDecimal value) {
if (value == null) {
return "";
}
BigDecimal normalized = value.stripTrailingZeros();
if (normalized.scale() < 0) {
normalized = normalized.setScale(0);
}
return normalized.toPlainString();
}
private String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 is unavailable", ex);
}
}
}

53
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationOverrideRequest.java

@ -0,0 +1,53 @@
package com.mydreamjob.salarycalculator.dto;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SalaryCalculationOverrideRequest {
@DecimalMin(value = "0.00")
@DecimalMax(value = "10000000.00")
@Digits(integer = 8, fraction = 2)
private BigDecimal socialSecurityBase;
@DecimalMin(value = "0.00")
@DecimalMax(value = "10000000.00")
@Digits(integer = 8, fraction = 2)
private BigDecimal housingFundBase;
@DecimalMin(value = "0.00")
@DecimalMax(value = "0.20")
@Digits(integer = 1, fraction = 6)
private BigDecimal pensionRate;
@DecimalMin(value = "0.00")
@DecimalMax(value = "0.20")
@Digits(integer = 1, fraction = 6)
private BigDecimal medicalRate;
@DecimalMin(value = "0.00")
@DecimalMax(value = "0.10")
@Digits(integer = 1, fraction = 6)
private BigDecimal unemploymentRate;
@DecimalMin(value = "0.00")
@DecimalMax(value = "0.12")
@Digits(integer = 1, fraction = 6)
private BigDecimal housingFundRate;
@DecimalMin(value = "0.00")
@DecimalMax(value = "10000.00")
@Digits(integer = 5, fraction = 2)
private BigDecimal seriousIllnessMonthlyFee;
}

51
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationRequest.java

@ -0,0 +1,51 @@
package com.mydreamjob.salarycalculator.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SalaryCalculationRequest {
@NotBlank
private String cityCode;
@NotNull
@DecimalMin(value = "0.01")
@DecimalMax(value = "10000000.00")
@Digits(integer = 8, fraction = 2)
private BigDecimal grossMonthlySalary;
@Min(1)
@Max(12)
private Integer monthIndex;
@DecimalMin(value = "0.00")
@DecimalMax(value = "10000000.00")
@Digits(integer = 8, fraction = 2)
private BigDecimal specialAdditionalDeduction;
@NotBlank
private String expectedPolicyVersion;
@Size(max = 64)
private String idempotencyKey;
@Valid
private SalaryCalculationOverrideRequest overrides;
}

19
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationResponse.java

@ -0,0 +1,19 @@
package com.mydreamjob.salarycalculator.dto;
import com.mydreamjob.salarycalculator.SalaryCalculationResult;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SalaryCalculationResponse {
private Long historyId;
private LocalDateTime createdAt;
private SalaryCalculationResult result;
}

31
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistoryDetailResponse.java

@ -0,0 +1,31 @@
package com.mydreamjob.salarycalculator.dto;
import com.fasterxml.jackson.databind.JsonNode;
import com.mydreamjob.salarycalculator.SalaryDataLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SalaryHistoryDetailResponse {
private Long id;
private String cityCode;
private String cityName;
private SalaryDataLevel dataLevel;
private String policyVersion;
private String taxRulesVersion;
private Integer monthIndex;
private BigDecimal grossMonthlySalary;
private BigDecimal netSalary;
private LocalDateTime createdAt;
private JsonNode inputSnapshot;
private JsonNode policySnapshot;
private JsonNode resultSnapshot;
}

25
mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistorySummaryResponse.java

@ -0,0 +1,25 @@
package com.mydreamjob.salarycalculator.dto;
import com.mydreamjob.salarycalculator.SalaryDataLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SalaryHistorySummaryResponse {
private Long id;
private String cityCode;
private String cityName;
private SalaryDataLevel dataLevel;
private BigDecimal grossMonthlySalary;
private BigDecimal netSalary;
private Integer monthIndex;
private LocalDateTime createdAt;
}

2
mydreamjob-server/src/main/resources/db/migration/V40__salary_calculator_schema.sql

@ -46,6 +46,7 @@ CREATE TABLE salary_calculation_history (
student_user_id BIGINT NOT NULL COMMENT '所属学生用户',
city_code VARCHAR(6) NOT NULL COMMENT '城市编码快照',
city_name VARCHAR(80) NOT NULL COMMENT '城市名称快照',
data_level VARCHAR(30) NOT NULL COMMENT 'CITY 或 PROVINCE_FALLBACK',
policy_version VARCHAR(64) NOT NULL COMMENT '城市政策版本',
tax_rules_version VARCHAR(64) NOT NULL COMMENT '全国个税规则版本',
month_index INT NOT NULL COMMENT '累计计税月份',
@ -67,3 +68,4 @@ CREATE TABLE salary_calculation_history (
INDEX idx_sch_student_city (student_user_id, city_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='薪资计算历史快照';

81
mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAccountDeletionIntegrationTest.java

@ -0,0 +1,81 @@
package com.mydreamjob.salarycalculator;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.deletion.AccountDeletion;
import com.mydreamjob.deletion.AccountDeletionCompletionWorker;
import com.mydreamjob.deletion.AccountDeletionRepository;
import com.mydreamjob.deletion.DeletionStatus;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@ActiveProfiles("integration-test")
@TestPropertySource(properties =
"spring.datasource.url=jdbc:h2:mem:integrationdb_salary_deletion;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
class SalaryAccountDeletionIntegrationTest {
private static final AtomicLong PHONE = new AtomicLong(970_000_000L);
@Autowired private UserRepository userRepository;
@Autowired private SalaryCalculationHistoryRepository historyRepository;
@Autowired private AccountDeletionRepository accountDeletionRepository;
@Autowired private AccountDeletionCompletionWorker completionWorker;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@Test
void completedDeletionPurgesSalaryHistory() {
User user = userRepository.save(User.builder()
.phone(String.valueOf(PHONE.getAndIncrement()))
.roles(Set.of(User.Role.STUDENT))
.role(User.Role.STUDENT)
.active(true)
.build());
historyRepository.save(history(user.getId()));
accountDeletionRepository.save(AccountDeletion.builder()
.userId(user.getId())
.status(DeletionStatus.PENDING)
.requestedAt(LocalDateTime.now().minusDays(31))
.effectiveAt(LocalDateTime.now().minusMinutes(1))
.version(0L)
.build());
assertThat(completionWorker.completeIfExpired(user.getId())).isTrue();
assertThat(historyRepository.findByStudentUserIdOrderByCreatedAtDescIdDesc(
user.getId(), PageRequest.of(0, 20)).getTotalElements()).isZero();
assertThat(userRepository.findById(user.getId()).orElseThrow().getPhone())
.startsWith("deleted_");
}
private SalaryCalculationHistory history(Long userId) {
return SalaryCalculationHistory.builder()
.studentUserId(userId)
.cityCode("110000")
.cityName("北京市")
.dataLevel(SalaryDataLevel.CITY)
.policyVersion("2026-09-20-v1")
.taxRulesVersion("CN_IIT_2019_V1")
.monthIndex(1)
.grossMonthlySalary(new BigDecimal("10000.00"))
.netSalary(new BigDecimal("7667.50"))
.inputSnapshotJson("{}")
.policySnapshotJson("{}")
.resultSnapshotJson("{}")
.build();
}
}

82
mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAuthorizationIntegrationTest.java

@ -0,0 +1,82 @@
package com.mydreamjob.salarycalculator;
import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties =
"spring.datasource.url=jdbc:h2:mem:integrationdb_salary_auth;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
class SalaryAuthorizationIntegrationTest {
private static final AtomicLong PHONE = new AtomicLong(980_000_000L);
@Autowired private MockMvc mockMvc;
@Autowired private JwtTokenProvider jwtTokenProvider;
@Autowired private UserRepository userRepository;
@Autowired private SalaryCalculationHistoryRepository historyRepository;
@MockBean private RedisTemplate<String, Object> redisTemplate;
@Test
void anonymousCannotUseSalaryCalculator() throws Exception {
mockMvc.perform(get("/salary-calculator/calculations"))
.andExpect(status().isUnauthorized());
}
@Test
void companyCannotCalculateOrReadHistory() throws Exception {
User company = userRepository.save(User.builder()
.phone(String.valueOf(PHONE.getAndIncrement()))
.roles(Set.of(User.Role.COMPANY))
.role(User.Role.COMPANY)
.active(true)
.build());
String token = token(company.getId());
String body = "{\"cityCode\":\"110000\",\"grossMonthlySalary\":10000,"
+ "\"monthIndex\":1,\"specialAdditionalDeduction\":0,"
+ "\"expectedPolicyVersion\":\"2026-09-20-v1\"}";
mockMvc.perform(post("/salary-calculator/calculations")
.header("Authorization", token)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(2002));
mockMvc.perform(get("/salary-calculator/calculations")
.header("Authorization", token))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(2002));
assertThat(historyRepository.count()).isZero();
}
private String token(long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(
userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "COMPANY"));
}
}

168
mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java

@ -0,0 +1,168 @@
package com.mydreamjob.salarycalculator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.auth.JwtTokenProvider;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties =
"spring.datasource.url=jdbc:h2:mem:integrationdb_salary_e2e;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
class SalaryCalculationEndToEndIntegrationTest {
private static final AtomicLong PHONE = new AtomicLong(960_000_000L);
@Autowired private MockMvc mockMvc;
@Autowired private JwtTokenProvider jwtTokenProvider;
@Autowired private UserRepository userRepository;
@Autowired private SalaryCalculationHistoryRepository historyRepository;
private final ObjectMapper mapper = new ObjectMapper();
@MockBean private RedisTemplate<String, Object> redisTemplate;
@Test
void calculateReplayConflictHistoryAndDelete() throws Exception {
long uid = createUser(User.Role.STUDENT);
String token = token(uid);
String request = requestJson("110000", "10000", 1, "0", "key-1");
String first = mockMvc.perform(post("/salary-calculator/calculations")
.header("Authorization", token)
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.result.cityCode").value("110000"))
.andExpect(jsonPath("$.data.result.dataLevel").value("CITY"))
.andExpect(jsonPath("$.data.result.estimate").value(true))
.andExpect(jsonPath("$.data.result.netSalary").value(7667.50))
.andReturn().getResponse().getContentAsString();
long historyId = mapper.readTree(first).path("data").path("historyId").asLong();
assertThat(historyRepository.count()).isEqualTo(1);
String replay = mockMvc.perform(post("/salary-calculator/calculations")
.header("Authorization", token)
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat(mapper.readTree(replay).path("data").path("historyId").asLong())
.isEqualTo(historyId);
assertThat(historyRepository.count()).isEqualTo(1);
mockMvc.perform(post("/salary-calculator/calculations")
.header("Authorization", token)
.contentType(MediaType.APPLICATION_JSON)
.content(requestJson("110000", "12000", 1, "0", "key-1")))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(9804));
mockMvc.perform(get("/salary-calculator/calculations")
.header("Authorization", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].id").value(historyId));
mockMvc.perform(get("/salary-calculator/calculations/{id}", historyId)
.header("Authorization", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.inputSnapshot.cityCode").value("110000"))
.andExpect(jsonPath("$.data.policySnapshot.dataLevel").value("CITY"))
.andExpect(jsonPath("$.data.resultSnapshot.netSalary").value(7667.50));
long otherId = createUser(User.Role.STUDENT);
mockMvc.perform(get("/salary-calculator/calculations/{id}", historyId)
.header("Authorization", token(otherId)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(9803));
mockMvc.perform(delete("/salary-calculator/calculations/{id}", historyId)
.header("Authorization", token))
.andExpect(status().isOk());
assertThat(historyRepository.findById(historyId)).isEmpty();
}
@Test
void policyVersionConflictReturnsLatestVersionWithoutHistory() throws Exception {
long uid = createUser(User.Role.STUDENT);
String request = requestJson("110000", "10000", 1, "0", "version-key")
.replace("2026-09-20-v1", "stale-version");
mockMvc.perform(post("/salary-calculator/calculations")
.header("Authorization", token(uid))
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(9805))
.andExpect(jsonPath("$.data.latestPolicyVersion").value("2026-09-20-v1"))
.andExpect(jsonPath("$.data.policyEffectiveDate").value("2026-07-01"));
assertThat(historyRepository.count()).isZero();
}
@Test
void invalidOverrideReturns9802() throws Exception {
long uid = createUser(User.Role.STUDENT);
String request = "{\"cityCode\":\"110000\",\"grossMonthlySalary\":10000,"
+ "\"monthIndex\":1,\"specialAdditionalDeduction\":0,"
+ "\"expectedPolicyVersion\":\"2026-09-20-v1\","
+ "\"idempotencyKey\":\"bad-override\","
+ "\"overrides\":{\"socialSecurityBase\":1000}}";
mockMvc.perform(post("/salary-calculator/calculations")
.header("Authorization", token(uid))
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(9802));
}
private String requestJson(
String cityCode, String gross, int monthIndex, String special, String idempotencyKey) {
return "{\"cityCode\":\"" + cityCode + "\","
+ "\"grossMonthlySalary\":" + gross + ","
+ "\"monthIndex\":" + monthIndex + ","
+ "\"specialAdditionalDeduction\":" + special + ","
+ "\"expectedPolicyVersion\":\"2026-09-20-v1\","
+ "\"idempotencyKey\":\"" + idempotencyKey + "\","
+ "\"overrides\":null}";
}
private long createUser(User.Role role) {
User user = userRepository.save(User.builder()
.phone(String.valueOf(PHONE.getAndIncrement()))
.roles(Set.of(role))
.role(role)
.active(true)
.build());
return user.getId();
}
private String token(long userId) {
return "Bearer " + jwtTokenProvider.generateAccessToken(
userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT"));
}
}

3
mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java

@ -42,7 +42,7 @@ class SalaryMigrationTest {
"pension_rate", "medical_rate", "unemployment_rate", "housing_fund_rate",
"serious_illness_monthly_fee", "data_level", "enabled", "created_at", "updated_at");
assertTableAndColumns("salary_calculation_history",
"id", "student_user_id", "city_code", "city_name", "policy_version",
"id", "student_user_id", "city_code", "city_name", "data_level", "policy_version",
"tax_rules_version", "month_index", "gross_monthly_salary", "net_salary",
"idempotency_key", "request_hash", "input_snapshot_json", "policy_snapshot_json",
"result_snapshot_json", "created_at");
@ -149,3 +149,4 @@ class SalaryMigrationTest {
}
}

1
mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryRepositoryIntegrationTest.java

@ -130,3 +130,4 @@ class SalaryRepositoryIntegrationTest {
}

Loading…
Cancel
Save