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 执行进度 |
|||
|
|||
## 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。 |
|||
|
|||
@ -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