You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

190 lines
9.2 KiB

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<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.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);
}
}
}