test(qa): verify native Kingbase restore migration and fusion workflows

This commit is contained in:
wangzhibiao committed 2026-10-14 18:31:38 +08:00
1 parent ba5dc7a6af
commit 6e73d34561
23 files changed
+1103 -31

No files matched your search

@@ -29,6 +29,14 @@ public class GlobalExceptionHandler
{
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
/** Disabled/absent endpoints are 404, not a logged system failure or an exception-detail leak. */
@ExceptionHandler(org.springframework.web.servlet.resource.NoResourceFoundException.class)
@org.springframework.web.bind.annotation.ResponseStatus(org.springframework.http.HttpStatus.NOT_FOUND)
public AjaxResult handleMissingResource(org.springframework.web.servlet.resource.NoResourceFoundException e)
{
return AjaxResult.error(HttpStatus.NOT_FOUND, "接口不存在或未启用");
}
/**
* 权限校验异常
*/
@@ -0,0 +1,20 @@
package com.ruoyi.framework.security;
import com.ruoyi.framework.web.exception.GlobalExceptionHandler;
import org.junit.Test;
import static org.junit.Assert.*;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.http.HttpMethod;
public class Round5v4MissingResourceTest {
@Test public void absentSensitiveEndpointsReturnRedacted404RatherThan500() throws Exception {
GlobalExceptionHandler h=new GlobalExceptionHandler();
Object body=h.handleMissingResource(new NoResourceFoundException(HttpMethod.POST,"iot/mqtt/publish","/iot/mqtt/publish"));
assertEquals(404,((java.util.Map<?,?>)body).get("code"));
assertFalse(body.toString().contains("static resource"));
assertFalse(body.toString().contains("iot/mqtt"));
ResponseStatus status=GlobalExceptionHandler.class.getMethod("handleMissingResource",NoResourceFoundException.class).getAnnotation(ResponseStatus.class);
assertEquals(404,status.value().value());
}
}
+2
View File
@@ -16,6 +16,8 @@
</description>
<dependencies>
<!-- Authorized private vendor artifact; only opt-in real Kingbase QA tests need it here. -->
<dependency><groupId>cn.com.kingbase</groupId><artifactId>kingbase8</artifactId><scope>test</scope></dependency>
<!-- 通用工具-->
<dependency>
@@ -15,6 +15,7 @@ import com.ruoyi.iot.lock.mapper.LockDeviceMapper;
import com.ruoyi.iot.lock.service.ILockAuthService;
import com.ruoyi.iot.lock.service.ILockCommandService;
import com.ruoyi.iot.lock.service.ILockFaceService;
import com.ruoyi.iot.lock.service.LockExecutionTransactions;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainDormitoryInfo;
@@ -92,6 +93,9 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe
@Autowired
private com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate;
@Autowired
private LockExecutionTransactions executionTransactions;
@org.springframework.beans.factory.annotation.Value("${train.lock.password-fallback-enabled:false}")
private boolean passwordFallbackEnabled = false;
@@ -553,7 +557,9 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe
}
try
{
lockAuthService.push(authId);
// AFTER_COMMIT still exposes the completed business transaction's resources.
// Enqueue in REQUIRES_NEW so its own commit callback actually dispatches.
executionTransactions.committed(() -> { lockAuthService.push(authId); return null; });
}
catch (Exception ex)
{
@@ -569,7 +575,7 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe
}
try
{
lockAuthService.clear(authId);
executionTransactions.committed(() -> { lockAuthService.clear(authId); return null; });
}
catch (Exception ex)
{
@@ -140,7 +140,7 @@
SELECT
COALESCE(cr.room_name, ts.site_name, '未知场地') AS venue_name,
count(1) AS usage_count,
ROUND(SUM((EXTRACT(EPOCH FROM l.end_time) - EXTRACT(EPOCH FROM l.start_time)) / 3600.0)::numeric, 1) AS total_hours
ROUND(CAST(SUM((EXTRACT(EPOCH FROM l.end_time) - EXTRACT(EPOCH FROM l.start_time)) / 3600.0) AS numeric), 1) AS total_hours
FROM train_lesson l
LEFT JOIN train_classroom_info cr ON cr.class_room_id = l.class_room_id
LEFT JOIN train_training_site ts ON ts.site_id = l.site_id
@@ -0,0 +1,49 @@
package com.ruoyi.train.integration.lock;
import java.io.*;
import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.*;
import com.ruoyi.iot.lock.domain.*;
import com.ruoyi.iot.lock.security.ExecutionStates;
/** Offline CLI fixture: no web endpoint, no real MQTT client, only explicitly marked synthetic QA records. */
public final class Round5v4BrowserFixture {
public static void main(String[] args) throws Exception {
Round5v4KingbaseIntegrationTest.Harness h=new Round5v4KingbaseIntegrationTest.Harness("round5v-local-mock");
Path path=Paths.get(System.getenv("ROUND5V4_LOG_DIR"),"browser-fixture.properties");
Properties p=new Properties();
if("seed".equals(args[0])) {
if(Files.exists(path))throw new SecurityException("Fixture already exists; preserve its audit trail");
h.seed();p.setProperty("marker",h.marker);p.setProperty("studentId",h.marker+"S");p.setProperty("person",h.person);
p.setProperty("dormA",h.dormA);p.setProperty("dormB",h.dormB);p.setProperty("acc",String.valueOf(h.acc));
p.setProperty("deviceA",String.valueOf(h.deviceA));p.setProperty("deviceB",String.valueOf(h.deviceB));
Files.createFile(path,PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
try(Writer w=Files.newBufferedWriter(path)){p.store(w,"SYNTHETIC ONLY; no credentials");}
System.out.println("BROWSER_FIXTURE_CREATED "+h.marker);return;
}
try(Reader r=Files.newBufferedReader(path)){p.load(r);}
if(!p.getProperty("marker","").matches("R5V4[0-9a-f]{16}"))throw new SecurityException("Invalid fixture identity");
String person=p.getProperty("person");Set<Long> devices=new HashSet<>(Arrays.asList(Long.valueOf(p.getProperty("deviceA")),Long.valueOf(p.getProperty("deviceB"))));
java.util.List<LockAuth> all=h.authMapper.selectAuthsByPersonNo(person);
for(LockAuth auth:all) {
if(!devices.contains(auth.getDeviceId()) || !h.gate.sameOrigin(auth) || !"MOCK".equals(auth.getExecutionMode()))throw new SecurityException("Non-fixture authorization");
LockCommand c=h.commandMapper.selectCommandByIntentKey(auth.getIntentKey());
if("ack".equals(args[0]) && c!=null && ExecutionStates.AWAITING_ACK.equals(c.getExecutionState())) {
if(!"MOCK".equals(c.getExecutionMode()) || !h.gate.sameOrigin(c))throw new SecurityException("Non-Mock command");
h.confirm(c);
} else if(!Arrays.asList("ack","state","verify").contains(args[0]))throw new IllegalArgumentException("seed/ack/state/verify only");
auth=h.authMapper.selectAuthById(auth.getAuthId());
System.out.println("FIXTURE_AUTH id="+auth.getAuthId()+" action="+auth.getIntentAction()+" version="+auth.getIntentVersion()+" state="+auth.getExecutionState()+" sync="+auth.getSyncStatus());
}
if("verify".equals(args[0])) {
org.junit.Assert.assertEquals(2,all.size());
for(LockAuth auth:all){org.junit.Assert.assertEquals(ExecutionStates.REVOKE,auth.getIntentAction());org.junit.Assert.assertEquals(ExecutionStates.CONFIRMED,auth.getExecutionState());org.junit.Assert.assertEquals("4",auth.getSyncStatus());}
java.util.List<Long> commands=h.jdbc.queryForList("select c.command_id from iotlocks.lock_command c join iotlocks.lock_auth a on a.auth_id=c.biz_id where a.person_no=? and c.environment_id=? order by c.command_id",Long.class,person,h.env);
org.junit.Assert.assertEquals(4,commands.size());
for(Long id:commands){LockCommand c=h.commandMapper.selectCommandById(id);org.junit.Assert.assertEquals("MOCK",c.getExecutionMode());org.junit.Assert.assertEquals(ExecutionStates.CONFIRMED,c.getExecutionState());org.junit.Assert.assertEquals("1",c.getGatewayAck());org.junit.Assert.assertEquals("1",c.getDeviceAck());System.out.println("FIXTURE_COMMAND id="+id+" type="+c.getCommandType()+" mode=MOCK state=CONFIRMED dual_ack=true");}
org.junit.Assert.assertEquals(Integer.valueOf(2),h.jdbc.queryForObject("select count(*) from public.train_accommodation a join public.train_enrollment e on e.enrollment_id=a.enrollment_id where e.student_id=? and a.accommodation_status=2",Integer.class,p.getProperty("studentId")));
System.out.println("FIXTURE_BROWSER_DATABASE_CLOSED_LOOP_PASS");
}
}
}
@@ -0,0 +1,227 @@
package com.ruoyi.train.integration.lock;
import static org.junit.Assert.*;
import java.io.*;
import java.lang.reflect.*;
import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermissions;
import java.sql.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import javax.sql.DataSource;
import org.junit.*;
import org.mybatis.spring.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import com.ruoyi.iot.config.IotMqttProperties;
import com.ruoyi.iot.lock.LockConstants;
import com.ruoyi.iot.lock.domain.*;
import com.ruoyi.iot.lock.mapper.*;
import com.ruoyi.iot.lock.protocol.*;
import com.ruoyi.iot.lock.security.*;
import com.ruoyi.iot.lock.service.*;
import com.ruoyi.iot.lock.service.impl.*;
import com.ruoyi.iot.service.IMqttService;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.integration.lock.service.ITrainLockAuthBridgeService;
import com.ruoyi.train.integration.lock.service.impl.TrainIotLockAuthBridgeServiceImpl;
import com.ruoyi.train.mapper.*;
import com.ruoyi.train.service.ITrainAccommodationService;
import com.ruoyi.train.service.impl.TrainAccommodationServiceImpl;
/** Opt-in REAL Kingbase, real production XML/Services/transaction manager. All egress is a fake transport. */
public class Round5v4KingbaseIntegrationTest {
private Harness h;
@Before public void setup() throws Exception {
Assume.assumeTrue("Explicit QA write opt-in required", "1".equals(System.getenv("ROUND5V4_KINGBASE_TESTS")));
h=new Harness("round5v4-qa-"+UUID.randomUUID()); h.seed();
}
@After public void preserveSyntheticAuditRows() {
if(h==null)return;
h.props.setMock(true);
// Preserve all synthetic records; close only never-attempted test work, never historical business rows.
for(Long id:h.jdbc.queryForList("select command_id from iotlocks.lock_command where environment_id=? and execution_state='READY' and attempt_started_at is null",Long.class,h.env)) {
try {h.commands.cancel(id);}catch(RuntimeException ignored) { }
}
h.jdbc.update("update iotlocks.lock_auth set execution_state='MANUAL_REVIEW',remark='Round5V4 synthetic test complete; preserved for audit' where environment_id=? and execution_state='READY'",h.env);
}
@Test public void checkinTransferCheckoutRealTransactionsAndMockDualAck() throws Exception {
h.accommodations.checkIn(h.acc,"round5v4");LockAuth first=h.auth(h.deviceA);h.confirm(h.latest(first));
assertEquals(ExecutionStates.CONFIRMED,h.auth(h.deviceA).getExecutionState());
TrainAccommodation next=h.accommodations.transfer(h.acc,h.dormB,"1","round5v4");
LockAuth old=h.auth(h.deviceA),fresh=h.auth(h.deviceB);
assertEquals(ExecutionStates.REVOKE,old.getIntentAction());assertEquals(ExecutionStates.GRANT,fresh.getIntentAction());
h.confirm(h.latest(old));h.confirm(h.latest(fresh));
h.accommodations.checkOut(next.getAccommodationId(),"round5v4");h.confirm(h.latest(h.auth(h.deviceB)));
assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus());
assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceB).getSyncStatus());
assertEquals(Integer.valueOf(2),h.trainMapper.selectTrainAccommodationByAccommodationId(next.getAccommodationId()).getAccommodationStatus());
}
@Test public void revokeIntentBeforeCommandSurvivesActualJvmHalt() throws Exception {
h.checkedIn();assertEquals(73,h.child("intent",h.acc));
LockAuth pending=h.auth(h.deviceA);assertEquals("1",pending.getStatus());assertEquals(ExecutionStates.REVOKE,pending.getIntentAction());
assertNull(h.commandMapper.selectCommandByIntentKey(pending.getIntentKey()));
h.restart();h.auths.retryPending();LockCommand revoke=h.latest(pending);assertNotNull(revoke);h.confirm(revoke);
assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus());
assertEquals(Integer.valueOf(2),h.trainMapper.selectTrainAccommodationByAccommodationId(h.acc).getAccommodationStatus());
}
@Test public void readyRevokeBeforeSendSurvivesActualJvmHalt() throws Exception {
h.checkedIn();assertEquals(75,h.child("ready",h.auth(h.deviceA).getAuthId()));
LockCommand revoke=h.latest(h.auth(h.deviceA));assertEquals(ExecutionStates.READY,revoke.getExecutionState());assertNull(revoke.getAttemptStartedAt());
h.restart();h.commands.retry(revoke.getCommandId());assertEquals(1,h.transport.attempts.get());h.confirm(revoke);
assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus());
}
@Test public void committedClaimUnknownAfterJvmHaltNeverBlindlyReplays() throws Exception {
h.checkedIn();assertEquals(74,h.child("claim",h.auth(h.deviceA).getAuthId()));
LockCommand revoke=h.latest(h.auth(h.deviceA));assertEquals(ExecutionStates.DISPATCHING,revoke.getExecutionState());assertNotNull(revoke.getAttemptStartedAt());
h.jdbc.update("update iotlocks.lock_command set attempt_started_at=? where command_id=?",new Timestamp(System.currentTimeMillis()-120000),revoke.getCommandId());
h.restart();h.commands.markTimeout(30);h.commands.retryPending();h.auths.retryPending();
assertEquals(ExecutionStates.MANUAL_REVIEW,h.commandMapper.selectCommandById(revoke.getCommandId()).getExecutionState());assertEquals(0,h.transport.attempts.get());
assertNotEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus());
}
@Test public void failedNeverAttemptedCommandCanRecover() throws Exception {
h.checkedIn();h.transport.attempts.set(0);h.transport.connected=false;h.auths.clear(h.auth(h.deviceA).getAuthId());LockCommand command=h.latest(h.auth(h.deviceA));
assertEquals(LockConstants.STATUS_FAIL,command.getCommandStatus());assertEquals(ExecutionStates.READY,command.getExecutionState());assertNull(command.getAttemptStartedAt());
h.transport.connected=true;h.commands.retry(command.getCommandId());assertEquals(1,h.transport.attempts.get());h.confirm(command);
assertEquals(LockConstants.SYNC_CLEARED,h.auth(h.deviceA).getSyncStatus());
}
@Test public void concurrentNativeRowLocksClaimOnlyOnce() throws Exception {
h.transport.connected=false;h.accommodations.checkIn(h.acc,"round5v4");Long id=h.latest(h.auth(h.deviceA)).getCommandId();h.transport.connected=true;
ExecutorService pool=Executors.newFixedThreadPool(4);
try {
List<Future<?>> tasks=new ArrayList<>();for(int i=0;i<4;i++)tasks.add(pool.submit(()->h.commands.dispatch(id)));
for(Future<?> f:tasks)f.get(20,TimeUnit.SECONDS);
}finally {pool.shutdownNow();}
assertEquals(1,h.transport.attempts.get());h.confirm(h.commandMapper.selectCommandById(id));
}
@Test public void checkinAndIntentRollbackTogetherAfterInjectedFailure() throws Exception {
h.injectBridgeFault("reconcileCheckin");
try{h.accommodations.checkIn(h.acc,"round5v4");fail();}catch(IllegalStateException expected) { }
assertEquals(Integer.valueOf(0),h.trainMapper.selectTrainAccommodationByAccommodationId(h.acc).getAccommodationStatus());
assertNull(h.auth(h.deviceA));assertEquals(0,h.transport.attempts.get());
}
@Test public void checkoutAndRevokeIntentRollbackTogether() throws Exception {
h.checkedIn();LockAuth before=h.auth(h.deviceA);h.injectBridgeFault("reconcileCheckout");
try{h.accommodations.checkOut(h.acc,"round5v4");fail();}catch(IllegalStateException expected) { }
LockAuth after=h.auth(h.deviceA);assertEquals(before.getIntentKey(),after.getIntentKey());assertEquals(ExecutionStates.GRANT,after.getIntentAction());
assertEquals(Integer.valueOf(1),h.trainMapper.selectTrainAccommodationByAccommodationId(h.acc).getAccommodationStatus());
}
@Test public void lateOldRevokeAckCannotOverwriteNewGrantVersion() throws Exception {
h.checkedIn();h.auths.clear(h.auth(h.deviceA).getAuthId());LockCommand old=h.latest(h.auth(h.deviceA));h.confirm(old);
LockAuth grant=h.auth(h.deviceA);h.auths.updateAuth(grant);LockAuth newer=h.auth(h.deviceA);String key=newer.getIntentKey();Long version=newer.getIntentVersion();
h.confirm(old);assertEquals(key,h.auth(h.deviceA).getIntentKey());assertEquals(version,h.auth(h.deviceA).getIntentVersion());assertEquals("0",h.auth(h.deviceA).getStatus());
}
@Test public void mockAndLegacyCommandsAreQuarantinedAcrossRealBoundary() throws Exception {
h.transport.connected=false;h.accommodations.checkIn(h.acc,"round5v4");LockCommand old=h.latest(h.auth(h.deviceA));
h.props.setMock(false);h.props.setEnabled(false);h.props.setRealDeviceWriteEnabled(false);h.props.setDeviceWhitelist(Collections.emptyList());
h.transport.connected=true;h.commands.dispatch(old.getCommandId());
assertEquals(ExecutionStates.QUARANTINED,h.commandMapper.selectCommandById(old.getCommandId()).getExecutionState());assertEquals(0,h.transport.attempts.get());
assertFalse(h.gate.checkDeviceWriteAllowed(old.getDeviceSn(),"0012"));
}
@Test public void uniqueIntentConstraintIsEnforcedByActualKingbase() throws Exception {
h.transport.connected=false;h.accommodations.checkIn(h.acc,"round5v4");LockCommand original=h.latest(h.auth(h.deviceA));
try {
h.tx.required(()->{LockCommand copy=h.commandMapper.selectCommandById(original.getCommandId());copy.setCommandId(null);copy.setCommandNo("R5V4"+UUID.randomUUID().toString().replace("-",""));h.commandMapper.insertCommand(copy);return null;});fail();
}catch(org.springframework.dao.DuplicateKeyException expected) { }
assertEquals(Integer.valueOf(1),h.jdbc.queryForObject("select count(*) from iotlocks.lock_command where intent_key=?",Integer.class,original.getIntentKey()));
}
static class Harness {
final String env;DataSource ds;JdbcTemplate jdbc;SqlSessionTemplate sessions;DataSourceTransactionManager manager;
LockAuthMapper authMapper;LockCommandMapper commandMapper;TrainAccommodationMapper trainMapper;
LockExecutionTransactions tx;IotMqttProperties props;RealIotSecurityGate gate;LockAuthServiceImpl auths;LockCommandServiceImpl commands;
TrainIotLockAuthBridgeServiceImpl bridgeTarget;ITrainLockAuthBridgeService bridge;TrainAccommodationServiceImpl accommodationTarget;ITrainAccommodationService accommodations;
Transport transport;String person,marker,dormA,dormB;long deviceA,deviceB,acc,enrollment;
Harness(String environment) throws Exception {
env=environment;String url=System.getenv("QA_DB_URL");
if(!"1".equals(System.getenv("ROUND5V4_KINGBASE_TESTS")) || url==null || !url.matches("jdbc:kingbase8://192\\.168\\.139\\.218:4321/railwaysystem_fusion_qa_20261013(?:\\?.*)?"))throw new SecurityException("Explicit isolated QA opt-in/URL required");
Class.forName("com.kingbase8.Driver");DriverManagerDataSource raw=new DriverManagerDataSource(url,"fusion_qa_app",System.getenv("QA_DB_PASSWORD"));
ds=new AbstractDataSource(){public Connection getConnection()throws SQLException{Connection c=raw.getConnection();try(Statement s=c.createStatement()){s.execute("set search_path to iotlocks,public,pg_catalog,sys_catalog");s.execute("set lock_timeout='5s'");}return c;}public Connection getConnection(String u,String p)throws SQLException{return getConnection();}};
jdbc=new JdbcTemplate(ds);assertEquals("railwaysystem_fusion_qa_20261013",jdbc.queryForObject("select current_database()",String.class));assertEquals("fusion_qa_app",jdbc.queryForObject("select current_user",String.class));
// Recovery APIs scan pending work. Refuse tests if unrelated active work could be affected.
for(String table:Arrays.asList("lock_auth","lock_command"))
if(jdbc.queryForObject("select count(*) from iotlocks."+table+" where execution_state='READY' and execution_mode<>'LEGACY' and coalesce(environment_id,'') not like 'round5v4-qa-%' and coalesce(environment_id,'')<>?",Integer.class,env)>0)
throw new SecurityException("Unrelated pending QA work exists; recovery test refused");
manager=new DataSourceTransactionManager(ds);tx=new LockExecutionTransactions(manager);
SqlSessionFactoryBean f=new SqlSessionFactoryBean();f.setDataSource(ds);org.apache.ibatis.session.Configuration mc=new org.apache.ibatis.session.Configuration();mc.setDatabaseId("kingbase");f.setConfiguration(mc);f.setTypeAliasesPackage("com.ruoyi.iot.lock.domain;com.ruoyi.train.domain");
List<org.springframework.core.io.Resource> resources=new ArrayList<>();
for(String n:Arrays.asList("LockAuth","LockCommand","LockDevice","LockGateway","LockGroup","LockFace"))resources.add(new ClassPathResource("mapper/lock/"+n+"Mapper.xml"));
for(String n:Arrays.asList("TrainAccommodation","TrainEnrollment","TrainStudent","TrainDormitoryInfo","TrainClassPlan"))resources.add(new ClassPathResource("mapper/train/"+n+"Mapper.xml"));
f.setMapperLocations(resources.toArray(new org.springframework.core.io.Resource[0]));sessions=new SqlSessionTemplate(f.getObject());
authMapper=sessions.getMapper(LockAuthMapper.class);commandMapper=sessions.getMapper(LockCommandMapper.class);trainMapper=sessions.getMapper(TrainAccommodationMapper.class);
props=new IotMqttProperties();props.setMock(true);props.setEnabled(false);props.setRealDeviceWriteEnabled(false);props.setDeviceWhitelist(Collections.emptyList());props.setEnvironmentId(env);restart();
}
@SuppressWarnings("unchecked") <T>T transactional(Object target,Class<T> type){
ProxyFactory p=new ProxyFactory(target);p.setInterfaces(type);p.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));return (T)p.getProxy();
}
void restart() throws Exception {
gate=new RealIotSecurityGate(props);
for(String n:Arrays.asList("device","gateway","group","auth","command")){
Class<?> type=Class.forName("com.ruoyi.iot.lock.mapper.Lock"+Character.toUpperCase(n.charAt(0))+n.substring(1)+"Mapper");inject(gate,n+"Mapper",sessions.getMapper(type));
}
commands=new LockCommandServiceImpl();auths=new LockAuthServiceImpl();transport=new Transport(this);
for(Object s:Arrays.asList(commands,auths)){inject(s,"authMapper",authMapper);inject(s,"commandMapper",commandMapper);inject(s,"securityGate",gate);inject(s,"transactions",tx);}
inject(commands,"mqttService",transport);inject(auths,"deviceMapper",sessions.getMapper(LockDeviceMapper.class));inject(auths,"gatewayMapper",sessions.getMapper(LockGatewayMapper.class));inject(auths,"groupMapper",sessions.getMapper(LockGroupMapper.class));inject(auths,"commandService",commands);
LockFaceServiceImpl faces=new LockFaceServiceImpl();inject(faces,"faceMapper",sessions.getMapper(LockFaceMapper.class));
bridgeTarget=new TrainIotLockAuthBridgeServiceImpl();
inject(bridgeTarget,"executionTransactions",tx);
for(String n:Arrays.asList("TrainAccommodation","TrainEnrollment","TrainStudent","TrainDormitoryInfo","TrainClassPlan"))inject(bridgeTarget,Character.toLowerCase(n.charAt(0))+n.substring(1)+"Mapper",sessions.getMapper(Class.forName("com.ruoyi.train.mapper."+n+"Mapper")));
inject(bridgeTarget,"lockDeviceMapper",sessions.getMapper(LockDeviceMapper.class));inject(bridgeTarget,"lockAuthMapper",authMapper);inject(bridgeTarget,"lockCommandMapper",commandMapper);inject(bridgeTarget,"lockAuthService",auths);inject(bridgeTarget,"lockCommandService",commands);inject(bridgeTarget,"lockFaceService",faces);inject(bridgeTarget,"securityGate",gate);
bridge=transactional(bridgeTarget,ITrainLockAuthBridgeService.class);
accommodationTarget=new TrainAccommodationServiceImpl();
for(String n:Arrays.asList("TrainAccommodation","TrainEnrollment","TrainDormitoryInfo","TrainClassPlan"))inject(accommodationTarget,Character.toLowerCase(n.charAt(0))+n.substring(1)+"Mapper",sessions.getMapper(Class.forName("com.ruoyi.train.mapper."+n+"Mapper")));
accommodationTarget.setAuthEngine("iot");accommodationTarget.setIotLockAuthBridgeService(bridge);accommodations=transactional(accommodationTarget,ITrainAccommodationService.class);
}
void seed(){
String key=UUID.randomUUID().toString().replace("-","");marker="R5V4"+key.substring(0,16);person="P5"+key.substring(0,6);dormA=marker+"A";dormB=marker+"B";
String plan=marker+"P",student=marker+"S";Timestamp start=new Timestamp(System.currentTimeMillis()-86400000),end=new Timestamp(System.currentTimeMillis()+3*86400000L);
jdbc.update("insert into public.train_class_plan(plan_id,plan_name,start_time,end_time,create_by) values(?,?,?,?,?)",plan,"Round5V4 Synthetic",start,end,"round5v4");
jdbc.update("insert into public.train_student(s_id,s_name,person_no,org_name,create_by) values(?,?,?,?,?)",student,"Round5V4 Synthetic",person,"ROUND5V4_SYNTHETIC","round5v4");
enrollment=jdbc.queryForObject("insert into public.train_enrollment(plan_id,student_id,accommodation_required,create_by) values(?,?,1,'round5v4') returning enrollment_id",Long.class,plan,student);
Long group=jdbc.queryForObject("insert into iotlocks.lock_group(group_code,group_name,create_by) values(?,?,'round5v4') returning group_id",Long.class,marker,"Round5V4 Synthetic");
Long gateway=jdbc.queryForObject("insert into iotlocks.lock_gateway(gateway_sn,group_id,create_by) values(?,?,'round5v4') returning gateway_id",Long.class,"G5"+key.substring(0,6),group);
for(int i=0;i<2;i++){
String sn=(i==0?"D5":"D6")+key.substring(0,6);
Long id=jdbc.queryForObject("insert into iotlocks.lock_device(device_sn,device_name,gateway_id,group_id,device_password,dev_ver,dev_type,create_by) values(?,?,?,?,?,'11','19','round5v4') returning device_id",Long.class,sn,"Round5V4 Synthetic",gateway,group,UUID.randomUUID().toString());
if(i==0)deviceA=id;else deviceB=id;
jdbc.update("insert into public.train_dormitory_info(dorm_id,dorm_building,dorm_floor,dorm_room,lock_device_sn,capacity,create_by) values(?,?,'1',?,?,4,'round5v4')",i==0?dormA:dormB,marker,i==0?"A":"B",sn);
}
jdbc.update("insert into iotlocks.lock_face(person_no,person_name,feature_data,feature_md5,extract_status,create_by) values(?,'Round5V4 Synthetic',?,?,'1','round5v4')",person,"mock_feature_round5v4_"+marker,key);
TrainAccommodation a=new TrainAccommodation(enrollment,dormA,"1");a.setCreateBy("round5v4");accommodations.insertTrainAccommodation(a);acc=a.getAccommodationId();
}
LockAuth auth(long device){List<LockAuth> all=authMapper.selectAuthsByPersonNo(person);return all.stream().filter(a->a.getDeviceId()==device).findFirst().orElse(null);}
LockCommand latest(LockAuth a){return commandMapper.selectCommandByIntentKey(authMapper.selectAuthById(a.getAuthId()).getIntentKey());}
void checkedIn(){accommodations.checkIn(acc,"round5v4");confirm(latest(auth(deviceA)));}
void confirm(LockCommand c){ack(c,"10");ack(c,"0");}
void ack(LockCommand c,String code){YdlinkFrameDTO req=YdlinkCodec.fromJson(c.getPayload());YdlinkFrameDTO response=new YdlinkFrameDTO();response.setSeq(c.getCommandNo());response.setOp(req.getOp());response.setFlatParams(Arrays.asList(String.valueOf(req.getParams().get(0)),c.getDeviceSn(),code,"","round5v4-mock"));String body=YdlinkCodec.toJson(response);if("10".equals(code))commands.handleGatewayAck(c.getTopic()+"_report",c.getCommandNo(),body,"Mock only");else commands.handleResponse(c.getTopic()+"_report",c.getCommandNo(),body,true,"Mock only");}
void injectBridgeFault(String method){accommodationTarget.setIotLockAuthBridgeService((ITrainLockAuthBridgeService)Proxy.newProxyInstance(ITrainLockAuthBridgeService.class.getClassLoader(),new Class<?>[]{ITrainLockAuthBridgeService.class},(p,m,a)->{try{Object r=m.invoke(bridge,a);if(m.getName().equals(method))throw new IllegalStateException("synthetic post-intent rollback");return r;}catch(InvocationTargetException e){throw e.getCause();}}));}
int child(String mode,long id)throws Exception{
Path dir=Paths.get(System.getenv("ROUND5V4_LOG_DIR"));Files.createDirectories(dir);Path log=Files.createTempFile(dir,"child-"+mode,".log",PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
Process p=new ProcessBuilder(System.getProperty("java.home")+"/bin/java","-cp",System.getProperty("surefire.test.class.path",System.getProperty("java.class.path")),Round5v4KingbaseIntegrationTest.class.getName(),mode,String.valueOf(id),env).redirectErrorStream(true).redirectOutput(log.toFile()).start();
if(!p.waitFor(40,TimeUnit.SECONDS)){p.destroyForcibly();throw new AssertionError("Isolated child timeout");}return p.exitValue();
}
}
static void inject(Object target,String name,Object value)throws Exception{Field f=target.getClass().getDeclaredField(name);f.setAccessible(true);f.set(target,value);}
static class Transport implements IMqttService {
final Harness h;boolean connected=true,crashAfterClaim;AtomicInteger attempts=new AtomicInteger();Transport(Harness harness){h=harness;}
public boolean isConnected(){return connected;}
public void publishCommand(LockCommand c){h.gate.requirePersistedDispatch(c);assertEquals(ExecutionStates.DISPATCHING,h.jdbc.queryForObject("select execution_state from iotlocks.lock_command where command_id=?",String.class,c.getCommandId()));if(crashAfterClaim)Runtime.getRuntime().halt(74);attempts.incrementAndGet();}
public void publish(String t,String p){throw new AssertionError("Raw publication forbidden");}public void publish(String t,Object p,Integer q,Boolean r){throw new AssertionError("Raw publication forbidden");}
public void subscribe(String t){}public void subscribe(String t,Integer q){}public void unsubscribe(String t){}public void reconnect(){}public Map<String,Integer> getSubscriptions(){return Collections.emptyMap();}
}
/** Separate process: committed database intent/claim survives Runtime.halt without touching any server process. */
public static void main(String[] args)throws Exception{
Harness h=new Harness(args[2]);long id=Long.parseLong(args[1]);
if(args[0].equals("intent")){
ILockAuthService crashing=(ILockAuthService)Proxy.newProxyInstance(ILockAuthService.class.getClassLoader(),new Class<?>[]{ILockAuthService.class},(p,m,a)->{if(m.getName().equals("clear"))Runtime.getRuntime().halt(73);try{return m.invoke(h.auths,a);}catch(InvocationTargetException e){throw e.getCause();}});
inject(h.bridgeTarget,"lockAuthService",crashing);h.accommodations.checkOut(id,"round5v4");
}else if(args[0].equals("ready")){h.transport.connected=false;h.auths.clear(id);Runtime.getRuntime().halt(75);}
else if(args[0].equals("claim")){h.transport.crashAfterClaim=true;h.auths.clear(id);}
throw new AssertionError("Crash injection was not reached");
}
}
@@ -114,6 +114,13 @@ public class TrainIotLockAuthBridgeMockTest
// 组装桥接服务
bridgeService = new TrainIotLockAuthBridgeServiceImpl();
// This in-memory fixture does not prove database transaction semantics.
com.ruoyi.iot.lock.service.LockExecutionTransactions transactions = new com.ruoyi.iot.lock.service.LockExecutionTransactions(new org.springframework.transaction.PlatformTransactionManager() {
public org.springframework.transaction.TransactionStatus getTransaction(org.springframework.transaction.TransactionDefinition d) { return new org.springframework.transaction.support.SimpleTransactionStatus(); }
public void commit(org.springframework.transaction.TransactionStatus s) { }
public void rollback(org.springframework.transaction.TransactionStatus s) { }
});
setField(bridgeService, "executionTransactions", transactions);
com.ruoyi.iot.config.IotMqttProperties executionProps = new com.ruoyi.iot.config.IotMqttProperties();
executionProps.setMock(true); executionProps.setEnvironmentId("bridge-fixture-mock");
com.ruoyi.iot.lock.security.RealIotSecurityGate executionGate = new com.ruoyi.iot.lock.security.RealIotSecurityGate(executionProps);