This commit is contained in:
2026-11-04 19:26:27 +08:00
parent 51291b4c27
commit 94a36b8c4c
9 changed files with 44 additions and 14 deletions
@@ -3093,7 +3093,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
seal_no varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码',
remark varchar(1000) DEFAULT NULL COMMENT '证据备注',
status varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废',
active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,保证每柜每类型仅一条有效事实',
active_flag tinyint unsigned DEFAULT '1' COMMENT '有效记录为1,作废记录为NULL,同类允许多张有效照片',
operation_key varchar(128) NOT NULL COMMENT '任务范围内幂等操作键',
request_hash char(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
void_reason varchar(500) DEFAULT NULL COMMENT '作废原因',
@@ -3109,7 +3109,7 @@ CREATE TABLE fl_loading_cabinet_evidence (
updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key),
UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
KEY idx_cabinet_evidence_container (container_booking_id,status),
KEY idx_cabinet_evidence_seal (seal_no,active_flag),
CONSTRAINT fk_cabinet_evidence_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id),
@@ -254,6 +254,9 @@ public class ContainerCustomsService {
throw conflict("当前柜尚未完成首次发送报关行资料");
}
CompletionRow completion = mapper.lockCompletion(documentId);
if (completion != null && "CONFIRMED".equals(completion.customerConfirmStatus())) {
throw conflict("客户已确认预录单,不可再次上传");
}
CustomsFileClient.Metadata metadata = upload(source.tenantId(), RECEIPT_FILE_TYPE, Long.toString(container.loadingPlanId()), operatorId, file);
long completionId;
int version;
@@ -14,7 +14,7 @@ public interface CabinetLoadingEvidenceMapper extends BaseMapper<CabinetLoadingE
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} ORDER BY evidence_type,id")
List<CabinetLoadingEvidenceEntity> byContainer(@Param("containerBookingId") long containerBookingId);
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 LIMIT 1 FOR UPDATE")
@Select("SELECT * FROM fl_loading_cabinet_evidence WHERE container_booking_id=#{containerBookingId} AND evidence_type=#{type} AND active_flag=1 ORDER BY id DESC LIMIT 1 FOR UPDATE")
CabinetLoadingEvidenceEntity activeForUpdate(@Param("containerBookingId") long containerBookingId,
@Param("type") String type);
@@ -74,8 +74,6 @@ public class CabinetLoadingEvidenceService {
if (current != null && !Objects.equals(current.getVersion(), expected)) throw conflict("装柜证据版本已变化,请刷新后重试");
if (current != null && Objects.equals(current.getFileId(), request.fileId())
&& Objects.equals(current.getSealNo(), sealNo)) return view(scope);
if (current != null && mapper.voidActive(current.getId(), scope.containerBookingId(), current.getVersion(),
"REPLACED", operatorId, operatorName) != 1) throw conflict("装柜证据版本已变化,请刷新后重试");
CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity();
value.setContainerBookingId(scope.containerBookingId()); value.setSourceTaskId(taskId);
value.setEvidenceType(type); value.setFileId(request.fileId()); value.setFileName(file.fileName());
@@ -84,8 +82,7 @@ public class CabinetLoadingEvidenceService {
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName);
try { mapper.insert(value); } catch (DuplicateKeyException exception) {
// A replacement may already have voided the old row in this transaction.
// Roll it back; a subsequent same-key request can read the committed winner.
// The task-scoped operation key protects concurrent duplicate uploads.
throw conflict("装柜证据发生并发变更,请刷新后重试");
}
return view(scope);
@@ -0,0 +1,16 @@
-- Existing databases: allow multiple active photos per evidence type. No photo rows are changed.
USE baiyue_firstleg;
SET @evidence_ddl = IF(EXISTS(
SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_loading_cabinet_evidence' AND index_name='uk_cabinet_evidence_active'
), 'ALTER TABLE fl_loading_cabinet_evidence DROP INDEX uk_cabinet_evidence_active', 'SELECT 1');
PREPARE evidence_ddl FROM @evidence_ddl;
EXECUTE evidence_ddl;
DEALLOCATE PREPARE evidence_ddl;
SET @evidence_ddl = IF(EXISTS(
SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_loading_cabinet_evidence' AND index_name='idx_cabinet_evidence_active'
), 'SELECT 1', 'ALTER TABLE fl_loading_cabinet_evidence ADD INDEX idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag)');
PREPARE evidence_ddl FROM @evidence_ddl;
EXECUTE evidence_ddl;
DEALLOCATE PREPARE evidence_ddl;
@@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence (
seal_no VARCHAR(64) NULL COMMENT '带锁码照对应锁码',
remark VARCHAR(1000) NULL COMMENT '证据备注',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废',
active_flag TINYINT UNSIGNED NULL DEFAULT 1 COMMENT '有效唯一标识;作废后置NULL',
active_flag TINYINT UNSIGNED NULL DEFAULT 1 COMMENT '有效标识;同类型允许多张,作废后置NULL',
operation_key VARCHAR(128) NOT NULL COMMENT '任务范围内幂等操作键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
void_reason VARCHAR(500) NULL COMMENT '作废原因',
@@ -26,7 +26,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_cabinet_evidence (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
KEY idx_cabinet_evidence_active (container_booking_id,evidence_type,active_flag),
UNIQUE KEY uk_cabinet_evidence_operation (source_task_id,operation_key),
KEY idx_cabinet_evidence_container (container_booking_id,status),
KEY idx_cabinet_evidence_seal (seal_no,active_flag),
@@ -140,6 +140,7 @@ paths:
responses: {'200': {description: 仅platform:firstleg:pda:work且任务必须由当前用户领取}}
post:
summary: 通过Firstleg代理上传并登记当前柜次装柜证据
description: 同类型照片追加保留,不替换已有有效照片。expectedVersion取当前同类型最新有效照片版本,无照片时为0;重复幂等键不追加。
parameters:
- {$ref: '#/components/parameters/TaskId'}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
@@ -304,6 +304,19 @@ class ContainerCustomsServiceTest {
verify(mapper,never()).insertBrokerSubmission(anyLong(),anyLong(),anyLong(),anyString(),anyInt(),anyString(),anyLong(),anyString());
}
@Test void receiptUploadRejectsAfterCustomerConfirmationBeforeBrokerExecution(){
var source=document(1L);var declaring=container(ContainerCustomsService.DECLARING,3);
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("confirmed"),anyString())).thenReturn(1);
when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(declaring);
when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.brokerSubmission(2L)).thenReturn(brokerSubmission());
when(mapper.lockCompletion(1L)).thenReturn(new ContainerCustomsMapper.CompletionRow(21L,1L,99L,
"receipt.pdf","b".repeat(64),"application/pdf",1L,5L,"admin",java.time.LocalDateTime.now(),1,"CONFIRMED",null));
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","confirmed",1,
new MockMultipartFile("file","receipt.pdf","application/pdf",new byte[]{1})))
.isInstanceOf(BizException.class).hasMessageContaining("客户已确认预录单");
verifyNoInteractions(files);
}
@Test void receiptUploadRejectsWhenInitialBrokerSubmissionIsMissing(){
var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3);
when(mapper.claimCommand(anyLong(),eq(5L),eq("UPLOAD_COMPLETION_1"),eq("before-send"),anyString())).thenReturn(1);
@@ -58,7 +58,7 @@ class CabinetLoadingEvidenceTransactionTest {
}).collect(Collectors.joining(","));
jdbc.execute("CREATE TABLE fl_loading_cabinet_evidence(" + columns + ")");
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT uq_operation UNIQUE(source_task_id,operation_key)");
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT uq_seal UNIQUE(seal_no,active_flag)");
jdbc.execute("CREATE INDEX idx_active ON fl_loading_cabinet_evidence(container_booking_id,evidence_type,active_flag)");
String taskColumns = Arrays.stream(LoadingPickTaskEntity.class.getDeclaredFields())
.filter(field -> !Modifier.isStatic(field.getModifiers())).map(field ->
field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase() + " "
@@ -98,7 +98,7 @@ class CabinetLoadingEvidenceTransactionTest {
}
@Test
void replacementInsertFailureRollsBackOldActiveRowAndSameRequestCanRetry() {
void appendInsertFailureRetainsOldActiveRowAndSameRequestCanRetry() {
nextFile = 301; upload("original");
var before = snapshot();
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence ADD CONSTRAINT reject_new_file CHECK(file_id <> 302)");
@@ -108,11 +108,11 @@ class CabinetLoadingEvidenceTransactionTest {
jdbc.execute("ALTER TABLE fl_loading_cabinet_evidence DROP CONSTRAINT reject_new_file");
upload("replacement");
assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus()))
.singleElement().satisfies(row -> assertThat(row.getFileId()).isEqualTo(302L));
.extracting(CabinetLoadingEvidenceEntity::getFileId).containsExactly(301L, 302L);
}
@Test
void duplicateKeyAfterStaleReplayReadsMustRollBackPriorVoid() {
void duplicateKeyAfterStaleReplayReadsRetainsAllPhotos() {
nextFile = 301; upload("original");
nextFile = 302; upload("winner");
var before = snapshot();
@@ -167,7 +167,7 @@ class CabinetLoadingEvidenceTransactionTest {
boolean secondPassed = second.get(20, java.util.concurrent.TimeUnit.SECONDS);
assertThat(firstPassed || secondPassed).isTrue();
assertThat(mapper.byContainer(99)).hasSize(2).filteredOn(row -> "ACTIVE".equals(row.getStatus()))
.singleElement().satisfies(row -> assertThat(row.getOperationKey()).isEqualTo("concurrent"));
.extracting(CabinetLoadingEvidenceEntity::getOperationKey).containsExactly("original", "concurrent");
var committed = snapshot();
upload("concurrent");
assertThat(snapshot()).isEqualTo(committed);