fix(finance): canonicalize wallet and reconciliation command hashes
This commit is contained in:
@@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
|
|||||||
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
import com.baiyue.finance.client.FirstlegFeeSourceClient;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import feign.FeignException;
|
import feign.FeignException;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -437,7 +438,14 @@ public class FinancePhase1OperationService {
|
|||||||
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
|
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
|
||||||
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
|
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
|
||||||
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
|
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
|
||||||
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
|
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
||||||
|
private String hash(Object value) {
|
||||||
|
try {
|
||||||
|
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||||
|
.writeValueAsBytes(value);
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||||
|
} catch (Exception failure) { throw error("FIN-DATA-500", "幂等请求哈希失败"); }
|
||||||
|
}
|
||||||
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
|
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
|
||||||
private static Long jsonLong(JsonNode node, String field) {
|
private static Long jsonLong(JsonNode node, String field) {
|
||||||
JsonNode value = node.path(field);
|
JsonNode value = node.path(field);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
import org.springframework.jdbc.support.KeyHolder;
|
import org.springframework.jdbc.support.KeyHolder;
|
||||||
@@ -306,7 +307,14 @@ public class FinanceService {
|
|||||||
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
|
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } }
|
/** 固定请求映射键序,避免服务重启后拒绝同一财务命令。 */
|
||||||
|
private String hash(Object request) {
|
||||||
|
try {
|
||||||
|
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||||
|
.writeValueAsBytes(request == null ? List.of() : request);
|
||||||
|
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||||
|
} catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); }
|
||||||
|
}
|
||||||
|
|
||||||
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
|
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
|
||||||
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
|
|||||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
|
||||||
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -694,8 +695,9 @@ public class FinanceWalletService {
|
|||||||
timestamp(rs.getTimestamp("reviewed_at")));
|
timestamp(rs.getTimestamp("reviewed_at")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 摘要固定映射键序,保证充值和资金指令跨进程重放稳定。 */
|
||||||
private String hash(Object value) {
|
private String hash(Object value) {
|
||||||
try { return sha256(objectMapper.writeValueAsBytes(value)); }
|
try { return sha256(objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value)); }
|
||||||
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
|
||||||
}
|
}
|
||||||
private String fileHash(MultipartFile file) {
|
private String fileHash(MultipartFile file) {
|
||||||
|
|||||||
+7
-2
@@ -10,6 +10,7 @@ import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
|
|||||||
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
|
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||||
import org.slf4j.MDC;
|
import org.slf4j.MDC;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
import org.springframework.jdbc.support.GeneratedKeyHolder;
|
||||||
@@ -521,9 +522,13 @@ public class ReconciliationStatementService {
|
|||||||
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
|
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
|
||||||
private String hash(Object value) {
|
private String hash(Object value) {
|
||||||
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
|
try {
|
||||||
catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); }
|
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
|
||||||
|
.writeValueAsBytes(value);
|
||||||
|
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
|
||||||
|
} catch (Exception failure) { throw error("FIN_001", "幂等请求哈希失败"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LocalDateTime localTime(Object value) {
|
private static LocalDateTime localTime(Object value) {
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ package com.baiyue.finance;
|
|||||||
|
|
||||||
import com.baiyue.finance.service.FinanceSettlementService;
|
import com.baiyue.finance.service.FinanceSettlementService;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import com.baiyue.finance.service.FinanceWalletService;
|
||||||
|
import com.baiyue.finance.service.FinancePhase1OperationService;
|
||||||
|
import com.baiyue.finance.service.FinanceService;
|
||||||
|
import com.baiyue.finance.service.ReconciliationStatementService;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -12,9 +17,12 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
|
|
||||||
class FinanceSettlementRequestHashTest {
|
class FinanceSettlementRequestHashTest {
|
||||||
/** 模拟不同 JVM 的 Map 遍历顺序;同一付款请求必须得到相同摘要。 */
|
/** 模拟不同 JVM 的 Map 遍历顺序;同一付款请求必须得到相同摘要。 */
|
||||||
@Test
|
@ParameterizedTest
|
||||||
void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment() {
|
@ValueSource(classes = {FinanceSettlementService.class, FinanceWalletService.class,
|
||||||
var service = new FinanceSettlementService(null, new ObjectMapper(), null, null, null);
|
FinancePhase1OperationService.class, FinanceService.class, ReconciliationStatementService.class})
|
||||||
|
void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment(Class<?> serviceType) {
|
||||||
|
Object service = org.mockito.Mockito.mock(serviceType, org.mockito.Mockito.CALLS_REAL_METHODS);
|
||||||
|
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||||
Map<String, Object> body = new LinkedHashMap<>();
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
body.put("amount", 190); body.put("expectedVersion", 0);
|
body.put("amount", 190); body.put("expectedVersion", 0);
|
||||||
Map<String, Object> first = new LinkedHashMap<>();
|
Map<String, Object> first = new LinkedHashMap<>();
|
||||||
|
|||||||
Reference in New Issue
Block a user