From 2ec4627db60648a17e602ffbbec54464cc1f6e9b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 21:32:39 +0800 Subject: [PATCH] fix(dispatch): publish PC date and daily sequence numbering --- deploy/sql/海外区域统一增量-MySQL8.sql | 6 ++ .../mapper/DispatchNumberSequenceMapper.java | 22 +++++ .../repository/DispatchNumberSequence.java | 29 +++++++ .../service/DispatchAggregateService.java | 3 +- .../service/DispatchNumberGenerator.java | 33 ++++++++ .../src/main/resources/db/lastleg.sql | 6 ++ .../DispatchNumberSequenceTest.java | 83 +++++++++++++++++++ .../DispatchAggregatePersistenceTest.java | 3 + .../service/DispatchNumberGeneratorTest.java | 31 +++++++ 9 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchNumberSequenceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGenerator.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGeneratorTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 99375cbc7..95d15f85a 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1656,6 +1656,12 @@ CREATE TABLE ll_monthly_exchange_rate_history ( KEY idx_ll_exchange_rate_history (exchange_rate_id,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='月度汇率变更历史'; +CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence ( + business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', + last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', + PRIMARY KEY(business_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水'; + CREATE TABLE ll_order ( id bigint NOT NULL COMMENT '雪花 ID', order_no varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程单号', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchNumberSequenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchNumberSequenceMapper.java new file mode 100644 index 000000000..1d8747a8c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchNumberSequenceMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.time.LocalDate; + +/** 排车日期流水的原子分配与连接级结果读取,必须加入同一个短事务。 */ +@Mapper +public interface DispatchNumberSequenceMapper { + /** 日期主键串行递增,同时在当前数据库连接记住本次实际分配值。 */ + @Insert("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(#{date},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)") + int allocate(@Param("date") LocalDate date); + + /** 连接级值不能使用会话或二级查询缓存,须读取本事务刚刚分配的值。 */ + @Select("SELECT LAST_INSERT_ID()") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + Long allocatedValue(); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java new file mode 100644 index 000000000..b6cb699ef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; + +/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */ +@Repository +public class DispatchNumberSequence { + private final DispatchNumberSequenceMapper mapper; + + /** 流水与排车主单使用相同服务数据库。 */ + public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; } + + /** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long next(LocalDate date) { + // 两个实际 Mapper 操作加入本次 REQUIRES_NEW,连接级结果不跨连接也不读缓存。 + mapper.allocate(date); + Long value = mapper.allocatedValue(); + if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败"); + if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满"); + return value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index ee7959563..7c5c760be 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -32,6 +32,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; /** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */ @Service public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { + @jakarta.annotation.Resource private DispatchNumberGenerator numbers; @jakarta.annotation.Resource private DispatchAppointmentAllocations appointments; @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; @jakarta.annotation.Resource private DispatchItemSourceService relabelSources; @@ -269,7 +270,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var replay = commands.replay(actor,null,identity); if (replay.isPresent()) return replay.get(); long id=IdWorker.getId(); - String number="DS-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT); + String number=numbers.next(); var shell=fleet.createDispatchShell(actor.operatorId(),new FleetExecutionModels.DispatchShellRequest(id,number, Long.parseLong(request.warehouseId()),request.idempotencyKey(),identity.commandHash(),request.plannedDepartureAt(), fleetVehicle(request.vehicle()),fleetDriver(request.driver()),request.remark())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGenerator.java new file mode 100644 index 000000000..0acba1e36 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGenerator.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.repository.DispatchNumberSequence; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +/** 平台排车单号:PC、北京时间日期与每日独立四位流水。 */ +@Component +public class DispatchNumberGenerator { + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private final DispatchNumberSequence sequences; + private final Clock clock; + + /** 正式运行按统一业务时区取当天日期。 */ + @Autowired + public DispatchNumberGenerator(DispatchNumberSequence sequences) { this(sequences, Clock.systemUTC()); } + + /** 固定时钟用于核实跨午夜归零边界。 */ + DispatchNumberGenerator(DispatchNumberSequence sequences, Clock clock) { this.sequences = sequences; this.clock = clock; } + + /** 已有命令重放由上层账本恢复原号,本方法只用于首次建单。 */ + public String next() { + LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE)); + long value = sequences.next(date); + if (value < 1 || value > 9999) throw new IllegalStateException("排车日流水不在四位范围内"); + return "PC" + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format(Locale.ROOT, "%04d", value); + } +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 95d394795..aa01aafb9 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -41,6 +41,12 @@ CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence ( PRIMARY KEY(business_date) ) ENGINE=InnoDB COMMENT='尾程单号按日原子流水'; +CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence ( + business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', + last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', + PRIMARY KEY(business_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水'; + CREATE TABLE IF NOT EXISTS ll_order ( id BIGINT NOT NULL COMMENT '雪花 ID', order_no VARCHAR(32) NOT NULL COMMENT '尾程单号', diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java new file mode 100644 index 000000000..5db5ce777 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDate; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +/** 使用真实SQL、连接内流水与Spring事务代理核对唯一性和事务边界。 */ +class DispatchNumberSequenceTest { + private static class Fixture { + final JdbcTemplate jdbc; + final DataSourceTransactionManager transactions; + final DispatchNumberSequence sequence; + final DispatchNumberSequenceMapper mapper; + /** 隔离库造表只使用测试 JDBC,被测分配和读取均由实际 Mapper 执行。 */ + Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); + jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data); + jdbc.execute("CREATE TABLE ll_dispatch_daily_sequence(business_date DATE PRIMARY KEY,last_sequence BIGINT NOT NULL)"); + var configuration=new MybatisConfiguration();configuration.addMapper(DispatchNumberSequenceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchNumberSequenceMapper.class); + var proxy=new ProxyFactory(new DispatchNumberSequence(mapper));proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource())); + sequence=(DispatchNumberSequence)proxy.getProxy(); + } + } + /** 每次业务事务独立取号,同一天连续增长,下一天从一开始。 */ + @Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() throws Exception { + var f=new Fixture();var today=LocalDate.of(2026,10,20); + assertThat(f.sequence.next(today)).isEqualTo(1); + assertThat(f.sequence.next(today)).isEqualTo(2); + assertThat(f.sequence.next(today.plusDays(1))).isEqualTo(1); + assertThat(f.sequence.next(today)).isEqualTo(3); + } + /** 两个真实事务连接争用同一天主键,全部已提交号码唯一且连续。 */ + @Test void concurrentConnectionsAllocateEveryNumberOnce() throws Exception { + var f=new Fixture();var date=LocalDate.of(2026,10,20);var pool=Executors.newFixedThreadPool(2); + try { + var work=new ArrayList>();for(int i=0;i<64;i++)work.add(()->f.sequence.next(date)); + var values=new TreeSet();for(var future:pool.invokeAll(work))values.add(future.get()); + assertThat(values).hasSize(64).first().isEqualTo(1L); + assertThat(values.last()).isEqualTo(64L); + } finally {pool.shutdownNow();} + } + /** 外层建单回滚不能回滚独立取号事务,也不能复用已分配号。 */ + @Test void outerBusinessRollbackDoesNotReuseAnAlreadyAllocatedNumber() throws Exception { + var f=new Fixture();var date=LocalDate.of(2026,10,20); + assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{ + assertThat(f.sequence.next(date)).isEqualTo(1);throw new IllegalStateException("business rollback"); + })).isInstanceOf(IllegalStateException.class); + assertThat(f.sequence.next(date)).isEqualTo(2); + } + /** 四位上限拒绝分配,并回滚本次递增,保持当日已用尽事实。 */ + @Test void fourDigitExhaustionDoesNotWrapOrIncrementTheStoredCounter() throws Exception { + var f=new Fixture();var date=LocalDate.of(2026,10,20); + f.jdbc.update("INSERT INTO ll_dispatch_daily_sequence VALUES(?,9999)",date); + assertThatThrownBy(()->f.sequence.next(date)).hasMessageContaining("四位流水号已用满"); + assertThat(f.jdbc.queryForObject("SELECT last_sequence FROM ll_dispatch_daily_sequence WHERE business_date=?",Long.class,date)).isEqualTo(9999L); + } + + /** 直接改变连接级值后再次调用同一 Mapper,必须绕过一级会话缓存返回新值。 */ + @Test void connectionResultBypassesSessionCacheInTheSameTransaction() throws Exception { + var f=new Fixture(); + new TransactionTemplate(f.transactions).executeWithoutResult(status->{ + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(7L); + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(37L); + }); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index d74c6ed17..add73ae54 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -536,6 +536,9 @@ public class DispatchAggregatePersistenceTest { service=new DispatchAggregateService(repository, new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet, new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json); + var numbers=mock(DispatchNumberGenerator.class);var dailySequence=new java.util.concurrent.atomic.AtomicInteger(); + when(numbers.next()).thenAnswer(invocation->"PC20261020"+String.format(java.util.Locale.ROOT,"%04d",dailySequence.incrementAndGet())); + org.springframework.test.util.ReflectionTestUtils.setField(service,"numbers",numbers); org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class)); org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership); org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGeneratorTest.java new file mode 100644 index 000000000..2dfe4acbd --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGeneratorTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.repository.DispatchNumberSequence; +import org.junit.jupiter.api.Test; +import java.time.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DispatchNumberGeneratorTest { + @Test void usesBusinessDateAndFourDigitsWithoutWarehouseOrCustomerSuffix() { + var sequences=mock(DispatchNumberSequence.class); + when(sequences.next(LocalDate.of(2026,10,20))).thenReturn(1L,42L,9999L); + var numbers=new DispatchNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T08:00:00Z"),ZoneOffset.UTC)); + assertThat(numbers.next()).isEqualTo("PC202610200001"); + assertThat(numbers.next()).isEqualTo("PC202610200042"); + assertThat(numbers.next()).isEqualTo("PC202610209999"); + } + @Test void switchesToTheNewDailySequenceAtShanghaiMidnight() { + var sequences=mock(DispatchNumberSequence.class); + when(sequences.next(LocalDate.of(2026,10,19))).thenReturn(238L); + when(sequences.next(LocalDate.of(2026,10,20))).thenReturn(1L); + assertThat(new DispatchNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-19T15:59:59Z"),ZoneOffset.UTC)).next()).isEqualTo("PC202610190238"); + assertThat(new DispatchNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC)).next()).isEqualTo("PC202610200001"); + } + @Test void rejectsExhaustedOrInvalidSequenceInsteadOfWrappingOrReusingNumbers() { + for(long value:new long[]{0,10000}) { + var sequences=mock(DispatchNumberSequence.class);when(sequences.next(any())).thenReturn(value); + assertThatThrownBy(()->new DispatchNumberGenerator(sequences).next()).isInstanceOf(IllegalStateException.class); + } + } +}