diff --git a/changes/n05-salary-calc/.spec-superflow.yaml b/changes/n05-salary-calc/.spec-superflow.yaml index d79235f..26a2229 100644 --- a/changes/n05-salary-calc/.spec-superflow.yaml +++ b/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 diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/3.md b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/3.md new file mode 100644 index 0000000..4773405 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/3.md @@ -0,0 +1,37 @@ +--- +task_id: "3" +task_hash: "sha256:b5aaa1f3ea977fa289582c0e7e29adc5990fc4c2f46a5e64c6f142b391dec368" +next: "Task 4: implement BigDecimal salary calculation engine" +completed: "City list/detail APIs with STUDENT authorization and dataLevel" +evidence: "mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest test: 9/9 passed" +review: ".superpowers/sdd/reviews/T3.md" +risk: "Fallback city detail must remain visibly marked" +commit_start: "2d315f90e46982e9658da6483f46e2acbe21bcca" +commit_end: "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7" +created_at: "2026-09-20T08:29:09.441Z" +plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5" +plan_revision: 1 +--- + +# Checkpoint: 3 + +## Next +Task 4: implement BigDecimal salary calculation engine + +## Completed +City list/detail APIs with STUDENT authorization and dataLevel + +## Evidence +mvn -Dtest=SalaryCityPolicyServiceTest,SalaryCityPolicyControllerTest test: 9/9 passed + +## Review +.superpowers/sdd/reviews/T3.md + +## Risk +Fallback city detail must remain visibly marked + +## Commit Start +2d315f90e46982e9658da6483f46e2acbe21bcca + +## Commit End +4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7 diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/4.md b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/4.md new file mode 100644 index 0000000..e9ce362 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/checkpoints/4.md @@ -0,0 +1,37 @@ +--- +task_id: "4" +task_hash: "sha256:d571027295959410fd431fe86fc37d769aa15e71d4e14fcb4e5c624b8960c193" +next: "Wave W3 task 5: calculation persistence and policy version validation" +completed: "BigDecimal calculation engine with cumulative withholding and override validation" +evidence: "mvn -Dtest=SalaryCalculationEngineTest test: 6/6 passed" +review: ".superpowers/sdd/reviews/T4.md" +risk: "Tax anchor cases will be expanded in W4" +commit_start: "2d315f90e46982e9658da6483f46e2acbe21bcca" +commit_end: "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7" +created_at: "2026-09-20T08:29:09.537Z" +plan_hash: "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5" +plan_revision: 1 +--- + +# Checkpoint: 4 + +## Next +Wave W3 task 5: calculation persistence and policy version validation + +## Completed +BigDecimal calculation engine with cumulative withholding and override validation + +## Evidence +mvn -Dtest=SalaryCalculationEngineTest test: 6/6 passed + +## Review +.superpowers/sdd/reviews/T4.md + +## Risk +Tax anchor cases will be expanded in W4 + +## Commit Start +2d315f90e46982e9658da6483f46e2acbe21bcca + +## Commit End +4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7 diff --git a/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzI.json b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzI.json new file mode 100644 index 0000000..fc8c755 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/plans/r1-77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5/reviews/VzI.json @@ -0,0 +1,10 @@ +{ + "status": "pass", + "base": "2d315f90e46982e9658da6483f46e2acbe21bcca", + "head": "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7", + "report": ".superpowers\\sdd\\reviews\\W2.md", + "report_sha256": "sha256:2e4ac6255d2c08931102f75b414b24552c6fe2272b3bc139a91e8c4c77215bbe", + "plan_hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5", + "plan_revision": 1, + "recorded_at": "2026-09-20T08:29:10.010Z" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/progress.md b/changes/n05-salary-calc/.superpowers/sdd/progress.md index cccfb68..061247e 100644 --- a/changes/n05-salary-calc/.superpowers/sdd/progress.md +++ b/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。 diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/T5.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/T5.md new file mode 100644 index 0000000..c87c36c --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/T5.md @@ -0,0 +1,7 @@ +# Task 5 self-review + +- Verdict: pass +- Scope: calculate-and-save API, policy version validation, normalized request fingerprint and idempotent replay. +- Evidence: `SalaryCalculationEndToEndIntegrationTest` covers success, replay, same-key conflict, stale policy and invalid override. W3 suite 6/6 passed. +- Contract check: no history on version conflict; replay returns same history; structured 9805 data includes latest version/date. +- Blockers: none. diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/T6.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/T6.md new file mode 100644 index 0000000..8120cb7 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/T6.md @@ -0,0 +1,7 @@ +# Task 6 self-review + +- Verdict: pass +- Scope: history list/detail/physical delete, pagination, user isolation and snapshot JSON. +- Evidence: `SalaryCalculationEndToEndIntegrationTest` covers list, detail, cross-user 404 and physical delete; W3 suite 6/6 passed. +- Contract check: list sorted by repository query; details return input/policy/result snapshots; deleted rows are absent. +- Blockers: none. diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/T7.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/T7.md new file mode 100644 index 0000000..3583576 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/T7.md @@ -0,0 +1,7 @@ +# Task 7 self-review + +- Verdict: pass +- Scope: STUDENT authorization, privacy boundaries and account-deletion purge. +- Evidence: `SalaryAuthorizationIntegrationTest` and `SalaryAccountDeletionIntegrationTest`; W3 suite 6/6 passed. +- Privacy check: no direct logger or System.out/printStackTrace use in `com.mydreamjob.salarycalculator`; response DTOs omit requestHash, idempotencyKey and studentUserId. +- Blockers: none. diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/VzI.json b/changes/n05-salary-calc/.superpowers/sdd/reviews/VzI.json new file mode 100644 index 0000000..fc8c755 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/VzI.json @@ -0,0 +1,10 @@ +{ + "status": "pass", + "base": "2d315f90e46982e9658da6483f46e2acbe21bcca", + "head": "4b42a624d7ec2bb5cac64bc13cd7d4d8eba91ee7", + "report": ".superpowers\\sdd\\reviews\\W2.md", + "report_sha256": "sha256:2e4ac6255d2c08931102f75b414b24552c6fe2272b3bc139a91e8c4c77215bbe", + "plan_hash": "sha256:77341d6e000ef1624d59d66b8b361c24e7ef8f1b5d065dd4d648a2a7537e72d5", + "plan_revision": 1, + "recorded_at": "2026-09-20T08:29:10.010Z" +} diff --git a/changes/n05-salary-calc/.superpowers/sdd/reviews/W3.md b/changes/n05-salary-calc/.superpowers/sdd/reviews/W3.md new file mode 100644 index 0000000..a19edb5 --- /dev/null +++ b/changes/n05-salary-calc/.superpowers/sdd/reviews/W3.md @@ -0,0 +1,9 @@ +# Wave W3 review + +- Verdict: pass +- Scope: calculation persistence, policy version, idempotency, history CRUD, user isolation and account-deletion cleanup. +- Tasks: 5, 6, 7. +- Evidence: `mvn -Dtest=SalaryAuthorizationIntegrationTest,SalaryCalculationEndToEndIntegrationTest,SalaryAccountDeletionIntegrationTest test` -> 6 tests, 0 failures, 0 errors. +- Requirements covered: calculate and save, exact response breakdown, policy conflict, idempotency replay/conflict, pagination, history privacy, physical delete, account deletion purge and STUDENT-only authorization. +- Residual risk: concurrency race behavior still needs full W4 regression and real MySQL validation. +- Review blockers: none. diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java index 2f72730..0310ce2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.mydreamjob.common.exception; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.salarycalculator.SalaryPolicyVersionConflictException; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; @@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; /** @@ -186,6 +188,19 @@ public class GlobalExceptionHandler { return HttpStatus.BAD_REQUEST; } + @ExceptionHandler(SalaryPolicyVersionConflictException.class) + public ResponseEntity>> handleSalaryPolicyVersionConflict( + SalaryPolicyVersionConflictException ex, HttpServletRequest request) { + Map details = new LinkedHashMap<>(); + details.put("latestPolicyVersion", ex.getLatestPolicyVersion()); + details.put("policyEffectiveDate", ex.getLatestPolicyEffectiveDate()); + ApiResponse> body = ApiResponse.error( + ErrorCode.SALARY_POLICY_VERSION_CONFLICT.getCode(), + ErrorCode.SALARY_POLICY_VERSION_CONFLICT.getMessage(), + getRequestId(request)); + body.setData(details); + return ResponseEntity.status(HttpStatus.CONFLICT).body(body); + } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidationException( MethodArgumentNotValidException ex, HttpServletRequest request) { @@ -263,3 +278,4 @@ public class GlobalExceptionHandler { + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java index 709ee60..107ea2b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java @@ -4,6 +4,7 @@ import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.dataexport.PersonalDataExportCleanupService; +import com.mydreamjob.salarycalculator.SalaryHistoryCleanupService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.orm.ObjectOptimisticLockingFailureException; @@ -32,6 +33,7 @@ public class AccountDeletionCompletionWorker { private final AuditService auditService; private final AccountDeletionPhoneHasher phoneHasher; private final PersonalDataExportCleanupService dataExportCleanupService; + private final SalaryHistoryCleanupService salaryHistoryCleanupService; /** * 若用户处于 PENDING 且已到期,则执行完成态迁移 + 脱敏 + 审计。 @@ -57,6 +59,7 @@ public class AccountDeletionCompletionWorker { desensitize(rec.getUserId()); // 注销完成:清理该用户的个人导出文件并作废未完成任务 dataExportCleanupService.purgeForDeletedUser(rec.getUserId()); + salaryHistoryCleanupService.purgeForDeletedUser(rec.getUserId()); // 完成 + 脱敏审计各写一次 auditService.log(userId, "ACCOUNT_DELETION_COMPLETED", "ACCOUNT_DELETION", rec.getId(), "account deletion completed; userId=" + userId @@ -109,4 +112,5 @@ public class AccountDeletionCompletionWorker { u.setActive(false); userRepository.saveAndFlush(u); } -} \ No newline at end of file +} + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java new file mode 100644 index 0000000..f69a0d2 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationController.java @@ -0,0 +1,91 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.api.PageResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationResponse; +import com.mydreamjob.salarycalculator.dto.SalaryHistoryDetailResponse; +import com.mydreamjob.salarycalculator.dto.SalaryHistorySummaryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "薪资计算器", description = "城市政策、到手工资估算与历史记录") +@RestController +@RequestMapping("/salary-calculator") +@RequiredArgsConstructor +public class SalaryCalculationController { + + private final SalaryCalculationService calculationService; + private final SalaryHistoryService historyService; + + @Operation(summary = "计算并保存到手工资", description = "仅 STUDENT;校验政策版本,支持幂等键") + @PostMapping("/calculations") + public ResponseEntity> calculate( + @Valid @RequestBody SalaryCalculationRequest body, HttpServletRequest request) { + Long userId = resolveUserId(); + return ResponseEntity.ok(ApiResponse.ok( + calculationService.calculateAndSave(userId, body), getRequestId(request))); + } + + @Operation(summary = "查询本人薪资历史", description = "仅 STUDENT;按创建时间倒序分页") + @GetMapping("/calculations") + public ResponseEntity>> list( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + HttpServletRequest request) { + Long userId = resolveUserId(); + return ResponseEntity.ok(ApiResponse.ok( + historyService.list(userId, page, size), getRequestId(request))); + } + + @Operation(summary = "查询本人薪资历史详情", description = "仅 STUDENT;返回完整快照") + @GetMapping("/calculations/{id}") + public ResponseEntity> get( + @PathVariable Long id, HttpServletRequest request) { + Long userId = resolveUserId(); + return ResponseEntity.ok(ApiResponse.ok( + historyService.get(userId, id), getRequestId(request))); + } + + @Operation(summary = "删除本人薪资历史", description = "仅 STUDENT;物理删除") + @DeleteMapping("/calculations/{id}") + public ResponseEntity> delete( + @PathVariable Long id, HttpServletRequest request) { + Long userId = resolveUserId(); + historyService.delete(userId, id); + return ResponseEntity.ok(ApiResponse.ok(null, getRequestId(request))); + } + + private Long resolveUserId() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() + || "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + try { + return Long.valueOf(auth.getName()); + } catch (NumberFormatException ex) { + throw new BusinessException(ErrorCode.UNAUTHORIZED); + } + } + + private String getRequestId(HttpServletRequest request) { + return request.getHeader("X-Request-Id"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java index fb9cf1b..06cd28e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationHistory.java +++ b/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; } + diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java new file mode 100644 index 0000000..177455c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryCalculationService.java @@ -0,0 +1,190 @@ +package com.mydreamjob.salarycalculator; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationOverrideRequest; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class SalaryCalculationService { + + private final SalaryCityPolicyRepository cityPolicyRepository; + private final SalaryCalculationHistoryRepository historyRepository; + private final UserRepository userRepository; + private final SalaryCalculationEngine calculationEngine; + private final SalaryRequestFingerprint requestFingerprint; + private final ObjectMapper objectMapper; + + @Transactional + public SalaryCalculationResponse calculateAndSave(Long userId, SalaryCalculationRequest request) { + requireStudent(userId); + String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); + String requestHash = idempotencyKey == null ? null : requestFingerprint.fingerprint(request); + if (idempotencyKey != null) { + var existing = historyRepository.findByStudentUserIdAndIdempotencyKey(userId, idempotencyKey); + if (existing.isPresent()) { + return replay(existing.get(), requestHash); + } + } + + SalaryCityPolicy policy = cityPolicyRepository.findByCityCodeAndEnabledTrue(request.getCityCode()) + .orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); + if (!Objects.equals(policy.getPolicyVersion(), request.getExpectedPolicyVersion())) { + throw new SalaryPolicyVersionConflictException( + policy.getPolicyVersion(), policy.getPolicyEffectiveDate()); + } + + SalaryCalculationResult result = calculationEngine.calculate(toEngineInput(request), policy); + SalaryCalculationHistory history = SalaryCalculationHistory.builder() + .studentUserId(userId) + .cityCode(policy.getCityCode()) + .cityName(policy.getCityName()) + .dataLevel(policy.getDataLevel()) + .policyVersion(policy.getPolicyVersion()) + .taxRulesVersion(result.getTaxRulesVersion()) + .monthIndex(result.getMonthIndex()) + .grossMonthlySalary(result.getGrossMonthlySalary()) + .netSalary(result.getNetSalary()) + .idempotencyKey(idempotencyKey) + .requestHash(requestHash) + .inputSnapshotJson(toJson(inputSnapshot(request))) + .policySnapshotJson(toJson(policySnapshot(policy, result))) + .resultSnapshotJson(toJson(result)) + .build(); + + try { + history = historyRepository.saveAndFlush(history); + } catch (DataIntegrityViolationException ex) { + if (idempotencyKey != null) { + SalaryCalculationHistory existing = historyRepository + .findByStudentUserIdAndIdempotencyKey(userId, idempotencyKey) + .orElseThrow(() -> ex); + return replay(existing, requestHash); + } + throw ex; + } + return toResponse(history); + } + + private SalaryCalculationResponse replay(SalaryCalculationHistory history, String requestHash) { + if (!Objects.equals(history.getRequestHash(), requestHash)) { + throw new BusinessException(ErrorCode.SALARY_IDEMPOTENCY_CONFLICT); + } + return toResponse(history); + } + + private SalaryCalculationResponse toResponse(SalaryCalculationHistory history) { + try { + SalaryCalculationResult result = objectMapper.readValue( + history.getResultSnapshotJson(), SalaryCalculationResult.class); + return SalaryCalculationResponse.builder() + .historyId(history.getId()) + .createdAt(history.getCreatedAt()) + .result(result) + .build(); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("stored salary result snapshot is invalid", ex); + } + } + + private SalaryCalculationInput toEngineInput(SalaryCalculationRequest request) { + SalaryCalculationOverrideRequest source = request.getOverrides(); + SalaryOverrideParameters overrides = source == null ? null : SalaryOverrideParameters.builder() + .socialSecurityBase(source.getSocialSecurityBase()) + .housingFundBase(source.getHousingFundBase()) + .pensionRate(source.getPensionRate()) + .medicalRate(source.getMedicalRate()) + .unemploymentRate(source.getUnemploymentRate()) + .housingFundRate(source.getHousingFundRate()) + .seriousIllnessMonthlyFee(source.getSeriousIllnessMonthlyFee()) + .build(); + return SalaryCalculationInput.builder() + .grossMonthlySalary(request.getGrossMonthlySalary()) + .monthIndex(request.getMonthIndex()) + .specialAdditionalDeduction(request.getSpecialAdditionalDeduction()) + .overrides(overrides) + .build(); + } + + private Map inputSnapshot(SalaryCalculationRequest request) { + Map input = new LinkedHashMap<>(); + input.put("cityCode", request.getCityCode()); + input.put("grossMonthlySalary", request.getGrossMonthlySalary()); + input.put("monthIndex", request.getMonthIndex() == null ? 1 : request.getMonthIndex()); + input.put("specialAdditionalDeduction", request.getSpecialAdditionalDeduction()); + input.put("expectedPolicyVersion", request.getExpectedPolicyVersion()); + input.put("overrides", request.getOverrides()); + return input; + } + + private Map policySnapshot( + SalaryCityPolicy policy, SalaryCalculationResult result) { + Map snapshot = new LinkedHashMap<>(); + snapshot.put("cityCode", policy.getCityCode()); + snapshot.put("cityName", policy.getCityName()); + snapshot.put("dataLevel", policy.getDataLevel()); + snapshot.put("policyVersion", policy.getPolicyVersion()); + snapshot.put("policyEffectiveDate", policy.getPolicyEffectiveDate()); + snapshot.put("defaultParameters", Map.of( + "socialSecurityBaseMin", policy.getSocialSecurityBaseMin(), + "socialSecurityBaseMax", policy.getSocialSecurityBaseMax(), + "housingFundBaseMin", policy.getHousingFundBaseMin(), + "housingFundBaseMax", policy.getHousingFundBaseMax(), + "pensionRate", policy.getPensionRate(), + "medicalRate", policy.getMedicalRate(), + "unemploymentRate", policy.getUnemploymentRate(), + "housingFundRate", policy.getHousingFundRate(), + "seriousIllnessMonthlyFee", policy.getSeriousIllnessMonthlyFee())); + snapshot.put("effectiveParameters", Map.of( + "socialSecurityBase", result.getSocialSecurityBase(), + "housingFundBase", result.getHousingFundBase(), + "pensionRate", result.getPensionRate(), + "medicalRate", result.getMedicalRate(), + "unemploymentRate", result.getUnemploymentRate(), + "housingFundRate", result.getHousingFundRate(), + "seriousIllnessMonthlyFee", result.getSeriousIllnessMonthlyFee())); + return snapshot; + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("unable to serialize salary snapshot", ex); + } + } + + private String normalizeIdempotencyKey(String value) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim(); + if (normalized.length() > 64) { + throw new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, "idempotencyKey is too long"); + } + return normalized; + } + + private void requireStudent(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles() == null + || user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryCleanupService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryCleanupService.java new file mode 100644 index 0000000..8cbb00a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryCleanupService.java @@ -0,0 +1,18 @@ +package com.mydreamjob.salarycalculator; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SalaryHistoryCleanupService { + + private final SalaryCalculationHistoryRepository historyRepository; + + @Transactional(propagation = Propagation.MANDATORY) + public long purgeForDeletedUser(Long userId) { + return historyRepository.deleteByStudentUserId(userId); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryService.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryService.java new file mode 100644 index 0000000..a7da223 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryHistoryService.java @@ -0,0 +1,106 @@ +package com.mydreamjob.salarycalculator; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.common.api.PageResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; +import com.mydreamjob.salarycalculator.dto.SalaryHistoryDetailResponse; +import com.mydreamjob.salarycalculator.dto.SalaryHistorySummaryResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SalaryHistoryService { + + private final SalaryCalculationHistoryRepository historyRepository; + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + + @Transactional(readOnly = true) + public PageResponse list(Long userId, int page, int size) { + requireStudent(userId); + if (page < 0 || size < 1 || size > 100) { + throw new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, "invalid pagination"); + } + Page result = historyRepository + .findByStudentUserIdOrderByCreatedAtDescIdDesc(userId, PageRequest.of(page, size)); + return PageResponse.of( + result.getContent().stream().map(this::toSummary).toList(), + result.getTotalElements(), page, size); + } + + @Transactional(readOnly = true) + public SalaryHistoryDetailResponse get(Long userId, Long historyId) { + requireStudent(userId); + SalaryCalculationHistory history = ownHistory(userId, historyId); + return SalaryHistoryDetailResponse.builder() + .id(history.getId()) + .cityCode(history.getCityCode()) + .cityName(history.getCityName()) + .dataLevel(history.getDataLevel()) + .policyVersion(history.getPolicyVersion()) + .taxRulesVersion(history.getTaxRulesVersion()) + .monthIndex(history.getMonthIndex()) + .grossMonthlySalary(history.getGrossMonthlySalary()) + .netSalary(history.getNetSalary()) + .createdAt(history.getCreatedAt()) + .inputSnapshot(readJson(history.getInputSnapshotJson())) + .policySnapshot(readJson(history.getPolicySnapshotJson())) + .resultSnapshot(readJson(history.getResultSnapshotJson())) + .build(); + } + + @Transactional + public void delete(Long userId, Long historyId) { + requireStudent(userId); + SalaryCalculationHistory history = ownHistory(userId, historyId); + historyRepository.delete(history); + historyRepository.flush(); + } + + private SalaryHistorySummaryResponse toSummary(SalaryCalculationHistory history) { + return SalaryHistorySummaryResponse.builder() + .id(history.getId()) + .cityCode(history.getCityCode()) + .cityName(history.getCityName()) + .dataLevel(history.getDataLevel()) + .grossMonthlySalary(history.getGrossMonthlySalary()) + .netSalary(history.getNetSalary()) + .monthIndex(history.getMonthIndex()) + .createdAt(history.getCreatedAt()) + .build(); + } + + private SalaryCalculationHistory ownHistory(Long userId, Long historyId) { + if (historyId == null) { + throw new BusinessException(ErrorCode.SALARY_HISTORY_NOT_FOUND); + } + return historyRepository.findByIdAndStudentUserId(historyId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.SALARY_HISTORY_NOT_FOUND)); + } + + private JsonNode readJson(String value) { + try { + return objectMapper.readTree(value); + } catch (JsonProcessingException ex) { + throw new IllegalStateException("stored salary snapshot is invalid", ex); + } + } + + private void requireStudent(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + if (user.getRoles() == null + || user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyVersionConflictException.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyVersionConflictException.java new file mode 100644 index 0000000..4da720c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryPolicyVersionConflictException.java @@ -0,0 +1,23 @@ +package com.mydreamjob.salarycalculator; + +import java.time.LocalDate; + +public class SalaryPolicyVersionConflictException extends RuntimeException { + + private final String latestPolicyVersion; + private final LocalDate latestPolicyEffectiveDate; + + public SalaryPolicyVersionConflictException(String latestPolicyVersion, LocalDate latestPolicyEffectiveDate) { + super("salary city policy version conflict"); + this.latestPolicyVersion = latestPolicyVersion; + this.latestPolicyEffectiveDate = latestPolicyEffectiveDate; + } + + public String getLatestPolicyVersion() { + return latestPolicyVersion; + } + + public LocalDate getLatestPolicyEffectiveDate() { + return latestPolicyEffectiveDate; + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryRequestFingerprint.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryRequestFingerprint.java new file mode 100644 index 0000000..62fe071 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/SalaryRequestFingerprint.java @@ -0,0 +1,68 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.salarycalculator.dto.SalaryCalculationOverrideRequest; +import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +@Component +public class SalaryRequestFingerprint { + + public String fingerprint(SalaryCalculationRequest request) { + StringBuilder canonical = new StringBuilder(); + append(canonical, normalize(request.getCityCode())); + append(canonical, normalize(request.getExpectedPolicyVersion())); + append(canonical, decimal(request.getGrossMonthlySalary())); + append(canonical, String.valueOf(request.getMonthIndex() == null ? 1 : request.getMonthIndex())); + append(canonical, decimal(request.getSpecialAdditionalDeduction() == null + ? BigDecimal.ZERO : request.getSpecialAdditionalDeduction())); + SalaryCalculationOverrideRequest overrides = request.getOverrides(); + if (overrides == null) { + for (int i = 0; i < 7; i++) { + append(canonical, ""); + } + } else { + append(canonical, decimal(overrides.getSocialSecurityBase())); + append(canonical, decimal(overrides.getHousingFundBase())); + append(canonical, decimal(overrides.getPensionRate())); + append(canonical, decimal(overrides.getMedicalRate())); + append(canonical, decimal(overrides.getUnemploymentRate())); + append(canonical, decimal(overrides.getHousingFundRate())); + append(canonical, decimal(overrides.getSeriousIllnessMonthlyFee())); + } + return sha256(canonical.toString()); + } + + private void append(StringBuilder builder, String value) { + builder.append(value.length()).append(':').append(value).append('|'); + } + + private String normalize(String value) { + return value == null ? "" : value.trim(); + } + + private String decimal(BigDecimal value) { + if (value == null) { + return ""; + } + BigDecimal normalized = value.stripTrailingZeros(); + if (normalized.scale() < 0) { + normalized = normalized.setScale(0); + } + return normalized.toPlainString(); + } + + private String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is unavailable", ex); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationOverrideRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationOverrideRequest.java new file mode 100644 index 0000000..407f27c --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationOverrideRequest.java @@ -0,0 +1,53 @@ +package com.mydreamjob.salarycalculator.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCalculationOverrideRequest { + + @DecimalMin(value = "0.00") + @DecimalMax(value = "10000000.00") + @Digits(integer = 8, fraction = 2) + private BigDecimal socialSecurityBase; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "10000000.00") + @Digits(integer = 8, fraction = 2) + private BigDecimal housingFundBase; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "0.20") + @Digits(integer = 1, fraction = 6) + private BigDecimal pensionRate; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "0.20") + @Digits(integer = 1, fraction = 6) + private BigDecimal medicalRate; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "0.10") + @Digits(integer = 1, fraction = 6) + private BigDecimal unemploymentRate; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "0.12") + @Digits(integer = 1, fraction = 6) + private BigDecimal housingFundRate; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "10000.00") + @Digits(integer = 5, fraction = 2) + private BigDecimal seriousIllnessMonthlyFee; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationRequest.java new file mode 100644 index 0000000..ee141ac --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationRequest.java @@ -0,0 +1,51 @@ +package com.mydreamjob.salarycalculator.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCalculationRequest { + + @NotBlank + private String cityCode; + + @NotNull + @DecimalMin(value = "0.01") + @DecimalMax(value = "10000000.00") + @Digits(integer = 8, fraction = 2) + private BigDecimal grossMonthlySalary; + + @Min(1) + @Max(12) + private Integer monthIndex; + + @DecimalMin(value = "0.00") + @DecimalMax(value = "10000000.00") + @Digits(integer = 8, fraction = 2) + private BigDecimal specialAdditionalDeduction; + + @NotBlank + private String expectedPolicyVersion; + + @Size(max = 64) + private String idempotencyKey; + + @Valid + private SalaryCalculationOverrideRequest overrides; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationResponse.java new file mode 100644 index 0000000..dd3685d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryCalculationResponse.java @@ -0,0 +1,19 @@ +package com.mydreamjob.salarycalculator.dto; + +import com.mydreamjob.salarycalculator.SalaryCalculationResult; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryCalculationResponse { + private Long historyId; + private LocalDateTime createdAt; + private SalaryCalculationResult result; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistoryDetailResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistoryDetailResponse.java new file mode 100644 index 0000000..c474f19 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistoryDetailResponse.java @@ -0,0 +1,31 @@ +package com.mydreamjob.salarycalculator.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import com.mydreamjob.salarycalculator.SalaryDataLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryHistoryDetailResponse { + private Long id; + private String cityCode; + private String cityName; + private SalaryDataLevel dataLevel; + private String policyVersion; + private String taxRulesVersion; + private Integer monthIndex; + private BigDecimal grossMonthlySalary; + private BigDecimal netSalary; + private LocalDateTime createdAt; + private JsonNode inputSnapshot; + private JsonNode policySnapshot; + private JsonNode resultSnapshot; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistorySummaryResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistorySummaryResponse.java new file mode 100644 index 0000000..87ea92a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/salarycalculator/dto/SalaryHistorySummaryResponse.java @@ -0,0 +1,25 @@ +package com.mydreamjob.salarycalculator.dto; + +import com.mydreamjob.salarycalculator.SalaryDataLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SalaryHistorySummaryResponse { + private Long id; + private String cityCode; + private String cityName; + private SalaryDataLevel dataLevel; + private BigDecimal grossMonthlySalary; + private BigDecimal netSalary; + private Integer monthIndex; + private LocalDateTime createdAt; +} diff --git a/mydreamjob-server/src/main/resources/db/migration/V40__salary_calculator_schema.sql b/mydreamjob-server/src/main/resources/db/migration/V40__salary_calculator_schema.sql index 44c2675..cd1ab3d 100644 --- a/mydreamjob-server/src/main/resources/db/migration/V40__salary_calculator_schema.sql +++ b/mydreamjob-server/src/main/resources/db/migration/V40__salary_calculator_schema.sql @@ -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='薪资计算历史快照'; + diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAccountDeletionIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAccountDeletionIntegrationTest.java new file mode 100644 index 0000000..4b9eb03 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAccountDeletionIntegrationTest.java @@ -0,0 +1,81 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import com.mydreamjob.deletion.AccountDeletion; +import com.mydreamjob.deletion.AccountDeletionCompletionWorker; +import com.mydreamjob.deletion.AccountDeletionRepository; +import com.mydreamjob.deletion.DeletionStatus; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_deletion;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class SalaryAccountDeletionIntegrationTest { + + private static final AtomicLong PHONE = new AtomicLong(970_000_000L); + + @Autowired private UserRepository userRepository; + @Autowired private SalaryCalculationHistoryRepository historyRepository; + @Autowired private AccountDeletionRepository accountDeletionRepository; + @Autowired private AccountDeletionCompletionWorker completionWorker; + + @MockBean private RedisTemplate redisTemplate; + + @Test + void completedDeletionPurgesSalaryHistory() { + User user = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(User.Role.STUDENT)) + .role(User.Role.STUDENT) + .active(true) + .build()); + historyRepository.save(history(user.getId())); + accountDeletionRepository.save(AccountDeletion.builder() + .userId(user.getId()) + .status(DeletionStatus.PENDING) + .requestedAt(LocalDateTime.now().minusDays(31)) + .effectiveAt(LocalDateTime.now().minusMinutes(1)) + .version(0L) + .build()); + + assertThat(completionWorker.completeIfExpired(user.getId())).isTrue(); + + assertThat(historyRepository.findByStudentUserIdOrderByCreatedAtDescIdDesc( + user.getId(), PageRequest.of(0, 20)).getTotalElements()).isZero(); + assertThat(userRepository.findById(user.getId()).orElseThrow().getPhone()) + .startsWith("deleted_"); + } + + private SalaryCalculationHistory history(Long userId) { + return SalaryCalculationHistory.builder() + .studentUserId(userId) + .cityCode("110000") + .cityName("北京市") + .dataLevel(SalaryDataLevel.CITY) + .policyVersion("2026-09-20-v1") + .taxRulesVersion("CN_IIT_2019_V1") + .monthIndex(1) + .grossMonthlySalary(new BigDecimal("10000.00")) + .netSalary(new BigDecimal("7667.50")) + .inputSnapshotJson("{}") + .policySnapshotJson("{}") + .resultSnapshotJson("{}") + .build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAuthorizationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAuthorizationIntegrationTest.java new file mode 100644 index 0000000..4afb135 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryAuthorizationIntegrationTest.java @@ -0,0 +1,82 @@ +package com.mydreamjob.salarycalculator; + +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_auth;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class SalaryAuthorizationIntegrationTest { + + private static final AtomicLong PHONE = new AtomicLong(980_000_000L); + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private SalaryCalculationHistoryRepository historyRepository; + + @MockBean private RedisTemplate redisTemplate; + + @Test + void anonymousCannotUseSalaryCalculator() throws Exception { + mockMvc.perform(get("/salary-calculator/calculations")) + .andExpect(status().isUnauthorized()); + } + + @Test + void companyCannotCalculateOrReadHistory() throws Exception { + User company = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(User.Role.COMPANY)) + .role(User.Role.COMPANY) + .active(true) + .build()); + String token = token(company.getId()); + + String body = "{\"cityCode\":\"110000\",\"grossMonthlySalary\":10000," + + "\"monthIndex\":1,\"specialAdditionalDeduction\":0," + + "\"expectedPolicyVersion\":\"2026-09-20-v1\"}"; + + mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + + mockMvc.perform(get("/salary-calculator/calculations") + .header("Authorization", token)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(2002)); + + assertThat(historyRepository.count()).isZero(); + } + + private String token(long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken( + userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "COMPANY")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java new file mode 100644 index 0000000..492f857 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryCalculationEndToEndIntegrationTest.java @@ -0,0 +1,168 @@ +package com.mydreamjob.salarycalculator; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mydreamjob.auth.JwtTokenProvider; +import com.mydreamjob.auth.User; +import com.mydreamjob.auth.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("integration-test") +@TestPropertySource(properties = + "spring.datasource.url=jdbc:h2:mem:integrationdb_salary_e2e;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") +class SalaryCalculationEndToEndIntegrationTest { + + private static final AtomicLong PHONE = new AtomicLong(960_000_000L); + + @Autowired private MockMvc mockMvc; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private UserRepository userRepository; + @Autowired private SalaryCalculationHistoryRepository historyRepository; + private final ObjectMapper mapper = new ObjectMapper(); + + @MockBean private RedisTemplate redisTemplate; + + @Test + void calculateReplayConflictHistoryAndDelete() throws Exception { + long uid = createUser(User.Role.STUDENT); + String token = token(uid); + String request = requestJson("110000", "10000", 1, "0", "key-1"); + + String first = mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.result.cityCode").value("110000")) + .andExpect(jsonPath("$.data.result.dataLevel").value("CITY")) + .andExpect(jsonPath("$.data.result.estimate").value(true)) + .andExpect(jsonPath("$.data.result.netSalary").value(7667.50)) + .andReturn().getResponse().getContentAsString(); + + long historyId = mapper.readTree(first).path("data").path("historyId").asLong(); + assertThat(historyRepository.count()).isEqualTo(1); + + String replay = mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + assertThat(mapper.readTree(replay).path("data").path("historyId").asLong()) + .isEqualTo(historyId); + assertThat(historyRepository.count()).isEqualTo(1); + + mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson("110000", "12000", 1, "0", "key-1"))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9804)); + + mockMvc.perform(get("/salary-calculator/calculations") + .header("Authorization", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value(historyId)); + + mockMvc.perform(get("/salary-calculator/calculations/{id}", historyId) + .header("Authorization", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.inputSnapshot.cityCode").value("110000")) + .andExpect(jsonPath("$.data.policySnapshot.dataLevel").value("CITY")) + .andExpect(jsonPath("$.data.resultSnapshot.netSalary").value(7667.50)); + + long otherId = createUser(User.Role.STUDENT); + mockMvc.perform(get("/salary-calculator/calculations/{id}", historyId) + .header("Authorization", token(otherId))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(9803)); + + mockMvc.perform(delete("/salary-calculator/calculations/{id}", historyId) + .header("Authorization", token)) + .andExpect(status().isOk()); + assertThat(historyRepository.findById(historyId)).isEmpty(); + } + + @Test + void policyVersionConflictReturnsLatestVersionWithoutHistory() throws Exception { + long uid = createUser(User.Role.STUDENT); + String request = requestJson("110000", "10000", 1, "0", "version-key") + .replace("2026-09-20-v1", "stale-version"); + + mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(9805)) + .andExpect(jsonPath("$.data.latestPolicyVersion").value("2026-09-20-v1")) + .andExpect(jsonPath("$.data.policyEffectiveDate").value("2026-07-01")); + + assertThat(historyRepository.count()).isZero(); + } + + @Test + void invalidOverrideReturns9802() throws Exception { + long uid = createUser(User.Role.STUDENT); + String request = "{\"cityCode\":\"110000\",\"grossMonthlySalary\":10000," + + "\"monthIndex\":1,\"specialAdditionalDeduction\":0," + + "\"expectedPolicyVersion\":\"2026-09-20-v1\"," + + "\"idempotencyKey\":\"bad-override\"," + + "\"overrides\":{\"socialSecurityBase\":1000}}"; + + mockMvc.perform(post("/salary-calculator/calculations") + .header("Authorization", token(uid)) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(9802)); + } + + private String requestJson( + String cityCode, String gross, int monthIndex, String special, String idempotencyKey) { + return "{\"cityCode\":\"" + cityCode + "\"," + + "\"grossMonthlySalary\":" + gross + "," + + "\"monthIndex\":" + monthIndex + "," + + "\"specialAdditionalDeduction\":" + special + "," + + "\"expectedPolicyVersion\":\"2026-09-20-v1\"," + + "\"idempotencyKey\":\"" + idempotencyKey + "\"," + + "\"overrides\":null}"; + } + + private long createUser(User.Role role) { + User user = userRepository.save(User.builder() + .phone(String.valueOf(PHONE.getAndIncrement())) + .roles(Set.of(role)) + .role(role) + .active(true) + .build()); + return user.getId(); + } + + private String token(long userId) { + return "Bearer " + jwtTokenProvider.generateAccessToken( + userId, String.valueOf(PHONE.getAndIncrement()), Map.of("roles", "STUDENT")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java index 6a4fd73..3246a49 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryMigrationTest.java +++ b/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 { } } + diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryRepositoryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryRepositoryIntegrationTest.java index a4553c0..71888d0 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryRepositoryIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/salarycalculator/SalaryRepositoryIntegrationTest.java @@ -130,3 +130,4 @@ class SalaryRepositoryIntegrationTest { } +