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