From 94a36b8c4cb1b0cd458a9559b75209e23e0ffc66 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 4 Nov 2026 19:26:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 4 ++-- .../service/ContainerCustomsService.java | 3 +++ .../mapper/CabinetLoadingEvidenceMapper.java | 2 +- .../service/CabinetLoadingEvidenceService.java | 5 +---- .../db/20261104-cabinet-evidence-append.sql | 16 ++++++++++++++++ .../db/phase3-loading-delivery-schema.sql | 4 ++-- .../openapi/phase3-pda-convergence-v1.yaml | 1 + .../container/ContainerCustomsServiceTest.java | 13 +++++++++++++ .../CabinetLoadingEvidenceTransactionTest.java | 10 +++++----- 9 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 firstleg/src/main/resources/db/20261104-cabinet-evidence-append.sql diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index fe60415a4..b966afba7 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index 5e204711a..6e8c632c6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/CabinetLoadingEvidenceMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/CabinetLoadingEvidenceMapper.java index 688a912b8..4174c6218 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/CabinetLoadingEvidenceMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/CabinetLoadingEvidenceMapper.java @@ -14,7 +14,7 @@ public interface CabinetLoadingEvidenceMapper extends BaseMapper 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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java index d42c07b48..fd6a80ea7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java @@ -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); diff --git a/firstleg/src/main/resources/db/20261104-cabinet-evidence-append.sql b/firstleg/src/main/resources/db/20261104-cabinet-evidence-append.sql new file mode 100644 index 000000000..941360ca1 --- /dev/null +++ b/firstleg/src/main/resources/db/20261104-cabinet-evidence-append.sql @@ -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; diff --git a/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql b/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql index 448de9240..d6e14b59e 100644 --- a/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql +++ b/firstleg/src/main/resources/db/phase3-loading-delivery-schema.sql @@ -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), diff --git a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml index 44ea78068..332fe6582 100644 --- a/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml +++ b/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml @@ -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}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index f2396f29f..91c746f6e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceTransactionTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceTransactionTest.java index c6e23423f..37efa889f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceTransactionTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceTransactionTest.java @@ -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);