feat(numbering): unify daily business document numbers (backend)

This commit is contained in:
2026-10-21 07:39:31 +08:00
parent 5d2a1d5b72
commit 8cd822ef8e
158 changed files with 1801 additions and 972 deletions
@@ -0,0 +1,21 @@
package com.baiyue.order.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;
/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */
@Mapper
public interface DocumentSequenceMapper {
/** 唯一序列键原子递增,并在连接内保存本次分配值。 */
@Insert("INSERT INTO ord_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) "
+ "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)")
int allocate(@Param("key") String key);
/** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */
@Select("SELECT LAST_INSERT_ID()")
@Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE)
Long allocatedValue();
}
@@ -0,0 +1,24 @@
package com.baiyue.order.repository;
import com.baiyue.order.mapper.DocumentSequenceMapper;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */
@Repository
public class DocumentSequenceRepository {
private final DocumentSequenceMapper mapper;
/** 只访问所属服务的编号表。 */
public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; }
/** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public long next(String key) {
mapper.allocate(key);
Long value = mapper.allocatedValue();
if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败");
return value;
}
}
@@ -44,6 +44,8 @@ import java.time.format.DateTimeFormatter;
@Service
public class OrderApplicationService {
@jakarta.annotation.Resource private OrderDocumentNumberGenerator documentNumbers;
private static final ZoneId INBOUND_CODE_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter INBOUND_CODE_DATE = DateTimeFormatter.ofPattern("yyMMdd");
@Resource private OrderRepository repository;
@@ -83,6 +85,7 @@ public class OrderApplicationService {
return orderId;
}
/** 创建主订单时分配独立日流水,入仓码与后续拆单规则保持不变。 */
private Long createDraftInternal(long tenantId, long userId, String username,
OrderRequests.SaveRequest request, boolean allowLegacyInboundCode) {
AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request);
@@ -94,7 +97,7 @@ public class OrderApplicationService {
request.destinationType());
OrderEntity order = new OrderEntity();
long orderId = IdWorker.getId();
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId));
order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(documentNumbers.next("FL"));
order.setStatus(OrderStatus.DRAFT);
apply(order, tenantId, request, snapshots, addressSnapshots, allowLegacyInboundCode);
order.setCreatedBy(userId); order.setUpdatedBy(userId);
@@ -984,7 +987,4 @@ public class OrderApplicationService {
private static String normalize(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private String nextNo(long orderId) {
return "FL" + orderId;
}
}
@@ -0,0 +1,34 @@
package com.baiyue.order.service;
import com.baiyue.common.core.number.DailyDocumentNumber;
import com.baiyue.order.repository.DocumentSequenceRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.util.Set;
/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */
@Component
public class OrderDocumentNumberGenerator {
private static final Set<String> PREFIXES = Set.of("FL");
private final DocumentSequenceRepository sequences;
private final Clock clock;
/** 正式运行使用统一业务时区。 */
@Autowired
public OrderDocumentNumberGenerator(DocumentSequenceRepository sequences) {
this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE));
}
/** 固定时钟用于跨日及前缀独立性验证。 */
OrderDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) {
this.sequences = sequences;
this.clock = clock;
}
/** 只为本服务已登记的业务类型分配号码。 */
public String next(String prefix) {
if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix);
return DailyDocumentNumber.next(prefix, clock, sequences::next);
}
}
+8
View File
@@ -423,3 +423,11 @@ CREATE TABLE IF NOT EXISTS ord_idempotency_record (
UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key),
KEY idx_order_idempotency_created (created_at)
) ENGINE=InnoDB COMMENT='订单写接口持久化幂等记录:唯一键保证同租户同操作者并发请求只执行一次';
-- 统一业务单号:前缀 + yyyyMMdd + 四位日流水。
CREATE TABLE IF NOT EXISTS ord_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键',
current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水';
@@ -0,0 +1,76 @@
package com.baiyue.order.repository;
import com.baiyue.order.mapper.DocumentSequenceMapper;
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.util.*;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */
class DocumentSequenceRepositoryTest {
private static class Fixture {
final JdbcTemplate jdbc;
final DataSourceTransactionManager transactions;
final DocumentSequenceMapper mapper;
final DocumentSequenceRepository repository;
/** 仅测试造表允许 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 ord_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)");
var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet();
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class);
var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource()));
repository=(DocumentSequenceRepository)proxy.getProxy();
}
}
/** 日期和前缀各自独立,重复分配持续递增。 */
@Test void dateAndPrefixHaveIndependentSequences() throws Exception {
var f=new Fixture();
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
assertThat(f.repository.next("BB:20261020")).isEqualTo(1);
assertThat(f.repository.next("AA:20261021")).isEqualTo(1);
}
/** 两个线程使用真实事务连接并发争用,不能产生重号。 */
@Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception {
var f=new Fixture();var pool=Executors.newFixedThreadPool(2);
try {
var work=new ArrayList<Callable<Long>>();
for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020"));
var values=new TreeSet<Long>();
for(var future:pool.invokeAll(work))values.add(future.get());
assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L);
} finally {pool.shutdownNow();}
}
/** 外层建单失败后不能把已经分配的单号再交给其他单据。 */
@Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception {
var f=new Fixture();
assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{
assertThat(f.repository.next("AA:20261020")).isEqualTo(1);
throw new IllegalStateException("业务回滚");
})).isInstanceOf(IllegalStateException.class);
assertThat(f.repository.next("AA:20261020")).isEqualTo(2);
}
/** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */
@Test void connectionResultIsNeverCached() 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);
});
}
}
@@ -99,6 +99,7 @@ class MerchantOrderCommandTransactionTest {
ReflectionTestUtils.setField(completion, "outbox", session.getMapper(OutboxEventMapper.class));
ReflectionTestUtils.setField(completion, "json", json);
OrderApplicationService target = new OrderApplicationService();
org.springframework.test.util.ReflectionTestUtils.setField(target, "documentNumbers", com.baiyue.order.service.TestDocumentNumbers.documents());
ReflectionTestUtils.setField(target, "repository", repository);
ReflectionTestUtils.setField(target, "fulfillmentCompletion", proxy(completion, manager));
ReflectionTestUtils.setField(target, "json", json);
@@ -47,6 +47,8 @@ class OrderApplicationServiceStateTest {
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); }
@Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); }
}
@Test
@@ -0,0 +1,41 @@
package com.baiyue.order.service;
import com.baiyue.order.repository.DocumentSequenceRepository;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import java.time.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class OrderDocumentNumberGeneratorTest {
private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC);
/** 多构造器情况下正式依赖可以被 Spring 正确选择。 */
@Test void springCreatesGeneratorWithRepository() {
try(var context=new AnnotationConfigApplicationContext()) {
context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class));
context.register(OrderDocumentNumberGenerator.class);context.refresh();
assertThat(context.getBean(OrderDocumentNumberGenerator.class)).isNotNull();
}
}
/** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */
@Test void usesShanghaiDateAndIndependentSequenceKey() {
var sequences=mock(DocumentSequenceRepository.class);
when(sequences.next("FL:20261020")).thenReturn(1L,2L,9999L);
var numbers=new OrderDocumentNumberGenerator(sequences,CLOCK);
assertThat(numbers.next("FL")).isEqualTo("FL202610200001");
assertThat(numbers.next("FL")).isEqualTo("FL202610200002");
assertThat(numbers.next("FL")).isEqualTo("FL202610209999");
verify(sequences,times(3)).next("FL:20261020");
}
/** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */
@Test void rejectsUnknownTypeAndOverflow() {
var sequences=mock(DocumentSequenceRepository.class);
var numbers=new OrderDocumentNumberGenerator(sequences,CLOCK);
assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(sequences);
when(sequences.next("FL:20261020")).thenReturn(10000L);
assertThatThrownBy(()->numbers.next("FL")).isInstanceOf(BizException.class);
}
}
@@ -49,6 +49,8 @@ class OrderIdempotencyServiceTest {
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); }
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
}
@Test
@@ -23,6 +23,7 @@ class OrderRemarksSynchronizationTest {
@SuppressWarnings("unchecked")
ObjectProvider<FirstlegInboundClient> provider = mock(ObjectProvider.class);
OrderApplicationService service = new OrderApplicationService();
org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.order.service.TestDocumentNumbers.documents());
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "firstlegProvider", provider);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
@@ -27,6 +27,8 @@ class PlatformOrderPageServiceTest {
@Resource private OrderApplicationService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); }
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
}
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
@@ -30,6 +30,8 @@ class TenantOrderCountServiceTest {
@Resource private OrderApplicationService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); }
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
}
@Test
@@ -35,6 +35,8 @@ class TenantOrderStatisticsServiceTest {
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); }
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
}
@@ -0,0 +1,21 @@
package com.baiyue.order.service;
import com.baiyue.order.repository.DocumentSequenceRepository;
import java.time.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */
public final class TestDocumentNumbers {
/** 测试工具不实例化。 */
private TestDocumentNumbers() {}
/** 每个测试生成器独立计数,类型前缀之间互不占号。 */
public static OrderDocumentNumberGenerator documents() {
var sequences=mock(DocumentSequenceRepository.class);
var counters=new ConcurrentHashMap<String,AtomicLong>();
lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet());
return new OrderDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC));
}
}