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.stereotype.Service; import org.springframework.transaction.annotation.Isolation; 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; private final SalaryPolicyPublicationProperties publication; @Transactional(isolation = Isolation.READ_COMMITTED) public SalaryCalculationResponse calculateAndSave(Long userId, SalaryCalculationRequest request) { String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); String requestHash = idempotencyKey == null ? null : requestFingerprint.fingerprint(request); // 在读取幂等记录之前锁定用户,跨实例串行化同一用户的写入;锁随事务结束释放。 requireStudent(userId); SalaryCityPolicy policy = cityPolicyRepository.findByCityCodeAndEnabledTrue(request.getCityCode()) .orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); requirePublished(policy.getCityCode(), policy.getPolicyVersion()); if (idempotencyKey != null) { var existing = historyRepository.findByStudentUserIdAndIdempotencyKey(userId, idempotencyKey); if (existing.isPresent()) { requirePublished(existing.get().getCityCode(), existing.get().getPolicyVersion()); return replay(existing.get(), requestHash); } } 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(); history = historyRepository.saveAndFlush(history); 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 void requirePublished(String cityCode, String policyVersion) { if (!publication.isPublished(cityCode, policyVersion)) { throw new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND); } } private SalaryCalculationResponse toResponse(SalaryCalculationHistory history) { try { SalaryCalculationResult result = objectMapper.readValue( history.getResultSnapshotJson(), SalaryCalculationResult.class); return SalaryCalculationResponse.builder() .historyId(history.getId()) .createdAt(history.getCreatedAt()) .result(result) .build(); } catch (JsonProcessingException ex) { throw new IllegalStateException("stored salary result snapshot is invalid", ex); } } private SalaryCalculationInput toEngineInput(SalaryCalculationRequest request) { SalaryCalculationOverrideRequest source = request.getOverrides(); SalaryOverrideParameters overrides = source == null ? null : SalaryOverrideParameters.builder() .socialSecurityBase(source.getSocialSecurityBase()) .housingFundBase(source.getHousingFundBase()) .pensionRate(source.getPensionRate()) .medicalRate(source.getMedicalRate()) .unemploymentRate(source.getUnemploymentRate()) .housingFundRate(source.getHousingFundRate()) .seriousIllnessMonthlyFee(source.getSeriousIllnessMonthlyFee()) .build(); return SalaryCalculationInput.builder() .grossMonthlySalary(request.getGrossMonthlySalary()) .monthIndex(request.getMonthIndex()) .specialAdditionalDeduction(request.getSpecialAdditionalDeduction()) .overrides(overrides) .build(); } private Map inputSnapshot(SalaryCalculationRequest request) { Map input = new LinkedHashMap<>(); input.put("cityCode", request.getCityCode()); input.put("grossMonthlySalary", request.getGrossMonthlySalary()); input.put("monthIndex", request.getMonthIndex() == null ? 1 : request.getMonthIndex()); input.put("specialAdditionalDeduction", request.getSpecialAdditionalDeduction()); input.put("expectedPolicyVersion", request.getExpectedPolicyVersion()); input.put("overrides", request.getOverrides()); return input; } private Map policySnapshot( SalaryCityPolicy policy, SalaryCalculationResult result) { Map snapshot = new LinkedHashMap<>(); snapshot.put("cityCode", policy.getCityCode()); snapshot.put("cityName", policy.getCityName()); snapshot.put("dataLevel", policy.getDataLevel()); snapshot.put("policyVersion", policy.getPolicyVersion()); snapshot.put("policyEffectiveDate", policy.getPolicyEffectiveDate()); snapshot.put("defaultParameters", Map.of( "socialSecurityBaseMin", policy.getSocialSecurityBaseMin(), "socialSecurityBaseMax", policy.getSocialSecurityBaseMax(), "housingFundBaseMin", policy.getHousingFundBaseMin(), "housingFundBaseMax", policy.getHousingFundBaseMax(), "pensionRate", policy.getPensionRate(), "medicalRate", policy.getMedicalRate(), "unemploymentRate", policy.getUnemploymentRate(), "housingFundRate", policy.getHousingFundRate(), "seriousIllnessMonthlyFee", policy.getSeriousIllnessMonthlyFee())); snapshot.put("effectiveParameters", Map.of( "socialSecurityBase", result.getSocialSecurityBase(), "housingFundBase", result.getHousingFundBase(), "pensionRate", result.getPensionRate(), "medicalRate", result.getMedicalRate(), "unemploymentRate", result.getUnemploymentRate(), "housingFundRate", result.getHousingFundRate(), "seriousIllnessMonthlyFee", result.getSeriousIllnessMonthlyFee())); return snapshot; } private String toJson(Object value) { try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException ex) { throw new IllegalStateException("unable to serialize salary snapshot", ex); } } private String normalizeIdempotencyKey(String value) { if (value == null || value.isBlank()) { return null; } String normalized = value.trim(); if (normalized.length() > 64) { throw new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, "idempotencyKey is too long"); } return normalized; } private void requireStudent(Long userId) { User user = userRepository.findByIdForUpdate(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); } } }