优化
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user