This commit is contained in:
2026-08-20 09:43:31 +08:00
parent f25e270f22
commit 7cf4a8d562
25 changed files with 804 additions and 41 deletions
Vendored
+14 -6
View File
@@ -65,10 +65,18 @@ pipeline {
stage('确定构建范围') { stage('确定构建范围') {
steps { steps {
script { script {
// 开发环境总控 Job 固定部署到 development,避免首次构建沿用 ENVIRONMENT 的默认 test 值。
// 其他 Job(包括现有 backend-java 测试环境)仍完全沿用原有参数。
String resolvedEnvironment = env.JOB_BASE_NAME == 'backend-java-dev'
? 'development'
: params.ENVIRONMENT
env.RESOLVED_ENVIRONMENT = resolvedEnvironment
echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}"
String requestedRepository String requestedRepository
if (['development', 'test'].contains(params.ENVIRONMENT)) { if (['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) {
requestedRepository = "baiyue-firstleg-${params.ENVIRONMENT}" requestedRepository = "baiyue-firstleg-${env.RESOLVED_ENVIRONMENT}"
echo "${params.ENVIRONMENT} 环境使用本地构建镜像,不登录、不推送远程 Registry" echo "${env.RESOLVED_ENVIRONMENT} 环境使用本地构建镜像,不登录、不推送远程 Registry"
} else { } else {
requestedRepository = params.IMAGE_REPOSITORY?.trim() requestedRepository = params.IMAGE_REPOSITORY?.trim()
if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') {
@@ -147,7 +155,7 @@ pipeline {
stage('生产环境部署确认(仅 production 执行)') { stage('生产环境部署确认(仅 production 执行)') {
when { when {
allOf { allOf {
expression { params.DEPLOY && params.ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' } expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' && env.NO_SERVICE_CHANGE != 'true' }
} }
} }
steps { steps {
@@ -158,7 +166,7 @@ pipeline {
stage('测试数据库初始化(可选)') { stage('测试数据库初始化(可选)') {
when { when {
allOf { allOf {
expression { params.ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE } expression { env.RESOLVED_ENVIRONMENT == 'test' && params.INITIALIZE_TEST_DATABASE }
} }
} }
steps { steps {
@@ -183,7 +191,7 @@ pipeline {
parameters: [ parameters: [
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT), string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT),
string(name: 'IMAGE_TAG', value: env.IMAGE_TAG), string(name: 'IMAGE_TAG', value: env.IMAGE_TAG),
string(name: 'ENVIRONMENT', value: params.ENVIRONMENT), string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT),
booleanParam(name: 'DEPLOY', value: params.DEPLOY), booleanParam(name: 'DEPLOY', value: params.DEPLOY),
booleanParam(name: 'SKIP_TESTS', value: params.SKIP_TESTS), booleanParam(name: 'SKIP_TESTS', value: params.SKIP_TESTS),
string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY), string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY),
@@ -14691,7 +14691,8 @@ VALUES
(5205000, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), (5205000, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0),
(5205010, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), (5205010, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0),
(5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0), (5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0),
(5205012, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0) (5205012, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0),
(5305012, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0; ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0;
DELETE rp FROM baiyue_iam.iam_role_permission rp DELETE rp FROM baiyue_iam.iam_role_permission rp
@@ -15292,6 +15293,13 @@ SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permissi
WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN')
AND p.permission_code='merchant:finance:statement:confirm'; AND p.permission_code='merchant:finance:statement:confirm';
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT'
WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER'
AND r.status='ACTIVE' AND r.deleted=0
AND p.permission_code='merchant:finance:statement:confirm'
AND p.status='ACTIVE' AND p.deleted=0;
INSERT INTO baiyue_iam.iam_permission INSERT INTO baiyue_iam.iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
VALUES VALUES
@@ -15514,6 +15522,9 @@ BEGIN
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 3 THEN IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 查询与确认权限不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 查询与确认权限不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='MERCHANT' AND permission_code='merchant:finance:statement:confirm' AND status='ACTIVE' AND deleted=0) <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局对账单确认权限不完整';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33 THEN IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整';
END IF; END IF;
@@ -15523,6 +15534,9 @@ BEGIN
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 4 THEN IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 4 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 查询与确认授权不完整'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 查询与确认授权不完整';
END IF; END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 AND p.permission_code='merchant:finance:statement:confirm' AND p.status='ACTIVE' AND p.deleted=0) <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局管理员缺少对账单确认权限';
END IF;
END$$ END$$
DELIMITER ; DELIMITER ;
CALL assert_finance_permissions(); CALL assert_finance_permissions();
@@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient; import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
@@ -24,6 +26,20 @@ public interface FirstlegFeeSourceClient {
@PathVariable long containerId, @PathVariable long feeId, @PathVariable long containerId, @PathVariable long feeId,
@RequestParam int sourceVersion); @RequestParam int sourceVersion);
@PostMapping("/fee-sources/commands/void")
ApiResponse<CommandResult> voidSource(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestHeader("X-Request-Hash") String requestHash,
@RequestBody VoidCommand request);
record VoidCommand(String sourceType, long sourceFeeId, int sourceVersion,
long customerId, Long inboundId, Long containerId,
long operatorId, String operatorName, String reason) {}
record CommandResult(String sourceType, long sourceFeeId, int sourceVersion,
String status, boolean replayed) {}
record Snapshot(long tenantId, String sourceType, long feeId, int feeVersion, record Snapshot(long tenantId, String sourceType, long feeId, int feeVersion,
Long inboundId, String inboundCode, Long orderId, Long containerId, Long inboundId, String inboundCode, Long orderId, Long containerId,
Long containerBookingId, String containerNo, long customerId, Long containerBookingId, String containerNo, long customerId,
@@ -121,6 +121,7 @@ public class FinancePhase1OperationController {
@PathVariable long feeSourceId, @PathVariable long feeSourceId,
@RequestHeader("Idempotency-Key") String key, @RequestHeader("Idempotency-Key") String key,
@Valid @RequestBody FeeSourceActionRequest request) { @Valid @RequestBody FeeSourceActionRequest request) {
return FinanceResponses.success(service.voidFinanceFeeSource(feeSourceId, user.userId(), key, request)); return FinanceResponses.success(service.voidFinanceFeeSource(
feeSourceId, user.userId(), user.username(), key, request));
} }
} }
@@ -72,7 +72,7 @@ public class FinanceFeeSourceEventService {
} }
long id = ((Number) row.get("id")).longValue(); long id = ((Number) row.get("id")).longValue();
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) { if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
jdbc.update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=?", id); jdbc.update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", id);
} }
return id; return id;
} }
@@ -9,8 +9,12 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
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 feign.FeignException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.slf4j.MDC; import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -36,10 +40,21 @@ public class FinancePhase1OperationService {
private static final String CUSTOMER_IDS = "SELECT tenant_id customer_id FROM fin_customer_settlement_profile UNION SELECT tenant_id FROM fin_bill UNION SELECT customer_id FROM fin_fee_source UNION SELECT tenant_id FROM fin_cost"; private static final String CUSTOMER_IDS = "SELECT tenant_id customer_id FROM fin_customer_settlement_profile UNION SELECT tenant_id FROM fin_bill UNION SELECT customer_id FROM fin_fee_source UNION SELECT tenant_id FROM fin_cost";
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final FirstlegFeeSourceClient firstleg;
private final String internalToken;
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) { @Autowired
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper,
FirstlegFeeSourceClient firstleg,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.jdbc = jdbc; this.jdbc = jdbc;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.firstleg = firstleg;
this.internalToken = internalToken;
}
public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this(jdbc, objectMapper, null, "");
} }
public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) { public PageEnvelope<AccountSummary> listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) {
@@ -164,6 +179,9 @@ public class FinancePhase1OperationService {
throw error("FIN-REQ-400", adjustment ? "费用调整请求无效" : "费用编辑请求无效"); throw error("FIN-REQ-400", adjustment ? "费用调整请求无效" : "费用编辑请求无效");
} }
FeeSourceDetail before = getFinanceFeeSource(feeSourceId); FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
if ("FIRSTLEG".equals(before.sourceSystem())) {
throw error("FIN-FEE-409", "Firstleg费用必须在入库或柜次费用明细中调整,Finance不可直接修改业务金额");
}
String operation = adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT"; String operation = adjustment ? "FEE_SOURCE_ADJUST" : "FEE_SOURCE_EDIT";
String path = "/api/platform/finance/v1/fee-sources/" + feeSourceId + (adjustment ? "/adjust" : ""); String path = "/api/platform/finance/v1/fee-sources/" + feeSourceId + (adjustment ? "/adjust" : "");
Long replay = reserve(before.customerId(), operatorId, operation, key, Long replay = reserve(before.customerId(), operatorId, operation, key,
@@ -191,10 +209,45 @@ public class FinancePhase1OperationService {
} }
@Transactional @Transactional
public FeeSourceDetail voidFinanceFeeSource(long feeSourceId, long operatorId, String key, public FeeSourceDetail voidFinanceFeeSource(long feeSourceId, long operatorId,
String operatorName, String key,
FeeSourceActionRequest request) { FeeSourceActionRequest request) {
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_VOID", if (request == null || request.expectedVersion() < 0 || !StringUtils.hasText(request.reason())) {
"VOIDED", List.of("DRAFT", "PENDING", "REJECTED", "APPROVED"), true); throw error("FIN-REQ-400", "费用操作请求无效");
}
FeeSourceDetail before = getFinanceFeeSource(feeSourceId);
if (!"FIRSTLEG".equals(before.sourceSystem())) {
return transitionFeeSource(feeSourceId, operatorId, key, request, "FEE_SOURCE_VOID",
"VOIDED", List.of("DRAFT", "PENDING", "REJECTED", "APPROVED"), true);
}
Long replay = reserve(before.customerId(), operatorId, "FEE_SOURCE_VOID", key, "POST",
"/api/platform/finance/v1/fee-sources/" + feeSourceId + "/void", request);
if (replay != null) return getFinanceFeeSource(replay);
if (before.statementGenerated()) throw error("FIN-FEE-409", "已生成单据的费用来源不可作废");
if (before.operationVersion() != request.expectedVersion()) {
throw error("FIN-FEE-409", "费用来源状态或版本已变化");
}
if (!List.of("DRAFT", "PENDING", "REJECTED", "APPROVED").contains(before.reviewStatus())
|| !"AVAILABLE".equals(before.sourceStatus())) {
throw error("FIN-FEE-409", "费用来源当前状态不允许作废");
}
FirstlegFeeSourceClient.VoidCommand command = new FirstlegFeeSourceClient.VoidCommand(
before.sourceType(), before.sourceFeeId(), before.sourceVersion(), before.customerId(),
before.inboundId(), before.containerId(), operatorId,
StringUtils.hasText(operatorName) ? operatorName.trim() : "SYSTEM", request.reason().trim());
try {
var response = firstleg.voidSource(internalToken, key, hash(command), command);
if (response == null || !response.successful() || response.data() == null
|| !"VOIDED".equals(response.data().status())) {
throw error("FIN-UPSTREAM-502", "Firstleg未确认费用来源作废");
}
} catch (FeignException exception) {
throw error("FIN-UPSTREAM-503", "Firstleg费用作废服务暂不可用");
}
audit(before.customerId(), feeSourceId, "FEE_SOURCE_VOID_REQUESTED",
request.reason(), before, before, operatorId);
complete(before.customerId(), operatorId, "FEE_SOURCE_VOID", key, feeSourceId);
return getFinanceFeeSource(feeSourceId);
} }
private FeeSourceDetail transitionFeeSource(long feeSourceId, long operatorId, String key, private FeeSourceDetail transitionFeeSource(long feeSourceId, long operatorId, String key,
@@ -121,7 +121,7 @@ class FinanceFeeSourceEventServiceTest {
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE"))); .thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
assertThat(service.consume(event)).isEqualTo(88L); assertThat(service.consume(event)).isEqualTo(88L);
verify(jdbc).update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=?", 88L); verify(jdbc).update("UPDATE fin_fee_source SET status='VOIDED',operation_version=operation_version+1 WHERE id=? AND status<>'VOIDED'", 88L);
verify(jdbc, never()).update(startsWith("DELETE"), any(Object[].class)); verify(jdbc, never()).update(startsWith("DELETE"), any(Object[].class));
} }
@@ -1,6 +1,8 @@
package com.baiyue.finance; package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.baiyue.finance.controller.FinancePhase1OperationController; import com.baiyue.finance.controller.FinancePhase1OperationController;
import com.baiyue.finance.dto.FinancePhase1Dtos; import com.baiyue.finance.dto.FinancePhase1Dtos;
import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
@@ -35,6 +37,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -57,6 +60,7 @@ class FinancePhase1OperationBehaviorTest {
controller.getFinanceFeeSource(88L); controller.getFinanceFeeSource(88L);
controller.reviewFinanceFeeSource(user, 88L, "review-key", review); controller.reviewFinanceFeeSource(user, 88L, "review-key", review);
controller.submitFinanceFeeSource(user, 88L, "submit-key", action); controller.submitFinanceFeeSource(user, 88L, "submit-key", action);
controller.voidFinanceFeeSource(user, 88L, "void-key", action);
verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L); verify(customerAccountService).list(1, 20, "柏悦", "ACTIVE", 9L);
verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any()); verify(operationService).listDerivedCustomerAccountLedger(eq(9L), eq(2), eq(50), eq("RECEIPT"), any(), any());
@@ -65,6 +69,7 @@ class FinancePhase1OperationBehaviorTest {
verify(operationService).getFinanceFeeSource(88L); verify(operationService).getFinanceFeeSource(88L);
verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review); verify(operationService).reviewFinanceFeeSource(88L, 71L, "review-key", review);
verify(operationService).submitFinanceFeeSource(88L, 71L, "submit-key", action); verify(operationService).submitFinanceFeeSource(88L, 71L, "submit-key", action);
verify(operationService).voidFinanceFeeSource(88L, 71L, "finance", "void-key", action);
} }
@Test @Test
@@ -220,29 +225,49 @@ class FinancePhase1OperationBehaviorTest {
} }
@Test @Test
void rejectedFeeCanBeEditedBackToDraftWithExpectedVersion() throws Exception { void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class, invocation -> JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1);
"update".equals(invocation.getMethod().getName())
? 1 : org.mockito.Mockito.RETURNS_DEFAULTS.answer(invocation));
AtomicInteger reads = new AtomicInteger();
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<FeeSourceDetail> mapper = invocation.getArgument(1);
boolean changed = reads.getAndIncrement() > 0;
return List.of(mapper.mapRow(resultSet(changed ? "DRAFT" : "REJECTED", false, changed ? 2 : 1), 0));
});
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_EDIT"), eq("edit-key"))).thenReturn(List.of());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
FeeSourceDetail result = service.editFinanceFeeSource(88L, 71L, "edit-key", var request = new FinancePhase1Dtos.FeeSourceEditRequest(
new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "更正金额", 1), false); new BigDecimal("120.00"), "更正金额", 1);
assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "edit-key", request, false))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Firstleg费用必须");
assertThatThrownBy(() -> service.editFinanceFeeSource(88L, 71L, "adjust-key", request, true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Firstleg费用必须");
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET amount="),
(Object[]) any());
}
assertThat(result.reviewStatus()).isEqualTo("DRAFT"); @Test
assertThat(result.operationVersion()).isEqualTo(2); void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET amount="), JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2);
eq(new BigDecimal("120.00")), eq(88L), eq(1), eq("REJECTED")); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_VOID"), eq("void-key"))).thenReturn(List.of());
FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
when(firstleg.voidSource(eq("internal-token"), eq("void-key"), anyString(), any()))
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
FinancePhase1OperationService service = new FinancePhase1OperationService(
jdbc, objectMapper(), firstleg, "internal-token");
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
assertThat(result.sourceStatus()).isEqualTo("AVAILABLE");
var command = org.mockito.ArgumentCaptor.forClass(FirstlegFeeSourceClient.VoidCommand.class);
verify(firstleg).voidSource(eq("internal-token"), eq("void-key"), anyString(), command.capture());
assertThat(command.getValue()).satisfies(value -> {
assertThat(value.sourceType()).isEqualTo("INBOUND_FEE");
assertThat(value.sourceFeeId()).isEqualTo(700L);
assertThat(value.sourceVersion()).isEqualTo(3);
assertThat(value.customerId()).isEqualTo(9L);
assertThat(value.inboundId()).isEqualTo(100L);
assertThat(value.operatorName()).isEqualTo("finance");
assertThat(value.reason()).isEqualTo("测试作废");
});
verify(jdbc, never()).update(startsWith("UPDATE fin_fee_source SET status='VOIDED'"),
(Object[]) any());
} }
@Test @Test
@@ -299,6 +324,7 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getLong("order_id")).thenReturn(90L); when(rs.getLong("order_id")).thenReturn(90L);
when(rs.getLong("inbound_id")).thenReturn(100L); when(rs.getLong("inbound_id")).thenReturn(100L);
when(rs.getLong("container_id")).thenReturn(200L); when(rs.getLong("container_id")).thenReturn(200L);
when(rs.wasNull()).thenReturn(false, false, true, false, false, true);
when(rs.getInt("source_fee_version")).thenReturn(3); when(rs.getInt("source_fee_version")).thenReturn(3);
when(rs.getString("source_system")).thenReturn("FIRSTLEG"); when(rs.getString("source_system")).thenReturn("FIRSTLEG");
when(rs.getString("source_no")).thenReturn("IN-100"); when(rs.getString("source_no")).thenReturn("IN-100");
@@ -312,7 +338,7 @@ class FinancePhase1OperationBehaviorTest {
when(rs.getString("review_reason")).thenReturn(null); when(rs.getString("review_reason")).thenReturn(null);
when(rs.getInt("operation_version")).thenReturn(version); when(rs.getInt("operation_version")).thenReturn(version);
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00")); when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("100.00"));
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"publicationStatus\":\"PUBLISHED\",\"operationVersion\":" + version + "}"); when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"inboundCode\":\"NYEXX2608180003\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\",\"reviewStatus\":\"" + reviewStatus + "\",\"publicationStatus\":\"PUBLISHED\",\"operationVersion\":" + version + "}");
return rs; return rs;
} }
@@ -19,6 +19,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -130,9 +131,15 @@ class FinanceReadApiBehaviorTest {
@Test @Test
void clientConfirmationHasIndependentWriteAuthority() throws Exception { void clientConfirmationHasIndependentWriteAuthority() throws Exception {
PreAuthorize rule = ClientFinanceController.class.getMethod("confirmStatement", AuthenticatedUser.class, var method = ClientFinanceController.class.getMethod("confirmStatement", AuthenticatedUser.class,
long.class, String.class, CustomerConfirmationRequest.class).getAnnotation(PreAuthorize.class); long.class, String.class, CustomerConfirmationRequest.class);
PreAuthorize rule = method.getAnnotation(PreAuthorize.class);
PostMapping mapping = method.getAnnotation(PostMapping.class);
assertThat(rule.value()).isEqualTo("hasAuthority('merchant:finance:statement:confirm')"); assertThat(rule.value()).isEqualTo("hasAuthority('merchant:finance:statement:confirm')");
assertThat(mapping.value()).containsExactly("/statements/{statementId}/confirm");
assertThat(CustomerConfirmationRequest.class.getRecordComponents())
.extracting(java.lang.reflect.RecordComponent::getName)
.containsExactly("expectedVersion");
} }
} }
@@ -0,0 +1,34 @@
package com.baiyue.finance;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import java.lang.reflect.RecordComponent;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class FirstlegFeeSourceClientContractTest {
@Test
void voidCommandFreezesInternalPathHeadersAndBusinessIdentity() throws Exception {
var method = FirstlegFeeSourceClient.class.getDeclaredMethod("voidSource",
String.class, String.class, String.class, FirstlegFeeSourceClient.VoidCommand.class);
assertThat(method.getAnnotation(PostMapping.class).value())
.containsExactly("/fee-sources/commands/void");
assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).value())
.isEqualTo("X-Internal-Service-Token");
assertThat(method.getParameters()[1].getAnnotation(RequestHeader.class).value())
.isEqualTo("Idempotency-Key");
assertThat(method.getParameters()[2].getAnnotation(RequestHeader.class).value())
.isEqualTo("X-Request-Hash");
assertThat(method.getParameters()[3].isAnnotationPresent(RequestBody.class)).isTrue();
assertThat(Arrays.stream(FirstlegFeeSourceClient.VoidCommand.class.getRecordComponents())
.map(RecordComponent::getName))
.containsExactly("sourceType", "sourceFeeId", "sourceVersion", "customerId",
"inboundId", "containerId", "operatorId", "operatorName", "reason");
}
}
@@ -4,7 +4,11 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot; import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.CommandResult;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
import com.baiyue.firstleg.service.FinanceFeeSourceCommandService;
import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService; import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -12,6 +16,8 @@ import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -22,6 +28,7 @@ import java.security.MessageDigest;
@RequestMapping("/internal/firstleg/v1") @RequestMapping("/internal/firstleg/v1")
public class InternalFinanceFeeSourceController { public class InternalFinanceFeeSourceController {
private final FinanceFeeSourceSnapshotService service; private final FinanceFeeSourceSnapshotService service;
private final FinanceFeeSourceCommandService commands;
@Value("${baiyue.security.internal-service-token}") @Value("${baiyue.security.internal-service-token}")
private String expectedToken; private String expectedToken;
@@ -43,6 +50,16 @@ public class InternalFinanceFeeSourceController {
return ApiResponse.success(service.container(containerId, feeId, sourceVersion)); return ApiResponse.success(service.container(containerId, feeId, sourceVersion));
} }
@PostMapping("/fee-sources/commands/void")
public ApiResponse<CommandResult> voidSource(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestHeader("X-Request-Hash") String requestHash,
@Valid @RequestBody VoidRequest request) {
authenticate(token);
return ApiResponse.success(commands.voidSource(idempotencyKey, requestHash, request));
}
private void authenticate(String token) { private void authenticate(String token) {
if (token == null || expectedToken == null || expectedToken.isBlank() if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/** Finance 发起、Firstleg 执行的业务费用命令契约。 */
public final class FinanceFeeSourceCommandDtos {
private FinanceFeeSourceCommandDtos() {}
public record VoidRequest(
@NotBlank @Pattern(regexp = "INBOUND_FEE|CONTAINER_FEE") String sourceType,
@Min(1) long sourceFeeId,
@Min(1) int sourceVersion,
@Min(1) long customerId,
Long inboundId,
Long containerId,
@Min(1) long operatorId,
@NotBlank @Size(max = 64) String operatorName,
@NotBlank @Size(max = 500) String reason
) {}
public record CommandResult(
@NotNull String sourceType,
long sourceFeeId,
int sourceVersion,
@NotNull String status,
boolean replayed
) {}
}
@@ -0,0 +1,33 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("fl_finance_fee_source_command")
public class FinanceFeeSourceCommandEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long customerId;
private Long operatorId;
private String operatorName;
private String action;
private String idempotencyKey;
private String requestHash;
private String sourceType;
private Long sourceFeeId;
private Integer sourceVersion;
private Long inboundId;
private Long containerId;
private String reason;
private String status;
private String resultStatus;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FinanceFeeSourceCommandMapper extends BaseMapper<FinanceFeeSourceCommandEntity> {
}
@@ -258,6 +258,26 @@ public class FbaPricingRepository {
.set(InboundFeeEntity::getSentAt, now)); .set(InboundFeeEntity::getSentAt, now));
} }
public int voidSentFee(long tenantId, long inboundId, long feeId, int feeVersion,
LocalDateTime now) {
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
.eq(InboundFeeEntity::getTenantId, tenantId)
.eq(InboundFeeEntity::getInboundId, inboundId)
.eq(InboundFeeEntity::getId, feeId)
.eq(InboundFeeEntity::getFeeVersion, feeVersion)
.eq(InboundFeeEntity::getFeeStatus, "SENT")
.set(InboundFeeEntity::getFeeStatus, "VOIDED")
.set(InboundFeeEntity::getUpdatedAt, now));
}
public int markInboundFeeVoided(long tenantId, long inboundId) {
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId)
.eq(InboundPricingEntity::getId, inboundId)
.eq(InboundPricingEntity::getFeeStatus, "SENT")
.set(InboundPricingEntity::getFeeStatus, "VOIDED"));
}
public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) { public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) {
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>() LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId) .eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
@@ -0,0 +1,38 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
import com.baiyue.firstleg.mapper.FinanceFeeSourceCommandMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
@Repository
@RequiredArgsConstructor
public class FinanceFeeSourceCommandRepository {
private final FinanceFeeSourceCommandMapper mapper;
public void insert(FinanceFeeSourceCommandEntity value) {
mapper.insert(value);
}
public FinanceFeeSourceCommandEntity find(long customerId, long operatorId,
String action, String key) {
return mapper.selectOne(new LambdaQueryWrapper<FinanceFeeSourceCommandEntity>()
.eq(FinanceFeeSourceCommandEntity::getCustomerId, customerId)
.eq(FinanceFeeSourceCommandEntity::getOperatorId, operatorId)
.eq(FinanceFeeSourceCommandEntity::getAction, action)
.eq(FinanceFeeSourceCommandEntity::getIdempotencyKey, key));
}
public int complete(long id, String resultStatus, LocalDateTime completedAt) {
return mapper.update(null, new LambdaUpdateWrapper<FinanceFeeSourceCommandEntity>()
.eq(FinanceFeeSourceCommandEntity::getId, id)
.eq(FinanceFeeSourceCommandEntity::getStatus, "PROCESSING")
.set(FinanceFeeSourceCommandEntity::getStatus, "SUCCEEDED")
.set(FinanceFeeSourceCommandEntity::getResultStatus, resultStatus)
.set(FinanceFeeSourceCommandEntity::getCompletedAt, completedAt));
}
}
@@ -0,0 +1,192 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.CommandResult;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.FinanceFeeSourceCommandRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
/** Finance 仅发起命令;业务状态与费用事件始终由 Firstleg 原子产生。 */
@Service
@RequiredArgsConstructor
public class FinanceFeeSourceCommandService {
private static final String ACTION_VOID = "VOID";
private final FinanceFeeSourceCommandRepository commands;
private final FeeItemRepository inboundFees;
private final FbaPricingRepository pricing;
private final ContainerFeeDetailRepository containerFees;
private final ContainerFeeDetailService containerFeeService;
private final FinanceFeeSourceOutboxService financeOutbox;
private final ObjectMapper objectMapper;
@Transactional
public CommandResult voidSource(String idempotencyKey, String clientHash, VoidRequest request) {
validateKey(idempotencyKey);
validateIdentity(request);
String requestHash = hash(request);
if (!StringUtils.hasText(clientHash) || !requestHash.equalsIgnoreCase(clientHash.trim())) {
throw bad("X-Request-Hash与费用作废请求不一致");
}
FinanceFeeSourceCommandEntity command = claim(idempotencyKey.trim(), requestHash, request);
if ("SUCCEEDED".equals(command.getStatus())) {
return result(request, command.getResultStatus(), true);
}
if ("INBOUND_FEE".equals(request.sourceType())) voidInbound(request);
else voidContainer(request);
if (commands.complete(command.getId(), "VOIDED", LocalDateTime.now()) != 1) {
throw conflict("费用作废幂等记录状态已变化");
}
return result(request, "VOIDED", false);
}
private FinanceFeeSourceCommandEntity claim(String key, String requestHash, VoidRequest request) {
FinanceFeeSourceCommandEntity value = new FinanceFeeSourceCommandEntity();
value.setCustomerId(request.customerId());
value.setOperatorId(request.operatorId());
value.setOperatorName(request.operatorName().trim());
value.setAction(ACTION_VOID);
value.setIdempotencyKey(key);
value.setRequestHash(requestHash);
value.setSourceType(request.sourceType());
value.setSourceFeeId(request.sourceFeeId());
value.setSourceVersion(request.sourceVersion());
value.setInboundId(request.inboundId());
value.setContainerId(request.containerId());
value.setReason(request.reason().trim());
value.setStatus("PROCESSING");
value.setCreatedAt(LocalDateTime.now());
try {
commands.insert(value);
return value;
} catch (DuplicateKeyException duplicate) {
FinanceFeeSourceCommandEntity existing = commands.find(request.customerId(),
request.operatorId(), ACTION_VOID, key);
if (existing == null || !requestHash.equals(existing.getRequestHash())) {
throw conflict("Idempotency-Key已用于其他费用作废请求");
}
if (!"SUCCEEDED".equals(existing.getStatus())) {
throw conflict("相同费用作废请求正在处理中");
}
return existing;
}
}
private void voidInbound(VoidRequest request) {
long inboundId = positive(request.inboundId(), "入库单ID");
if (request.containerId() != null) throw bad("入库费用不能携带柜次ID");
InboundFeeDetailEntity detail = inboundFees.findSourceVersion(inboundId,
request.sourceFeeId(), request.sourceVersion());
if (detail == null || !Long.valueOf(request.customerId()).equals(detail.getTenantId())) {
throw new BizException(ResultCode.NOT_FOUND, "指定客户的入库费用来源版本不存在");
}
if ("VOIDED".equals(detail.getPublicationStatus())) return;
if (!"APPROVED".equals(detail.getReviewStatus())
|| !"PUBLISHED".equals(detail.getPublicationStatus())) {
throw conflict("只有当前已审核发布的入库费用来源可以作废");
}
LocalDateTime now = LocalDateTime.now();
if ("FBA_PRICING".equals(detail.getSourceType())) {
if (pricing.voidSentFee(request.customerId(), inboundId, request.sourceFeeId(),
request.sourceVersion(), now) != 1
|| pricing.markInboundFeeVoided(request.customerId(), inboundId) != 1) {
throw conflict("入库计价费用状态或版本已变化");
}
} else if (!"MANUAL".equals(detail.getSourceType())) {
throw conflict("该入库费用来源类型不支持作废");
}
if (inboundFees.voidApproved(request.customerId(), inboundId, detail.getId(),
request.sourceVersion(), request.operatorId()) != 1) {
throw conflict("入库费用状态或版本已变化");
}
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.VOIDED,
request.customerId(), inboundId, request.sourceFeeId(), request.sourceVersion(), now);
}
private void voidContainer(VoidRequest request) {
long containerId = positive(request.containerId(), "柜次ID");
if (request.inboundId() != null) throw bad("柜次费用不能携带入库单ID");
ContainerFeeDetailEntity detail = containerFees.find(containerId, request.sourceFeeId());
if (detail == null || !Long.valueOf(request.customerId()).equals(detail.getTenantId())) {
throw new BizException(ResultCode.NOT_FOUND, "指定客户的柜次费用来源版本不存在");
}
if (!Integer.valueOf(request.sourceVersion()).equals(detail.getSourceVersion())) {
throw conflict("柜次费用来源版本已变化");
}
if ("VOIDED".equals(detail.getSourceStatus())) return;
containerFeeService.voidApproved(request.customerId(), containerId,
request.sourceFeeId(), request.sourceVersion(), request.operatorId());
}
private String hash(VoidRequest request) {
try {
return sha256(objectMapper.writeValueAsString(request));
} catch (JsonProcessingException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "费用作废请求序列化失败");
}
}
private static void validateIdentity(VoidRequest request) {
if (request == null || request.sourceFeeId() <= 0 || request.sourceVersion() <= 0
|| request.customerId() <= 0 || request.operatorId() <= 0
|| !StringUtils.hasText(request.operatorName()) || !StringUtils.hasText(request.reason())) {
throw bad("费用作废请求无效");
}
if (!"INBOUND_FEE".equals(request.sourceType())
&& !"CONTAINER_FEE".equals(request.sourceType())) {
throw bad("费用来源类型无效");
}
}
private static void validateKey(String key) {
if (!StringUtils.hasText(key) || key.trim().length() > 128) {
throw bad("Idempotency-Key不能为空且不能超过128个字符");
}
}
private static long positive(Long value, String label) {
if (value == null || value <= 0) throw bad(label + "无效");
return value;
}
private static CommandResult result(VoidRequest request, String status, boolean replayed) {
return new CommandResult(request.sourceType(), request.sourceFeeId(),
request.sourceVersion(), status, replayed);
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
private static BizException bad(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -125,6 +125,32 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
KEY idx_container_fee_snapshot_tenant (tenant_id, loading_plan_id) KEY idx_container_fee_snapshot_tenant (tenant_id, loading_plan_id)
) ENGINE=InnoDB COMMENT='柜次费用不可变版本快照'; ) ENGINE=InnoDB COMMENT='柜次费用不可变版本快照';
CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command (
id BIGINT UNSIGNED NOT NULL COMMENT 'Finance费用来源命令ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,由Firstleg费用事实再次校验',
operator_id BIGINT UNSIGNED NOT NULL COMMENT 'Finance操作人ID',
operator_name VARCHAR(64) NOT NULL COMMENT 'Finance操作人名称快照',
action VARCHAR(24) NOT NULL COMMENT '命令动作;当前仅VOID',
idempotency_key VARCHAR(128) NOT NULL COMMENT 'Finance透传的幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256',
source_type VARCHAR(24) NOT NULL COMMENT 'INBOUND_FEE或CONTAINER_FEE',
source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '稳定费用来源ID',
source_version INT UNSIGNED NOT NULL COMMENT '精确费用来源版本',
inbound_id BIGINT UNSIGNED NULL COMMENT '入库费用所属入库单ID',
container_id BIGINT UNSIGNED NULL COMMENT '柜次费用所属柜次ID',
reason VARCHAR(500) NOT NULL COMMENT '操作原因',
status VARCHAR(16) NOT NULL COMMENT 'PROCESSING或SUCCEEDED',
result_status VARCHAR(16) NULL COMMENT '成功业务结果;当前为VOIDED',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_finance_fee_command_idem (customer_id, operator_id, action, idempotency_key),
KEY idx_finance_fee_command_source (source_type, source_fee_id, source_version, created_at),
CONSTRAINT chk_finance_fee_command_action CHECK (action IN ('VOID')),
CONSTRAINT chk_finance_fee_command_status CHECK (status IN ('PROCESSING', 'SUCCEEDED')),
CONSTRAINT chk_finance_fee_command_source CHECK (source_type IN ('INBOUND_FEE', 'CONTAINER_FEE'))
) ENGINE=InnoDB COMMENT='Finance发起、Firstleg执行业务费用命令的幂等审计记录';
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id', CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id',
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction"); "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name', CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name',
@@ -153,6 +179,10 @@ BEGIN
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail') THEN WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用表不存在'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用表不存在';
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'fl_finance_fee_source_command') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:费用命令幂等审计表不存在';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail' WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
@@ -635,7 +635,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
subtotal_amount DECIMAL(18,2) NOT NULL COMMENT '基础费用金额', subtotal_amount DECIMAL(18,2) NOT NULL COMMENT '基础费用金额',
additional_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '附加费金额', additional_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '附加费金额',
total_amount DECIMAL(18,2) NOT NULL COMMENT '总金额', total_amount DECIMAL(18,2) NOT NULL COMMENT '总金额',
fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态:CALCULATED已计算、SUPERSEDED已被新版本替代', fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态:CALCULATED已计算、CONFIRMED已确认、SENT已发送、SUPERSEDED已被新版本替代、VOIDED已作废',
review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认', review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认',
exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否基于测量异常放行生成', exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否基于测量异常放行生成',
calculated_by BIGINT UNSIGNED NOT NULL COMMENT '试算人ID', calculated_by BIGINT UNSIGNED NOT NULL COMMENT '试算人ID',
@@ -94,6 +94,25 @@ paths:
- {name: sourceVersion, in: query, required: true, schema: {type: integer, minimum: 1}} - {name: sourceVersion, in: query, required: true, schema: {type: integer, minimum: 1}}
responses: responses:
'200': {description: 成功时 data 为 FinanceFeeSourceSnapshot;不存在精确版本时返回业务错误} '200': {description: 成功时 data 为 FinanceFeeSourceSnapshot;不存在精确版本时返回业务错误}
/internal/firstleg/v1/fee-sources/commands/void:
post:
summary: Finance 发起 Firstleg 业务费用作废
description: >-
Finance 只发起命令,不修改或伪造业务费用状态。Firstleg 按 sourceType、sourceFeeId、sourceVersion、
customerId 和所属入库单/柜次精确校验,原子作废业务费用并写入 FIRSTLEG_FEE_VOIDED Outbox;
Finance 后续仅消费该事件同步本地费用池。相同幂等键与请求哈希安全重放,异参重放返回冲突。
security:
- internalServiceToken: []
parameters:
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
- {name: X-Request-Hash, in: header, required: true, schema: {type: string, pattern: '^[0-9a-f]{64}$'}}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/FinanceFeeSourceVoidCommand'}
responses:
'200': {description: 成功时 data 为 FinanceFeeSourceCommandResult;业务失败仍按统一响应 code 返回}
/api/platform/v1/first-leg/customer-dimension-config: /api/platform/v1/first-leg/customer-dimension-config:
get: get:
summary: 查询客户固定四箱规尺寸配置 summary: 查询客户固定四箱规尺寸配置
@@ -1879,3 +1898,26 @@ components:
actualVolumeCbm: {type: number, nullable: true} actualVolumeCbm: {type: number, nullable: true}
sourceHash: {type: string} sourceHash: {type: string}
snapshotVersion: {type: integer, minimum: 1} snapshotVersion: {type: integer, minimum: 1}
FinanceFeeSourceVoidCommand:
type: object
additionalProperties: false
required: [sourceType, sourceFeeId, sourceVersion, customerId, operatorId, operatorName, reason]
properties:
sourceType: {type: string, enum: [INBOUND_FEE, CONTAINER_FEE]}
sourceFeeId: {type: integer, format: int64, minimum: 1}
sourceVersion: {type: integer, minimum: 1}
customerId: {type: integer, format: int64, minimum: 1}
inboundId: {type: integer, format: int64, minimum: 1, nullable: true, description: INBOUND_FEE必填且containerId必须为空}
containerId: {type: integer, format: int64, minimum: 1, nullable: true, description: CONTAINER_FEE必填且inboundId必须为空}
operatorId: {type: integer, format: int64, minimum: 1}
operatorName: {type: string, minLength: 1, maxLength: 64}
reason: {type: string, minLength: 1, maxLength: 500}
FinanceFeeSourceCommandResult:
type: object
required: [sourceType, sourceFeeId, sourceVersion, status, replayed]
properties:
sourceType: {type: string, enum: [INBOUND_FEE, CONTAINER_FEE]}
sourceFeeId: {type: integer, format: int64}
sourceVersion: {type: integer}
status: {type: string, enum: [VOIDED]}
replayed: {type: boolean}
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService; import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService;
import com.baiyue.firstleg.service.FinanceFeeSourceCommandService;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
@@ -15,6 +17,7 @@ import static org.mockito.Mockito.verify;
class InternalFinanceFeeSourceControllerTest { class InternalFinanceFeeSourceControllerTest {
private final FinanceFeeSourceSnapshotService service = mock(FinanceFeeSourceSnapshotService.class); private final FinanceFeeSourceSnapshotService service = mock(FinanceFeeSourceSnapshotService.class);
private final FinanceFeeSourceCommandService commands = mock(FinanceFeeSourceCommandService.class);
private final InternalFinanceFeeSourceController controller = controller(); private final InternalFinanceFeeSourceController controller = controller();
@Test @Test
@@ -34,6 +37,16 @@ class InternalFinanceFeeSourceControllerTest {
verify(service).container(41L, 51L, 3); verify(service).container(41L, 51L, 3);
} }
@Test
void routesAuthorizedVoidCommandWithIdempotencyAndHash() {
VoidRequest request = new VoidRequest("INBOUND_FEE", 31L, 2, 9L,
21L, null, 71L, "finance", "测试作废");
controller.voidSource("finance-token", "void-key", "a".repeat(64), request);
verify(commands).voidSource("void-key", "a".repeat(64), request);
}
@Test @Test
void exposesFrozenInternalPaths() { void exposesFrozenInternalPaths() {
var mapping = InternalFinanceFeeSourceController.class var mapping = InternalFinanceFeeSourceController.class
@@ -52,11 +65,14 @@ class InternalFinanceFeeSourceControllerTest {
.contains("/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:") .contains("/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:")
.contains("{name: sourceVersion, in: query, required: true") .contains("{name: sourceVersion, in: query, required: true")
.contains("supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true") .contains("supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true")
.contains("为空时仅要求供应商ACTIVE"); .contains("为空时仅要求供应商ACTIVE")
.contains("/internal/firstleg/v1/fee-sources/commands/void:")
.contains("FIRSTLEG_FEE_VOIDED Outbox")
.contains("FinanceFeeSourceVoidCommand:");
} }
private InternalFinanceFeeSourceController controller() { private InternalFinanceFeeSourceController controller() {
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service); InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service, commands);
ReflectionTestUtils.setField(value, "expectedToken", "finance-token"); ReflectionTestUtils.setField(value, "expectedToken", "finance-token");
return value; return value;
} }
@@ -37,6 +37,9 @@ class FinanceFeeSourceMigrationContractTest {
"source_version", "supplier_id", "supplier_name", "supplier_category", "source_version", "supplier_id", "supplier_name", "supplier_category",
"CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot", "CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot",
"uk_container_fee_source_version", "uk_container_fee_source_version",
"CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command",
"uk_finance_fee_command_idem",
"request_hash CHAR(64) NOT NULL",
"firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE", "firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'", "firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整", "Firstleg Finance迁移失败:入库费用供应商快照字段不完整",
@@ -0,0 +1,159 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.FinanceFeeSourceCommandRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DuplicateKeyException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FinanceFeeSourceCommandServiceTest {
@Mock FinanceFeeSourceCommandRepository commands;
@Mock FeeItemRepository inboundFees;
@Mock FbaPricingRepository pricing;
@Mock ContainerFeeDetailRepository containerFees;
@Mock ContainerFeeDetailService containerFeeService;
@Mock FinanceFeeSourceOutboxService outbox;
private ObjectMapper objectMapper;
private FinanceFeeSourceCommandService service;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper().findAndRegisterModules();
service = new FinanceFeeSourceCommandService(commands, inboundFees, pricing,
containerFees, containerFeeService, outbox, objectMapper);
doAnswer(invocation -> {
FinanceFeeSourceCommandEntity value = invocation.getArgument(0);
value.setId(901L);
return null;
}).when(commands).insert(any());
lenient().when(commands.complete(eq(901L), eq("VOIDED"), any())).thenReturn(1);
}
@Test
void voidsManualInboundBusinessFactAndPublishesEvent() throws Exception {
VoidRequest request = inboundRequest(700L, 2);
InboundFeeDetailEntity detail = inboundDetail("MANUAL", 777L, 700L, 2, 9L);
when(inboundFees.findSourceVersion(100L, 700L, 2)).thenReturn(detail);
when(inboundFees.voidApproved(9L, 100L, 777L, 2, 71L)).thenReturn(1);
var result = service.voidSource("void-key", hash(request), request);
assertThat(result.status()).isEqualTo("VOIDED");
assertThat(result.replayed()).isFalse();
verify(outbox).publishInbound(org.mockito.ArgumentMatchers.eq(FinanceFeeSourceOutboxService.VOIDED),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(100L),
org.mockito.ArgumentMatchers.eq(700L), org.mockito.ArgumentMatchers.eq(2), any());
verify(pricing, never()).voidSentFee(anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
void voidsFbaPricingAndMaterializedInboundDetailAtomically() throws Exception {
VoidRequest request = inboundRequest(700L, 3);
when(inboundFees.findSourceVersion(100L, 700L, 3))
.thenReturn(inboundDetail("FBA_PRICING", 778L, 700L, 3, 9L));
when(pricing.voidSentFee(9L, 100L, 700L, 3, any())).thenReturn(1);
when(pricing.markInboundFeeVoided(9L, 100L)).thenReturn(1);
when(inboundFees.voidApproved(9L, 100L, 778L, 3, 71L)).thenReturn(1);
service.voidSource("void-fba", hash(request), request);
verify(pricing).voidSentFee(9L, 100L, 700L, 3, any());
verify(pricing).markInboundFeeVoided(9L, 100L);
verify(outbox).publishInbound(org.mockito.ArgumentMatchers.eq(FinanceFeeSourceOutboxService.VOIDED),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(100L),
org.mockito.ArgumentMatchers.eq(700L), org.mockito.ArgumentMatchers.eq(3), any());
}
@Test
void routesContainerVoidThroughExistingBusinessService() throws Exception {
VoidRequest request = new VoidRequest("CONTAINER_FEE", 800L, 4, 9L,
null, 200L, 71L, "finance", "柜次费用作废");
ContainerFeeDetailEntity detail = new ContainerFeeDetailEntity();
detail.setId(800L); detail.setTenantId(9L); detail.setLoadingPlanId(200L);
detail.setSourceVersion(4); detail.setSourceStatus("ACTIVE");
when(containerFees.find(200L, 800L)).thenReturn(detail);
service.voidSource("void-container", hash(request), request);
verify(containerFeeService).voidApproved(9L, 200L, 800L, 4, 71L);
}
@Test
void rejectsCrossCustomerSourceBeforeBusinessMutation() throws Exception {
VoidRequest request = inboundRequest(700L, 2);
when(inboundFees.findSourceVersion(100L, 700L, 2))
.thenReturn(inboundDetail("MANUAL", 777L, 700L, 2, 10L));
assertThatThrownBy(() -> service.voidSource("void-cross", hash(request), request))
.isInstanceOf(BizException.class).hasMessageContaining("指定客户");
verify(inboundFees, never()).voidApproved(anyLong(), anyLong(), anyLong(), anyInt(), anyLong());
verify(outbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
void replaysCompletedCommandWithoutRepeatingBusinessMutation() throws Exception {
VoidRequest request = inboundRequest(700L, 2);
String requestHash = hash(request);
doThrow(new DuplicateKeyException("duplicate")).when(commands).insert(any());
FinanceFeeSourceCommandEntity existing = new FinanceFeeSourceCommandEntity();
existing.setRequestHash(requestHash); existing.setStatus("SUCCEEDED");
existing.setResultStatus("VOIDED");
when(commands.find(9L, 71L, "VOID", "void-replay")).thenReturn(existing);
var result = service.voidSource("void-replay", requestHash, request);
assertThat(result.replayed()).isTrue();
verify(inboundFees, never()).findSourceVersion(anyLong(), anyLong(), anyInt());
}
private VoidRequest inboundRequest(long feeId, int version) {
return new VoidRequest("INBOUND_FEE", feeId, version, 9L,
100L, null, 71L, "finance", "入库费用作废");
}
private static InboundFeeDetailEntity inboundDetail(String sourceType, long id,
long sourceFeeId, int version,
long tenantId) {
InboundFeeDetailEntity value = new InboundFeeDetailEntity();
value.setId(id); value.setTenantId(tenantId); value.setInboundId(100L);
value.setSourceType(sourceType); value.setSourceFeeId(sourceFeeId);
value.setSourceFeeVersion(version); value.setReviewStatus("APPROVED");
value.setPublicationStatus("PUBLISHED");
return value;
}
private String hash(VoidRequest request) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)));
}
}
@@ -29,7 +29,8 @@ VALUES
(5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0), (5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0),
(5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0), (5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0),
(5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,0), (5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,0),
(5205012,10001,'MERCHANT','BUTTON',5205000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0) (5205012,10001,'MERCHANT','BUTTON',5205000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0),
(5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
permission_name=VALUES(permission_name), route_path=VALUES(route_path), permission_name=VALUES(permission_name), route_path=VALUES(route_path),
@@ -106,3 +107,12 @@ SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3)
JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT' JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT'
WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN')
AND p.permission_code='merchant:finance:statement:confirm'; AND p.permission_code='merchant:finance:statement:confirm';
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
FROM iam_role r
JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT'
WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER'
AND r.status='ACTIVE' AND r.deleted=0
AND p.permission_code='merchant:finance:statement:confirm'
AND p.status='ACTIVE' AND p.deleted=0;
@@ -31,6 +31,8 @@ class AdminPrototypeMenuPermissionSqlContractTest {
assertThat(sql).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'"); assertThat(sql).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'");
} }
assertThat(sql) assertThat(sql)
.contains("(5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm'")
.contains("r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER'")
.contains("'platform:finance:fee-source:review' code, 5105041 parent_id") .contains("'platform:finance:fee-source:review' code, 5105041 parent_id")
.contains("'platform:finance:fee-source:edit', 5105041") .contains("'platform:finance:fee-source:edit', 5105041")
.contains("'platform:finance:reconciliation:create', 5105042") .contains("'platform:finance:reconciliation:create', 5105042")
@@ -59,6 +61,7 @@ class AdminPrototypeMenuPermissionSqlContractTest {
.contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33") .contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33")
.contains("Finance 导出和下载中心授权不完整") .contains("Finance 导出和下载中心授权不完整")
.contains("merchant:finance:statement:confirm") .contains("merchant:finance:statement:confirm")
.contains("客户全局管理员缺少对账单确认权限")
.satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'")) .satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'"))
.isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()"))); .isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()")));
} }