Files
baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFactsChangedPersistenceTest.java
T
2026-10-19 17:56:36 +08:00

101 lines
6.6 KiB
Java

package com.baiyue.lastleg.delivery;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.delivery.mapper.DeliveryFactsChangedMapper;
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
import com.baiyue.lastleg.delivery.repository.DeliveryFactsChangedRepository;
import com.baiyue.lastleg.delivery.service.DeliveryFactsChangedService;
import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.Signal;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
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.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/** 执行真实生产Mapper与事务,验证信号不能早确认或在回滚后遗留。 */
class DeliveryFactsChangedPersistenceTest {
private JdbcTemplate db;
private TransactionTemplate tx;
private DeliveryFactsChangedService service;
private final Instant now=Instant.parse("2026-10-19T01:00:00Z");
/** 每例建立隔离数据库,SQL写入仅存在于测试fixture。 */
@BeforeEach void prepare() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:signals-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
db=new JdbcTemplate(ds);
db.execute("""
CREATE TABLE ll_delivery_facts_changed(id BIGINT PRIMARY KEY,customer_id VARCHAR(30),dispatch_id BIGINT,
dispatch_no VARCHAR(50),delivery_id BIGINT,delivery_no VARCHAR(50),delivery_version BIGINT,
facts_hash VARCHAR(64),occurred_at TIMESTAMP,state VARCHAR(20),attempt_count INT,next_attempt_at TIMESTAMP,
last_error_code VARCHAR(64),processed_at TIMESTAMP,UNIQUE(customer_id,delivery_id,delivery_version))
""");
db.execute("""
CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(30),dispatch_order_id BIGINT,
dispatch_no VARCHAR(50),delivery_no VARCHAR(50),version BIGINT,signed_at TIMESTAMP,signed_packages INT)
""");
db.update("INSERT INTO ll_delivery_order VALUES(1,'101',10,'DP-A','DL-A',1,?,5)",local(now));
var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
config.addMapper(DeliveryFactsChangedMapper.class); config.addMapper(DeliveryOrderMapper.class);
var factory=new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config);
var session=new SqlSessionTemplate(factory.getObject());
var repository=new DeliveryFactsChangedRepository(session.getMapper(DeliveryFactsChangedMapper.class),session.getMapper(DeliveryOrderMapper.class));
var manager=new DataSourceTransactionManager(ds); tx=new TransactionTemplate(manager);
tx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_READ_COMMITTED);
var proxy=new ProxyFactory(new DeliveryFactsChangedService(repository,new ObjectMapper().findAndRegisterModules()));
proxy.setProxyTargetClass(true); proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));
service=(DeliveryFactsChangedService)proxy.getProxy();
}
/** 原签收事务失败则不存在候选,原请求重放只有一条事实。 */
@Test void enqueueRollsBackAndDeduplicatesWithRealMapper() {
assertThrows(IllegalStateException.class,()->tx.execute(s->{service.enqueue("DP-A","DL-A",1,now);throw new IllegalStateException("rollback");}));
assertTrue(service.due(now,20).isEmpty());
tx.executeWithoutResult(s->{service.enqueue("DP-A","DL-A",1,now);service.enqueue("DP-A","DL-A",1,now);});
assertEquals(1,service.due(now,20).size());
assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(s->service.enqueue("DP-A","DL-A",2,now)));
db.update("UPDATE ll_delivery_order SET signed_packages=4 WHERE id=1");
assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(s->service.enqueue("DP-A","DL-A",1,now)));
}
/** 阶段写入与信号ack任一失败都回滚,失败记录保留可重试状态。 */
@Test void acknowledgmentRollsBackAndRetryNeverReopensProcessedFact() {
tx.executeWithoutResult(s->service.enqueue("DP-A","DL-A",1,now));
Signal signal=service.due(now,20).get(0);
assertThrows(IllegalStateException.class,()->tx.execute(s->{service.acknowledge(signal,now);throw new IllegalStateException("CAS failed");}));
assertEquals(1,service.due(now,20).size());
service.failed(signal,now.plusSeconds(30),"DISPATCH_RECONCILE_FAILED");
assertTrue(service.due(now,20).isEmpty());
assertEquals(1,service.due(now.plusSeconds(30),20).get(0).attempts());
tx.executeWithoutResult(s->service.acknowledge(signal,now.plusSeconds(30)));
service.failed(signal,now.plusSeconds(60),"DISPATCH_RECONCILE_FAILED");
assertTrue(service.due(now.plusSeconds(60),20).isEmpty());
assertEquals("PROCESSED",db.queryForObject("SELECT state FROM ll_delivery_facts_changed",String.class));
}
/** 内部ID相同而业务身份不同也不能确认或记录别人的信号。 */
@Test void mismatchedIdentityAndMissingTransactionAreRejected() {
tx.executeWithoutResult(s->service.enqueue("DP-A","DL-A",1,now));
var original=service.due(now,20).get(0);
var other=new Signal(original.signalId(),"DP-B","DL-A",1,0,now);
assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(s->service.acknowledge(other,now)));
assertThrows(org.springframework.transaction.IllegalTransactionStateException.class,()->service.acknowledge(original,now));
assertThrows(org.springframework.transaction.IllegalTransactionStateException.class,()->service.enqueue("DP-A","DL-A",1,now));
assertEquals(1,service.due(now,20).size());
}
/** 与生产现有LocalDateTime字段保持服务端时区语义。 */
private static LocalDateTime local(Instant value) { return LocalDateTime.ofInstant(value,ZoneId.systemDefault()); }
}