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