From 91b6574d53193bc96471603bbbac3780a93c2890 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 10:56:06 +0800 Subject: [PATCH] fix(finance): canonicalize wallet and reconciliation command hashes --- .../service/FinancePhase1OperationService.java | 10 +++++++++- .../baiyue/finance/service/FinanceService.java | 10 +++++++++- .../finance/service/FinanceWalletService.java | 4 +++- .../service/ReconciliationStatementService.java | 9 +++++++-- .../FinanceSettlementRequestHashTest.java | 16 ++++++++++++---- 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index 1a64ca3a3..e9d3f6e1f 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest; import com.baiyue.finance.client.FirstlegFeeSourceClient; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import feign.FeignException; import org.springframework.beans.factory.annotation.Autowired; 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 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 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 Long jsonLong(JsonNode node, String field) { JsonNode value = node.path(field); diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java index b707dffec..a81c66cd2 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceService.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.KeyHolder; @@ -306,7 +307,14 @@ public class FinanceService { 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 BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java index 970f1c44c..c5a4349c8 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceWalletService.java @@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest; import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry; import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.ResponseEntity; @@ -694,8 +695,9 @@ public class FinanceWalletService { timestamp(rs.getTimestamp("reviewed_at"))); } + /** 摘要固定映射键序,保证充值和资金指令跨进程重放稳定。 */ 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", "请求摘要生成失败"); } } private String fileHash(MultipartFile file) { diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java index 0085f28e3..34c5c62eb 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -10,6 +10,7 @@ import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine; import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import org.slf4j.MDC; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; @@ -521,9 +522,13 @@ public class ReconciliationStatementService { catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); } } + /** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */ private String hash(Object value) { - try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } - catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); } + 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_001", "幂等请求哈希失败"); } } private static LocalDateTime localTime(Object value) { diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementRequestHashTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementRequestHashTest.java index e3d231d2c..2db9805fa 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementRequestHashTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementRequestHashTest.java @@ -2,7 +2,12 @@ package com.baiyue.finance; import com.baiyue.finance.service.FinanceSettlementService; 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 java.util.LinkedHashMap; @@ -12,9 +17,12 @@ import static org.assertj.core.api.Assertions.assertThat; class FinanceSettlementRequestHashTest { /** 模拟不同 JVM 的 Map 遍历顺序;同一付款请求必须得到相同摘要。 */ - @Test - void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment() { - var service = new FinanceSettlementService(null, new ObjectMapper(), null, null, null); + @ParameterizedTest + @ValueSource(classes = {FinanceSettlementService.class, FinanceWalletService.class, + 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 body = new LinkedHashMap<>(); body.put("amount", 190); body.put("expectedVersion", 0); Map first = new LinkedHashMap<>();