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

+66 -15
View File
@@ -5,9 +5,15 @@
## 当前验收边界 ## 当前验收边界
Round 5V-3 已成功接管SSH管理入口,完成匹配V9原生一致性备份及归档完整读取。 Round 5V-4 已在获准的新建离线 Kingbase V9 容器中实际恢复原生备份,完成
005授权有效,但独立恢复环境尚未确认或获准创建,当前状态为 005 的提交、重复执行、异常/断连/锁超时回滚及原备份恢复演练,然后才对
`MIGRATION_AUTHORIZED_BLOCKED_ISOLATED_RESTORE`,005未执行。 `railwaysystem_fusion_qa_20261013` 执行 16 列 / 4 索引增量迁移。
11 项真实 Kingbase Service/Mapper/事务测试(含独立测试 JVM 强制中断)已通过。
当前 QA 后端、15 个 Chrome 路由与合成学员入住/调宿/退宿的 Mock 双回执闭环已实际验收。
详见 [本轮软件验收记录](docs/iot/ROUND5V4_SOFTWARE_QA.md)。
但收尾18:27实测服务器22/4321均超时,后端出现08001/EOF;当前就绪状态为
`BLOCKED_NETWORK`,不是软件功能失败或迁移未执行。已停止本轮本机QA三进程。
管理员恢复合法连接后,重新执行只读预检和隔离启动即可;不要重做005、覆盖恢复或修改系统路由。
不能将 H2/替身测试、构建成功或登录页显示称为 不能将 H2/替身测试、构建成功或登录页显示称为
Kingbase 事务/浏览器业务闭环验收。真实硬件验收始终未进行。 Kingbase 事务/浏览器业务闭环验收。真实硬件验收始终未进行。
迁移前置、失败回滚和核验清单见 [迁移计划](docs/iot/ROUND5V_KINGBASE_MIGRATION_PLAN.md)。 迁移前置、失败回滚和核验清单见 [迁移计划](docs/iot/ROUND5V_KINGBASE_MIGRATION_PLAN.md)。
@@ -30,6 +36,7 @@ bash scripts/bootstrap/install-kingbase-jdbc.sh
bash scripts/bootstrap/check-kingbase-jdbc.sh bash scripts/bootstrap/check-kingbase-jdbc.sh
mvn -B -f RuoYi-Vue/pom.xml -pl ruoyi-admin -am '-Dtest=Round5s*,Round5vAccommodationAtomicityTest,RealIotSecurityGateTest,FaceExtractClientMockTest,CommandRespHandlerMockTest,DmsHttpClientTest,DmsTokenManagerTest,TrainIotLockAuthBridgeMockTest' -Dsurefire.failIfNoSpecifiedTests=false package mvn -B -f RuoYi-Vue/pom.xml -pl ruoyi-admin -am '-Dtest=Round5s*,Round5vAccommodationAtomicityTest,RealIotSecurityGateTest,FaceExtractClientMockTest,CommandRespHandlerMockTest,DmsHttpClientTest,DmsTokenManagerTest,TrainIotLockAuthBridgeMockTest' -Dsurefire.failIfNoSpecifiedTests=false package
python3 -m unittest discover -s scripts/qa -p test_qa_tools.py -v python3 -m unittest discover -s scripts/qa -p test_qa_tools.py -v
mvn -B -f RuoYi-Vue/pom.xml -pl ruoyi-admin -am -Dtest=Round5v4MissingResourceTest -Dsurefire.failIfNoSpecifiedTests=false test
``` ```
驱动私有坐标:`cn.com.kingbase:kingbase8:9.0.0`;SHA256: 驱动私有坐标:`cn.com.kingbase:kingbase8:9.0.0`;SHA256:
@@ -72,11 +79,12 @@ V009R003C018原生工具;现有kingbase管理角色可经容器本地连接认
fusion_qa_app提权。已完成原生备份,服务器及本机副本均在非Git目录,目录0700、文件0600。 fusion_qa_app提权。已完成原生备份,服务器及本机副本均在非Git目录,目录0700、文件0600。
最新位置、SHA及独立恢复解阻方案见 [本轮记录](docs/iot/ROUND5V3_SERVER_BACKUP.md)。 最新位置、SHA及独立恢复解阻方案见 [本轮记录](docs/iot/ROUND5V3_SERVER_BACKUP.md)。
`sys_restore --file=/dev/null`成功仅证明归档可完整读取,不能替代实际数据库恢复。 `sys_restore --file=/dev/null`成功仅证明归档可完整读取,不能替代实际数据库恢复。
本机Docker守护进程不可用;服务器只确认一个共享V9实例,旧V8目录不是获准的 Round 5V-4 已另获明确授权,复用服务器现有 V9 镜像创建专用隔离恢复容器,
兼容恢复环境。需提供已有独立恢复目标,或明确另行批准隔离容器/实例及恢复库。 原生恢复及迁移/回滚均已实际验证;该授权不扩展到其他实例或数据库。
不要自行创建数据库、启动新容器或复用共享库进行覆盖恢复。 恢复证据及新建资源身份见本轮记录;不要把一次性演练脚本当作通用初始化脚本重跑,
不要复用共享库进行覆盖恢复。历史 Round 5V-3 文档保留为当时的真实阻塞记录。
## 3. 启停隔离 QA(迁移批准并完成后) ## 3. 启停隔离 QA(目标库 005 已完成)
```bash ```bash
python3 scripts/qa/qa.py start --config /protected/round5v.env python3 scripts/qa/qa.py start --config /protected/round5v.env
@@ -91,6 +99,9 @@ python3 scripts/qa/qa.py frontend
whitelist空 / protocol-verified=false / retry-task-enabled=false;人脸 HTTP关闭; whitelist空 / protocol-verified=false / retry-task-enabled=false;人脸 HTTP关闭;
OpenAPI、QA危险接口、Druid控制台和Quartz自动启动关闭;旧DMS关闭。 OpenAPI、QA危险接口、Druid控制台和Quartz自动启动关闭;旧DMS关闭。
继承环境中的 Spring/IoT/DMS/JVM 注入覆盖被过滤,数据库 URL/账号由命令行固定。 继承环境中的 Spring/IoT/DMS/JVM 注入覆盖被过滤,数据库 URL/账号由命令行固定。
启动器将构建包复制为 `.qa-runtime/ruoyi-admin-<SHA前缀>.jar` 并校验散列,防止 Maven
覆盖正在运行的嵌套 JAR。Druid URL 去除 JDBC 秒单位超时参数,池统一使用连接30秒/
网络60秒;实际 Kingbase/Druid 探针验证了 60000ms,不能把 URL 的 15 当成池毫秒值。
JWT和独立Redis密钥由安全随机数首次生成至 `.qa-runtime/runtime-secrets.env`,0600, JWT和独立Redis密钥由安全随机数首次生成至 `.qa-runtime/runtime-secrets.env`,0600,
以进程环境注入,不写源码/命令行;数据库及身份密钥仅取受保护配置文件。 以进程环境注入,不写源码/命令行;数据库及身份密钥仅取受保护配置文件。
不得为演示去打开真实门禁或危险接口。 不得为演示去打开真实门禁或危险接口。
@@ -101,10 +112,48 @@ curl --noproxy '*' --fail http://127.0.0.1:18096/captchaImage
python3 scripts/qa/qa.py stop python3 scripts/qa/qa.py stop
``` ```
stop只处理本脚本账本中且进程命令身份仍匹配的PID;不会清端口、杀其他Chrome、 stop只处理本脚本账本中且进程命令身份仍匹配的PID,等待其退出再移除账本;不会清端口、杀其他Chrome、
停止实验工程或修改系统网络。JWT、访问日志、快照、浏览器证据均不进入Git。 停止实验工程或修改系统网络。JWT、访问日志、快照、浏览器证据均不进入Git。
## 4. 迁移后验收清单(未完成项不得标 PASS) ## 4. 可复现的真实 Kingbase / Chrome 软件验收
仅在已迁移、已批准合成写入的同名 QA 库执行:
```bash
python3 scripts/qa/run_kingbase_tests.py --approve-synthetic-qa-writes --config /protected/round5v.env
# 真实 Kingbase 11 项;不使用 H2,不启动任何真实 MQTT / 人脸客户端。
# 合成数据有 R5V4 标识并保留审计,不删除既有数据,不重置序列。
```
Chrome 验收使用 Playwright CLI 技能(本轮实际 Chrome 155.0.8059.40),打开独立
`round5v4` 会话后运行:
```bash
python3 scripts/qa/browser_login.py --config /protected/round5v.env
# 若 QA 自身启用验证码:从实际截图识别后,加 --use-current-page --captcha ACTUAL_ANSWER;
# 不关闭验证码。本轮现有配置的 captchaImage 返回 captchaEnabled=false,未改其开关。
playwright-cli -s=round5v4 run-code --filename=scripts/qa/browser_regression.js
python3 scripts/qa/browser_fixture.py seed --approve-synthetic-qa-writes --config /protected/round5v.env
```
登录配置额外需要受保护文件中的 QA_ADMIN_USERNAME / QA_ADMIN_PASSWORD。登录驱动仅
在独立 about:blank 页内存中读取配置,关闭该页后填写登录表单,不把配置上传至应用。
CLI 不具备本机工具时使用技能自带 wrapper;不要为此修改工程依赖或系统浏览器设置。
`seed` 仅首次执行,生成 `.qa-runtime/round5v4/browser-fixture.properties`;已存在则拒绝覆盖。
按该文件学号在动态菜单 `/equip/accommodation` 搜索,依次入住、调房至同标识 B 宿舍、退宿。
每次操作后,先观察 AWAITING_ACK,然后执行受控回执驱动,再到授权和指令页面核对:
```bash
python3 scripts/qa/browser_fixture.py state --approve-synthetic-qa-writes --config /protected/round5v.env
python3 scripts/qa/browser_fixture.py ack --approve-synthetic-qa-writes --config /protected/round5v.env
python3 scripts/qa/browser_fixture.py verify --approve-synthetic-qa-writes --config /protected/round5v.env
```
驱动使用真实 Mapper/Service,只接受该文件合成人员与两把合成门锁、MOCK 来源及
`round5v-local-mock` 环境的当前 AWAITING_ACK 指令,不开放任何 HTTP 回执端点。
授权页按人员编号过滤,指令页按 fixture.marker(分组编号)过滤,应有四条 CONFIRMED 指令。
住宿 V2 模型只持久化分配与最后变更时间,页面已按真实字段标注,不捏造独立入住/退宿时间戳。
1. 管理员真实登录、getInfo/getRouters、原品牌菜单和首页图表;培训核心查询。 1. 管理员真实登录、getInfo/getRouters、原品牌菜单和首页图表;培训核心查询。
2. IoT九页:group/gateway/device/face/auth/command/accesslog/mqttlog/config, 2. IoT九页:group/gateway/device/face/auth/command/accesslog/mqttlog/config,
@@ -116,13 +165,15 @@ stop只处理本脚本账本中且进程命令身份仍匹配的PID;不会清
ACK由隔离的Service测试驱动,不通过开放生产式控制接口来换取通过。 ACK由隔离的Service测试驱动,不通过开放生产式控制接口来换取通过。
5. 受控Mock ACK成功只能证明软件闭环,不能证明真实门锁已授权或撤权。 5. 受控Mock ACK成功只能证明软件闭环,不能证明真实门锁已授权或撤权。
现有 `Round5sExecutionIntegrationTest` 的文件型H2证据不是第4项的替代品; 现有 H2 与替身测试仍不等于真实数据库证据;本轮新增
`Round5vAccommodationAtomicityTest`证明异常传播/事务声明,不证明Kingbase回滚。 `Round5v4KingbaseIntegrationTest` 才是上述 Kingbase 事务和应用进程中断验证的执行入口。
## 5. CI与当前阻塞 ## 5. CI与硬件边界
`.gitlab-ci.yml` 已改为显式web+RUN_FUSION_VALIDATION=true才允许的验证流水线; `.gitlab-ci.yml` 已改为显式web+RUN_FUSION_VALIDATION=true才允许的验证流水线;
Java21、私有JDBC预检及npm ci,**无部署job**。本轮未Push,也未触发/验证远程Runner。 Java21、私有JDBC预检及npm ci,**无部署job**。本轮未Push,也未触发/验证远程Runner。
005授权、原生备份及既有管理连接均已确认;当前仍缺实际独立恢复证明、真实Kingbase生命周期验证和 本轮数据库、事务、启动和 Chrome 软件验收已经完成;仍未做真实门锁、人脸或网关验收。
依托新QA后端的完整Chrome验收。迁移安全前提未满足时,单独前端会明确返回502, 硬件测试另需老师批准、确认协议中未验证的帧校验字段/回执及重传策略、单台白名单和人工核对流程。
不得用旧实验后端或伪造API响应冒充新版本验收。 商业驱动和恢复容器许可仅按现有合法 QA/试用环境验证,不代表生产部署许可。
应用账号既有的三张系统表过宽权限未扩大也未自动收回,应由管理员另行最小化。
任何安全前提不满足时,不得用旧实验后端或伪造 API 响应冒充新版本验收。
@@ -29,6 +29,14 @@ public class GlobalExceptionHandler
{ {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); 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> </description>
<dependencies> <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> <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.ILockAuthService;
import com.ruoyi.iot.lock.service.ILockCommandService; import com.ruoyi.iot.lock.service.ILockCommandService;
import com.ruoyi.iot.lock.service.ILockFaceService; 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.TrainAccommodation;
import com.ruoyi.train.domain.TrainClassPlan; import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainDormitoryInfo; import com.ruoyi.train.domain.TrainDormitoryInfo;
@@ -92,6 +93,9 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe
@Autowired @Autowired
private com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate; private com.ruoyi.iot.lock.security.RealIotSecurityGate securityGate;
@Autowired
private LockExecutionTransactions executionTransactions;
@org.springframework.beans.factory.annotation.Value("${train.lock.password-fallback-enabled:false}") @org.springframework.beans.factory.annotation.Value("${train.lock.password-fallback-enabled:false}")
private boolean passwordFallbackEnabled = false; private boolean passwordFallbackEnabled = false;
@@ -553,7 +557,9 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe
} }
try 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) catch (Exception ex)
{ {
@@ -569,7 +575,7 @@ public class TrainIotLockAuthBridgeServiceImpl implements ITrainLockAuthBridgeSe
} }
try try
{ {
lockAuthService.clear(authId); executionTransactions.committed(() -> { lockAuthService.clear(authId); return null; });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -140,7 +140,7 @@
SELECT SELECT
COALESCE(cr.room_name, ts.site_name, '未知场地') AS venue_name, COALESCE(cr.room_name, ts.site_name, '未知场地') AS venue_name,
count(1) AS usage_count, 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 FROM train_lesson l
LEFT JOIN train_classroom_info cr ON cr.class_room_id = l.class_room_id 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 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(); 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(); com.ruoyi.iot.config.IotMqttProperties executionProps = new com.ruoyi.iot.config.IotMqttProperties();
executionProps.setMock(true); executionProps.setEnvironmentId("bridge-fixture-mock"); executionProps.setMock(true); executionProps.setEnvironmentId("bridge-fixture-mock");
com.ruoyi.iot.lock.security.RealIotSecurityGate executionGate = new com.ruoyi.iot.lock.security.RealIotSecurityGate(executionProps); com.ruoyi.iot.lock.security.RealIotSecurityGate executionGate = new com.ruoyi.iot.lock.security.RealIotSecurityGate(executionProps);
@@ -73,14 +73,14 @@
<el-tag v-else size="small">{{ scope.row.accommodationStatus }}</el-tag> <el-tag v-else size="small">{{ scope.row.accommodationStatus }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="入住时间" align="center" prop="checkInTime" width="150"> <el-table-column label="分配时间" align="center" prop="assignedTime" width="150">
<template slot-scope="scope"> <template slot-scope="scope">
<span>{{ parseTime(scope.row.checkInTime, '{y}-{m}-{d} {h}:{i}') }}</span> <span>{{ parseTime(scope.row.assignedTime, '{y}-{m}-{d} {h}:{i}') }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="退宿时间" align="center" prop="checkOutTime" width="150"> <el-table-column label="最近状态变更" align="center" prop="updateTime" width="150">
<template slot-scope="scope"> <template slot-scope="scope">
<span>{{ parseTime(scope.row.checkOutTime, '{y}-{m}-{d} {h}:{i}') }}</span> <span>{{ parseTime(scope.row.updateTime, '{y}-{m}-{d} {h}:{i}') }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" min-width="180"> <el-table-column label="操作" align="center" class-name="small-padding fixed-width" min-width="180">
@@ -187,14 +187,14 @@
<el-tag v-else size="mini">{{ scope.row.accommodationStatus }}</el-tag> <el-tag v-else size="mini">{{ scope.row.accommodationStatus }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="入住时间" align="center" width="150"> <el-table-column label="分配时间" align="center" width="150">
<template slot-scope="scope"> <template slot-scope="scope">
<span>{{ parseTime(scope.row.checkInTime, '{y}-{m}-{d} {h}:{i}') }}</span> <span>{{ parseTime(scope.row.assignedTime, '{y}-{m}-{d} {h}:{i}') }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="退宿/调离时间" align="center" width="150"> <el-table-column label="最近状态变更" align="center" width="150">
<template slot-scope="scope"> <template slot-scope="scope">
<span>{{ parseTime(scope.row.checkOutTime, '{y}-{m}-{d} {h}:{i}') }}</span> <span>{{ parseTime(scope.row.updateTime, '{y}-{m}-{d} {h}:{i}') }}</span>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
+3
View File
@@ -1,5 +1,8 @@
# Round 5V-3 — SSH接管、原生备份完成,独立恢复仍待解阻 # Round 5V-3 — SSH接管、原生备份完成,独立恢复仍待解阻
历史记录保持原状。后续 Round 5V-4 已另获新建隔离恢复容器授权,实际恢复、005演练/回滚及源QA迁移均已完成;
当前结论见 [Round 5V-4 软件验收](ROUND5V4_SOFTWARE_QA.md),下文的“尚未恢复/未迁移”仅指当时状态。
日期:2026-10-14;工作分支 `fix/iot-safety-hardening`;开始提交 日期:2026-10-14;工作分支 `fix/iot-safety-hardening`;开始提交
`b94ac7f05aef80e092fddcf3d6ff3ac0a1a890b3`。本轮没有重新运行H2或源码安全审计。 `b94ac7f05aef80e092fddcf3d6ff3ac0a1a890b3`。本轮没有重新运行H2或源码安全审计。
凭据仅用于SSH认证输入,未写入工程、命令行参数、报告或凭据文件。 凭据仅用于SSH认证输入,未写入工程、命令行参数、报告或凭据文件。
+179
View File
@@ -0,0 +1,179 @@
# Round 5V-4 — Kingbase 隔离恢复、005 迁移与软件融合 QA
执行日期:2026-10-14。工作树:`boot4-iot-fusion-archive`;分支:`fix/iot-safety-hardening`。
本轮基线:`ba5dc7a6af9e6bb6b5166ef04d49af621c4f8de2`。
GitLab 已归档融合基线仍为 `feat/boot4-iot-fusion@37392144`;本轮没有 Push、Merge 或共享部署。
**收尾状态(18:27之后):BLOCKED_NETWORK,不声明当前 SOFTWARE_QA_READY=YES。**
下列迁移、11项真实Kingbase测试、15路由及浏览器业务闭环在17:35前已实际通过并保留证据。
之后数据库连接中断(后端首条记录17:50:55,SQLSTATE08001;后续EOF),18:27独立TCP探测
192.168.139.218:22和:4321均超时。无法仅凭本机证据区分服务器离线、链路或路由原因,
未修改Mac/服务器网络、代理、服务或数据库配置。已停止本轮本机QA前端/后端/Redis,
不把端口仍监听当成数据库可用。恢复合法连接后只需重新预检、启动与关键读验收,
**005已经成功提交,不要因为断网而重复初始化、覆盖恢复或重做迁移。**
## 1. 授权与实际边界
本轮用户另行批准在 192.168.139.218 新建离线、资源受限 V9 临时恢复容器及恢复库;
只有原生备份恢复、005 演练及失败恢复通过,才能迁移独立 QA 库。
所有 Kingbase 业务写入均限定 `railwaysystem_fusion_qa_20261013`,或本轮新隔离容器中的专用恢复库。
未修改共享 `training`、独立 `iotlocks` 等数据库;目标 QA 内的 `iotlocks` 是 Schema,不是另一个数据库。
未扩大业务账号权限、修改系统网络、控制 MQTT/人脸/门锁设备、重启共享服务。
## 2. 镜像、许可、资源和共享服务
服务器只读确认:root UID0,hostname `localhost.localdomain`,NFSChina Server 4.0,
128 CPU、512 GiB 内存、可用约 372000 MiB,磁盘剩余约 535 GiB,负载低。
现有 `kingbase` 容器 `dcd88030ca5a`,4321→54321,版本 `KingbaseES V009R003C018`;
原挂载 `/opt/kingbase-docker/data` 未修改或复用。原生工具目录 `/home/kingbase/install/kingbase/bin/`。
仅复用已存在镜像 `kingbase_v009r003c018b0003_single_x86:v1`:
`sha256:540fa4d0c8f365c8d363a801404e93f8401c7bc5035ecd7b621f0dba862f2515`。
宿主机已有导入 tar 的 manifest/config 散列与 Docker image ID 相符;无 RepoDigest,
未拉取新镜像,不声称额外验证了供应商签名或生产供应链来源。
从镜像原始层读取并用原生许可工具检查:V009 企业版试用授权,180天,容器名/MAC不绑定,
未见 vCPU 限额;未改写许可、复制生产凭据或连接许可服务器。新服务实际通过原生启动许可校验。
这只证明现有获准 QA/试用范围可启动,不代表长期或生产部署许可。
参考:[Kingbase Docker 安装与许可检查](https://bbs.kingbase.com.cn/kingbase-html/v9/install-updata/install-docker/install-docker-1.html)。
新容器 `railwaysystem-qa-restore-round5v4`:
`c11ca10490c242651c0e87c2778102cd9de32cd703b48b57c26801686d4ef41f`。
实际运行过;Docker inspect 与 cgroup 实测确认 network=none、只有 lo、路由为空、
PortBindings={}、2 CPU、2 GiB memory+swap总额、pids=256、privileged=false、drop NET_RAW,
没有 Docker Socket 或正式数据卷。唯一新数据挂载:
`/opt/railwaysystem-qa-restore-round5v4/data` → `/home/kingbase/userdata`。
创建前检查 realpath、符号链接、名称和挂载冲突;专用随机凭据仅在 root0700 目录中的0600文件。
存储不是 5 GiB 硬配额。按用户允许的替代条件,独立监护进程每2秒统计任务目录、overlay及日志,
达到4 GiB或宿主机空闲低于100 GiB即核验容器身份后停止;日志有轮转上限。
实际观测用量约219 MB,远低于阈值;没有 OOM。监护实现:`scripts/qa/round5v4_storage_guard.py`。
共享 Kingbase/GitLab 的容器 ID 和启动时间一直未变(均为2026-10-06),GitLab healthy,其他服务持续运行。
本轮恢复完成后仅停止上述新容器;不删除其数据、恢复证据或备份。
首次Docker stop因镜像入口未响应而超时退出137(非OOM);仅重启该新实例后,
实际复核恢复库和原备份恢复库全量清单仍一致,再用原生sys_ctl fast正常停库(exit0),
sys_controldata确认cluster state=shut down,最后停止容器入口。没有重启共享实例。
## 3. 原生备份与实际恢复(不是文件读取替代)
备份源:`railwaysystem_fusion_qa_20261013`;原生 V9 `sys_dump` 自定义归档,
此前同一导出快照的63表清单/指纹,与本轮迁移前源库及新恢复库再次逐项匹配。
远端:`/opt/railway-fusion-qa-backup-20261014.YLjmJ0/fusion-qa-pre005-v2.dump`;
本机:`/Users/wzb/railway-fusion-qa-backup-20261014.6cgPn7/fusion-qa-pre005-v2.dump`。
380907字节,SHA256:`caa62bc985bd433b5d428b36e34d684b557c82576b69e5e5d9d348edfdf316b4`。
两份均在 Git 外受保护目录,原备份未改写、未删除。
在新实例创建 `railwaysystem_fusion_restore_round5v4`,用匹配版本 `sys_restore -O -x -1 -e`
实际恢复成功,退出0,stderr为空。Owner 映射到仅在新实例存在的 `round5v4_admin`,
不恢复源库认证配置/ACL;此为明确的恢复映射,而非遗漏生产认证。
实测恢复对象:public54表 + iotlocks9表;所有63表行数及排序行指纹一致;
892列、146索引、113约束、39序列、20扩展及Schema/默认值/触发器清单完成对账。
旧授权10条、旧指令15条,培训学员47、参训57、住宿34条。
唯一 native dump/reparse 语法差异是 `chk_external_operation_status` 的数组/元素 text cast:
只对这一个精确约束做规范化,另用原生SQL对10个允许值、4个非法值及NULL共15例验证语义无差异。
其他元数据不宽泛忽略。恢复后基本只读查询通过。
清单生成器:`scripts/qa/kingbase_manifest.sql`;受保护证据包含原生清单和JSON摘要。
## 4. 005 的隔离演练、回滚与源 QA 执行
005 SHA256:`90648b6ac67470f9a14f0795c4479dfb4811d4907e41a13ee5eeada16582b87f`。
审计完整20条语句:16项新增列、4项新增索引(含唯一意图索引);无DROP/TRUNCATE/DELETE/覆写业务数据。
隔离 V9 实际通过:
| 场景 | 实际结果 |
|---|---|
| 迁移中故意调用不存在函数 | SQLSTATE42883;全量DDL回滚,原结构和数据指纹一致 |
| 客户端在未提交005后断开 | 服务器自动回滚;结构/指纹一致 |
| 另一连接持读锁,DDL等锁500ms | SQLSTATE55P03;安全回滚,未破坏数据 |
| 005正常COMMIT | 16列/4索引、默认值、旧记录隔离均正确 |
| 同一005重复执行 | 全量结构/数据清单不变 |
| 重复意图键 | 原生唯一约束23505,事务回滚 |
| 从原备份进行失败恢复 | 在同一新容器内另建专用 recovery 库并实际恢复,完整清单与迁移前一致 |
上述为真实数据库的事务、连接中断及恢复验证;另有收尾时仅临时实例非优雅停止后的重启完整性复核,
不是对共享/源QA数据库服务器断电作测试,也不是完整灾难恢复/长期故障注入认证。
没有在源QA库上删除列/表或全库覆盖演练。
之后重新核验源数据库名/Schema、原备份SHA、63表清单均未变化、其他源库连接数0;
使用已有管理角色,不提权业务账号。设置 lock_timeout=3s、statement_timeout=30s,
显式事务及两表ACCESS EXCLUSIVE锁,在锁内再次核对原行指纹,执行相同005并COMMIT。
源 QA 的后验清单与隔离迁移后清单完全对应。旧10授权/15命令为 LEGACY/QUARANTINED、
version0、无来源环境/意图key,未转换为REAL。业务验收后再次过滤LEGACY核对其完整原记录指纹,仍一致。
一次性执行/核验代码:`scripts/qa/round5v4_database_stage.py`。不要对已在使用的QA库重跑该一次性编排。
`fusion_qa_app` 真实连接成功,现有SELECT/INSERT/UPDATE及序列权限足够全部业务测试;
无superuser/createdb/createrole、无schema CREATE。既有 sys_menu/sys_notice_read/sys_role_menu
三表仍有TRUNCATE/TRIGGER/REFERENCES旧授权:本轮未使用、未扩大,也未擅自收回,建议管理员另行最小化。
## 5. 真实 Kingbase Service / Mapper / 事务与故障恢复
`Round5v4KingbaseIntegrationTest`:11项真实Kingbase测试,使用生产XML、Service、
DataSourceTransactionManager及真实AOP事务代理;账号固定 fusion_qa_app;URL强校验仅允许目标QA库。
外部仅替换 MQTT 传输,未加载真实MQTT或人脸HTTP客户端。恢复扫描前拒绝无关READY工作。
合成 R5V4 数据保留审计,不删除原数据,不重置序列;用例后仅关闭自己的未尝试任务。
覆盖:入住、调宿撤旧建新、退宿意图;意图提交/命令创建前、命令创建/发送前、
DISPATCHING占用提交/传输结果未知三种独立JVM `Runtime.halt`,再创建新Service读取持久化状态恢复;
UNKNOWN转MANUAL_REVIEW且零盲目重发;未尝试的FAIL撤权可补偿;4线程原生行锁仅发送一次;
唯一意图冲突;入住和退宿故障注入时住宿状态与意图同事务回滚;旧撤权ACK不覆盖新授权;MOCK到REAL边界隔离。
数据库提交、Mock发送、双Mock回执均分别断言;不等于设备最终执行。
真实测试发现并修复此前未暴露的事务边界缺陷:桥接 AFTER_COMMIT 调用 REQUIRED 时仍可能
加入已提交但尚未清理的事务,命令滞留READY。现在桥接回调通过 `LockExecutionTransactions.committed`
明确在 REQUIRES_NEW 中生成命令,再由它自己的提交回调占用/发送;业务原子性仍留在原事务内。
修改位于 `TrainIotLockAuthBridgeServiceImpl.dispatchPushSafe/dispatchClearSafe`。
另通过85项选定后端回归、新增1项Boot4缺路由处理回归、12项QA工具测试及前端生产构建,
不把这些替代真实数据库结论。
## 6. 实际后端与 Chrome 验收
当前归档工程新进程,Java21.0.2 / Boot4.1.0,127.0.0.1:18096;生产前端127.0.0.1:18088;
独立Redis127.0.0.1:16389,口令随机、无持久化。不复用实验PID51901/8096或其他工程PID53305/8080。
运行JAR有SHA绑定私有副本;停机核验账本/PID出生时间/命令/cwd并等待退出,无粗暴清端口。
有效参数强制:mqtt.enabled=false、mock=true、real-device-write-enabled=false、whitelist空、
protocol-verified=false、retry-task-enabled=false、QA/OpenAPI关闭、人脸HTTP关闭且Mock、DMS关闭、Quartz不自动运行。
数据库用户名/URL固定QA,秘密只从本机0600配置及私有随机运行配置注入。
Kingbase/Druid实际探针发现URL中的socketTimeout=15被池解析为15ms;启动器移除URL超时并明确池60000ms,
同一探针实际验证60000ms。参考 [Druid 1.2.28 原始实现](https://github.com/alibaba/druid/blob/1.2.28/core/src/main/java/com/alibaba/druid/pool/DruidAbstractDataSource.java)。
首页Kingbase分析SQL的 `::numeric` 改为等价标准CAST,消除Druid统计SQL解析异常;没有关闭SQL安全检查。
负向运行检查还发现被禁用的MQTT/QA端点会被旧全局处理器误报成HTTP200/业务500;
新增对Boot4 NoResourceFoundException的脱敏404处理,并以单元及真实HTTP复验,不注册或开放控制接口。
真实 Google Chrome155.0.8059.40(headed)管理员登录成功,getInfo/getRouters=200;
现有QA验证码配置返回 captchaEnabled=false,未改开关,不声称完成启用状态验证码挑战。
15路由逐页通过组件名、实际表格/图表、HTTP/业务code、JS错误检查:
首页Index/AdminDashboard四张真实ECharts、学员Student、教师Teacher、开班Plan、宿舍Dormitory、动态菜单住宿Accommodation,
以及9个IoT组件Group/Gateway/Device/Face/Auth/AccessLog/Command/MqttLog/ServiceConfig。
最后一轮浏览器请求仅出现127.0.0.1:18088,无外部主机;浏览器设有外发拒绝保护,未发生外部请求阻断事件。
品牌/Logo/侧边栏/动态菜单保留,无404/登录替身页;17:35前最终验收期间后端无SQL或未捕获异常。
后续网络连接异常见本报告顶部,不能将该时点的成功外推为当前在线状态。
浏览器合成 fixture `R5V4463c944c9547465b`,学号后缀S、人员P5463c94:
预分配2043 → 真UI入住 → Mock grant确认 → 真UI调房到B/新住宿2044 → 旧权revoke+新权grant确认
→ 真UI退宿 → revoke确认;两个授权1035/1036最终REVOKE版本2、CONFIRMED、sync4;
四条0011/0012命令均MOCK、CONFIRMED、网关/设备双ACK,住宿两条已退宿;UI与数据库再次一致核对。
离线回执驱动只接受该fixture的当前MOCK命令,危险HTTP回执入口未启用。
住宿履历和指令流水在真实Chrome中查看,不是仅调用API或替身页面。
前端旧页面引用模型不存在的checkInTime/checkOutTime,已改为真实assignedTime/updateTime,
标签为“分配时间/最近状态变更”;无额外DDL,不将updateTime冒充精确入住/退宿专用时间戳。
原生备份、数据库/浏览器运行日志和截图仅保存在受保护Git外/忽略目录,不提交个人资料。
## 7. 证据位置、复现与剩余边界
- 远端专用根:`/opt/railwaysystem-qa-restore-round5v4/`,含原生恢复输出、结构/行指纹、
isolated-results.json、source-migration-results.json、container-safety.json、historical-preservation.json、空间监护日志。
- 本机受保护忽略目录 `.qa-runtime/round5v4/`:同名JSON、真实Kingbase JUnit/子JVM日志、
后端/前端构建、浏览器15页结果和闭环数据验证;JUnit XML在ruoyi-train/target/surefire-reports。
- `output/playwright/`:最终首页、IoT九页(敏感数据行遮罩)、合成住宿履历/退宿/授权/指令证据;均不入Git。
- 具体构建、隔离启停、Kingbase选定测试及浏览器夹具流程见根 `DEMO_RUNBOOK.md`。
软件功能QA已通过,但当前可运行就绪状态受上述网络问题阻塞,更不代表生产发布或真实硬件验收。
仍须老师明确批准单台测试、确认真实协议校验字段/回执语义、
提供现场设备与单台白名单、核对UNKNOWN人工处置流程;门禁保持关闭。
Vue2/CLI4废弃依赖、旧历史素材/外部凭据轮换、既有账号过宽权限及长期商业许可仍须单独治理。
本轮没有远程CI/部署验证,没有数据库服务器断电测试,没有真实IoT执行证据。
+10 -1
View File
@@ -1,6 +1,15 @@
# Round 5V — isolated Kingbase migration and rollback gate # Round 5V — isolated Kingbase migration and rollback gate
## Current Round 5V-3 decision ## Current Round 5V-4 outcome
The separately authorized new offline V9 recovery container actually restored the native backup;
005 rehearsal, repeatability, transactional rollback and fresh-target backup recovery passed.
Only afterwards, the exact approved 16-column/4-index script committed on the named source QA database.
Real Kingbase service/transaction/process-interruption tests and Chrome software acceptance now passed.
See [Round 5V-4 evidence](ROUND5V4_SOFTWARE_QA.md). Do not rerun initialization or overwrite a live database.
The following sections retain the pre-migration plan/observations, not current missing-approval claims.
## Historical Round 5V-3 decision
The user explicitly authorized migration 005 **only** on The user explicitly authorized migration 005 **only** on
`railwaysystem_fusion_qa_20261013`, together with protected backup and isolated `railwaysystem_fusion_qa_20261013`, together with protected backup and isolated
+16
View File
@@ -0,0 +1,16 @@
import com.alibaba.druid.pool.DruidDataSource;
import java.sql.*;
/** Read-only actual Kingbase/Druid initialization probe, secrets only from environment. */
class QaDruidProbe {
public static void main(String[] args)throws Exception {
String url=System.getenv("QA_DB_URL");
if(url==null || !url.matches("jdbc:kingbase8://192\\.168\\.139\\.218:4321/railwaysystem_fusion_qa_20261013(?:\\?.*)?"))throw new SecurityException();
try(DruidDataSource ds=new DruidDataSource()) {
ds.setDriverClassName("com.kingbase8.Driver");ds.setUrl(url);ds.setUsername("fusion_qa_app");ds.setPassword(System.getenv("QA_DB_PASSWORD"));
ds.setInitialSize(1);ds.setMaxActive(2);ds.setMinIdle(1);ds.setConnectTimeout(30000);ds.setSocketTimeout(60000);
if(args.length>0)ds.setConnectionProperties(args[0]);
ds.setValidationQuery("select 1");ds.setConnectionInitSqls(java.util.List.of("set search_path to iotlocks,public,pg_catalog,sys_catalog"));
ds.init();try(Connection c=ds.getConnection()){c.setReadOnly(true);c.setAutoCommit(false);try(Statement s=c.createStatement();ResultSet r=s.executeQuery("select current_database(),current_user")){r.next();System.out.println(r.getString(1)+" | "+r.getString(2)+" | network_timeout_ms="+c.getNetworkTimeout());}c.rollback();}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Explicit synthetic fixture/Mock ACK driver; no QA HTTP endpoints or real device transport."""
import argparse
import os
from pathlib import Path
import subprocess
import xml.etree.ElementTree as ET
import qa
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('action',choices=['seed','ack','state','verify'])
parser.add_argument('--approve-synthetic-qa-writes',action='store_true',required=True)
parser.add_argument('--config',type=Path,default=Path.home()/'.railwaysystem-qa.env')
args=parser.parse_args()
env=qa.environment(qa.load_config(args.config));qa.private_directory()
directory=qa.RUNTIME/'round5v4';directory.mkdir(mode=0o700,exist_ok=True)
env.update(ROUND5V4_KINGBASE_TESTS='1',ROUND5V4_LOG_DIR=str(directory))
report=qa.ROOT/'RuoYi-Vue/ruoyi-train/target/surefire-reports/TEST-com.ruoyi.train.integration.lock.Round5v4KingbaseIntegrationTest.xml'
tree=ET.parse(report);cp=tree.find("./properties/property[@name='surefire.test.class.path']").attrib['value']
command=['java','-cp',cp,'com.ruoyi.train.integration.lock.Round5v4BrowserFixture',args.action]
result=subprocess.run(command,cwd=qa.ROOT,env=env,text=True,capture_output=True,timeout=90)
with open(directory/('browser-fixture-'+args.action+'.log'),'w',opener=lambda p,f:os.open(p,f,0o600)) as log:
log.write(result.stdout+result.stderr)
for line in result.stdout.splitlines():
if line.startswith(('BROWSER_FIXTURE_CREATED','FIXTURE_AUTH','FIXTURE_COMMAND','FIXTURE_BROWSER_DATABASE')):print(line)
print('FIXTURE_DRIVER_EXIT='+str(result.returncode))
return result.returncode
if __name__=='__main__':
raise SystemExit(main())
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Log into the task's Chrome QA session without credentials in CLI arguments, code, or logs.
The protected configuration is read through File API on a separate about:blank page,
never uploaded to the application; only two login values are filled, solely in memory.
Requires the Playwright CLI skill and an already-open isolated round5v4 Chrome session.
"""
import argparse
import json
from pathlib import Path
import subprocess
import qa
CODE = r'''async (page) => {
if(!USE_CURRENT_PAGE){await page.context().clearCookies();await page.goto('http://127.0.0.1:18088/login');}
await page.getByRole('textbox',{name:'账号',exact:true}).waitFor();
await page.waitForLoadState('networkidle');
page.__qaEvidence={responses:[],errors:[]};
if(page.__qaErrorListener)page.removeListener('pageerror',page.__qaErrorListener);
if(page.__qaResponseListener)page.removeListener('response',page.__qaResponseListener);
page.__qaErrorListener=error=>page.__qaEvidence.errors.push(error.message);
page.__qaResponseListener=async response=>{const url=response.url();if(!url.includes('/prod-api/'))return;let code=null;try{code=(await response.json()).code;}catch{}page.__qaEvidence.responses.push({path:url.split('/prod-api')[1],status:response.status(),code});};
page.on('pageerror',page.__qaErrorListener);page.on('response',page.__qaResponseListener);
const local=await page.context().newPage();let credentials={};
try {
await local.goto('about:blank');await local.setContent('<input id="protected-config" type="file">');
await local.locator('#protected-config').setInputFiles(CONFIG_PATH);
credentials=await local.locator('#protected-config').evaluate(async input=>{
const text=await input.files[0].text();const vals={};
for(const line of text.split('\n')){const m=line.match(/^(?:export )?(QA_ADMIN_USERNAME|QA_ADMIN_PASSWORD)=(.*)$/);if(m){let v=m[2].trim();if((v.startsWith("'")&&v.endsWith("'"))||(v.startsWith('"')&&v.endsWith('"')))v=v.slice(1,-1);vals[m[1]]=v;}}
return vals;
});
}finally{await local.close();}
if(!credentials.QA_ADMIN_USERNAME||!credentials.QA_ADMIN_PASSWORD)throw new Error('Protected QA login configuration missing');
if(await page.getByRole('textbox',{name:'验证码',exact:true}).isVisible()){
if(!CAPTCHA_ANSWER)throw new Error('Captcha enabled: complete visual captcha manually; do not disable it');
await page.getByRole('textbox',{name:'验证码',exact:true}).fill(CAPTCHA_ANSWER);
}
await page.getByRole('textbox',{name:'账号',exact:true}).fill(credentials.QA_ADMIN_USERNAME);
await page.getByRole('textbox',{name:'密码',exact:true}).fill(credentials.QA_ADMIN_PASSWORD);credentials={};
await page.getByRole('button',{name:'登 录'}).click();await page.waitForURL(url=>url.origin==='http://127.0.0.1:18088'&&!['/login','/404','/401'].includes(url.pathname),{timeout:20000});await page.waitForLoadState('networkidle');
return {url:page.url(),title:await page.title(),api:page.__qaEvidence.responses,errors:page.__qaEvidence.errors};
}'''
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--config',type=Path,default=Path.home()/'.railwaysystem-qa.env')
parser.add_argument('--cli',type=Path,default=Path.home()/'.codex/skills/playwright/scripts/playwright_cli.sh')
parser.add_argument('--captcha',default='',help='Answer from the actual displayed captcha, never disable validation')
parser.add_argument('--use-current-page',action='store_true')
args=parser.parse_args();qa.load_config(args.config)
code=CODE.replace('CONFIG_PATH',json.dumps(str(args.config.expanduser().resolve()))).replace('CAPTCHA_ANSWER',json.dumps(args.captcha)).replace('USE_CURRENT_PAGE',str(args.use_current_page).lower())
result=subprocess.run([str(args.cli),'-s=round5v4','run-code',code],text=True,capture_output=True,timeout=45)
# The emitted code contains a path, never credential values. Do not snapshot a filled login form.
print(result.stdout)
return result.returncode
if __name__=='__main__':raise SystemExit(main())
+41
View File
@@ -0,0 +1,41 @@
async (page) => {
if (!page.__qaEvidence) throw new Error('Run the protected Chrome login driver first');
const blockedHosts=new Set(),requestHosts=new Set();
// Real pages/API only; never substitute responses or allow a stored photo URL to contact a device service.
await page.route('**/*',async route=>{
const url=route.request().url(),host=url.match(/^https?:\/\/([^/]+)/)?.[1];
if(host)requestHosts.add(host);
if(host&&host!=='127.0.0.1:18088'){blockedHosts.add(host);await route.abort('blockedbyclient');}
else await route.continue();
});
const routes = [
['/index', null], ['/person/student', 'Student'], ['/person/teacher', null],
['/coursemgr/plan', null], ['/equip/dormitory', null], ['/equip/accommodation', 'Accommodation'],
...Object.entries({group:'Group',gateway:'Gateway',device:'Device',face:'Face',auth:'Auth',
accesslog:'AccessLog',command:'Command',mqttlog:'MqttLog',config:'ServiceConfig'}).map(([r,c])=>['/iot/'+r,c])
];
const results=[];
for (const [path,expected] of routes) {
const start=page.__qaEvidence.responses.length;
await page.goto('http://127.0.0.1:18088'+path);
await page.waitForLoadState('networkidle');
const components=await page.locator('.app-main').evaluate(e=>{
function names(v){return v?[v.$options.name||v.$options._componentTag||'',...v.$children.flatMap(names)]:[];}
return names(e.__vue__).filter(n=>n&&!n.startsWith('El'));
});
const api=[...new Map(page.__qaEvidence.responses.slice(start).map(r=>[JSON.stringify(r),r])).values()];
const tables=await page.locator('.app-main .el-table').count();
const pass=page.url()==='http://127.0.0.1:18088'+path&&
(await page.title()).includes('SYFJGYD')&&(!expected||components.includes(expected))&&
!components.some(n=>/404|401|ErrorPage/i.test(n))&&api.length>0&&
api.every(r=>r.status===200&&(r.code==null||r.code===200))&&page.__qaEvidence.errors.length===0&&blockedHosts.size===0&&
(path==='/index'?await page.locator('canvas').count()===4:tables>0);
results.push({path,pass,components:[...new Set(components)],tables,api});
if(!pass)throw new Error('Actual component/API regression failed: '+JSON.stringify(results.at(-1)));
if(path==='/index')await page.screenshot({path:'output/playwright/round5v4-home-final.png',fullPage:true});
if(path.startsWith('/iot/'))await page.screenshot({path:'output/playwright/round5v4-'+path.split('/').at(-1)+'-headers.png',
mask:[page.locator('.el-table__body-wrapper'),page.locator('.avatar-container')]});
}
return {pass:true,chrome:page.context().browser().version(),results,errors:page.__qaEvidence.errors,
requestHosts:[...requestHosts],blockedExternalHosts:[...blockedHosts]};
}
+35
View File
@@ -0,0 +1,35 @@
\set ON_ERROR_STOP on
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SET LOCAL statement_timeout='30s';
SET LOCAL lock_timeout='5s';
SET LOCAL timezone='UTC';
SET LOCAL search_path=pg_catalog,sys_catalog,public,iotlocks;
SELECT current_database() IN ('railwaysystem_fusion_qa_20261013','railwaysystem_fusion_restore_round5v4','railwaysystem_fusion_recovery_round5v4') AS allowed_database
\gset
\if :allowed_database
\else
\echo REFUSING_UNAPPROVED_DATABASE
\quit 3
\endif
SELECT 'IDENTITY',current_database(),current_user,version();
SELECT 'SCHEMA',nspname FROM pg_namespace WHERE nspname IN ('public','iotlocks') ORDER BY nspname;
SELECT 'TABLE',schemaname,tablename FROM pg_tables WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,tablename;
SELECT format('SELECT %L,count(*),md5(coalesce(string_agg(row_to_json(t)::text,%L ORDER BY row_to_json(t)::text),%L)) FROM %I.%I t;',concat('DATA:',schemaname,'.',tablename),'','',schemaname,tablename)
FROM pg_tables WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,tablename
\gexec
SELECT 'LEGACY_DATA:lock_auth',count(*),md5(coalesce(string_agg((to_jsonb(t)-ARRAY['execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at'])::text,'' ORDER BY auth_id),'')) FROM iotlocks.lock_auth t;
SELECT 'LEGACY_DATA:lock_command',count(*),md5(coalesce(string_agg((to_jsonb(t)-ARRAY['execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at','device_sn','attempt_started_at'])::text,'' ORDER BY command_id),'')) FROM iotlocks.lock_command t;
SELECT 'COLUMN',table_schema,table_name,column_name,ordinal_position,data_type,is_nullable,coalesce(column_default,''),coalesce(character_maximum_length::text,'')
FROM information_schema.columns WHERE table_schema IN ('public','iotlocks') ORDER BY table_schema,table_name,ordinal_position;
SELECT 'INDEX',schemaname,tablename,indexname,indexdef FROM pg_indexes WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,tablename,indexname;
SELECT 'CONSTRAINT',n.nspname,t.relname,c.conname,c.contype,pg_get_constraintdef(c.oid)
FROM pg_constraint c JOIN pg_class t ON c.conrelid=t.oid JOIN pg_namespace n ON n.oid=t.relnamespace
WHERE n.nspname IN ('public','iotlocks') ORDER BY n.nspname,t.relname,c.conname;
SELECT 'TRIGGER',n.nspname,t.relname,g.tgname,pg_get_triggerdef(g.oid)
FROM pg_trigger g JOIN pg_class t ON t.oid=g.tgrelid JOIN pg_namespace n ON n.oid=t.relnamespace
WHERE n.nspname IN ('public','iotlocks') AND NOT g.tgisinternal ORDER BY n.nspname,t.relname,g.tgname;
SELECT 'SEQUENCE',schemaname,sequencename,start_value,min_value,max_value,increment_by,cycle,cache_size,last_value
FROM pg_sequences WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,sequencename;
SELECT 'EXTENSION',extname,extversion FROM pg_extension ORDER BY extname;
SELECT 'OWNER',schemaname,tablename,tableowner FROM pg_tables WHERE schemaname IN ('public','iotlocks') ORDER BY schemaname,tablename;
ROLLBACK;
+40 -3
View File
@@ -9,10 +9,12 @@ from pathlib import Path
import secrets import secrets
import shlex import shlex
import signal import signal
import shutil
import socket import socket
import stat import stat
import subprocess import subprocess
import sys import sys
import time
import urllib.parse import urllib.parse
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@@ -95,6 +97,15 @@ def available(port):
probe.bind(('127.0.0.1', port)) probe.bind(('127.0.0.1', port))
def druid_url(url):
# Druid 1.2.28 also parses URL timeout values as pool milliseconds, whereas
# Kingbase JDBC uses seconds. Keep pool timeout configuration in one place.
parsed = urllib.parse.urlsplit(url.removeprefix('jdbc:'))
query = [(k,v) for k,v in urllib.parse.parse_qsl(parsed.query)
if k.lower() not in ('connecttimeout', 'sockettimeout')]
return 'jdbc:' + urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query)))
def launch(name, command, env): def launch(name, command, env):
private_directory() private_directory()
ledger = RUNTIME / (name + '.json') ledger = RUNTIME / (name + '.json')
@@ -114,6 +125,15 @@ def start(config, env, backend_port, redis_port):
available(backend_port) available(backend_port)
available(redis_port) available(redis_port)
private_directory() private_directory()
built_jar = ROOT / 'RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar'
if not built_jar.is_file():
raise ValueError('Build backend jar first')
digest = hashlib.sha256(built_jar.read_bytes()).hexdigest()
runtime_jar = RUNTIME / ('ruoyi-admin-' + digest[:16] + '.jar')
if not runtime_jar.exists():
shutil.copyfile(built_jar, runtime_jar)
if hashlib.sha256(runtime_jar.read_bytes()).hexdigest() != digest:
raise ValueError('Runtime artifact hash mismatch')
secret_path = RUNTIME / 'runtime-secrets.env' secret_path = RUNTIME / 'runtime-secrets.env'
if not secret_path.exists(): if not secret_path.exists():
with open(secret_path, 'x', opener=lambda p, f: os.open(p, f, 0o600)) as f: with open(secret_path, 'x', opener=lambda p, f: os.open(p, f, 0o600)) as f:
@@ -125,17 +145,23 @@ def start(config, env, backend_port, redis_port):
if not config.get(key): if not config.get(key):
raise ValueError('Missing required protected identity configuration') raise ValueError('Missing required protected identity configuration')
env[key] = config[key] env[key] = config[key]
env.update(SPRING_DATASOURCE_DRUID_MASTER_URL=env['QA_DB_URL'], runtime_url = druid_url(env['QA_DB_URL'])
env.update(SPRING_DATASOURCE_DRUID_MASTER_URL=runtime_url,
SPRING_DATASOURCE_DRUID_MASTER_USERNAME='fusion_qa_app', SPRING_DATASOURCE_DRUID_MASTER_USERNAME='fusion_qa_app',
SPRING_DATASOURCE_DRUID_MASTER_PASSWORD=env['QA_DB_PASSWORD'], SPRING_DATASOURCE_DRUID_MASTER_PASSWORD=env['QA_DB_PASSWORD'],
SPRING_DATA_REDIS_PASSWORD=env['QA_REDIS_PASSWORD']) SPRING_DATA_REDIS_PASSWORD=env['QA_REDIS_PASSWORD'])
redis_config = RUNTIME / 'redis.conf' redis_config = RUNTIME / 'redis.conf'
with open(redis_config, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as f: with open(redis_config, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as f:
f.write('bind 127.0.0.1\nprotected-mode yes\nport ' + str(redis_port) + '\nsave ""\nappendonly no\nrequirepass ' + env['QA_REDIS_PASSWORD'] + '\n') f.write('bind 127.0.0.1\nprotected-mode yes\nport ' + str(redis_port) + '\nsave ""\nappendonly no\nrequirepass ' + env['QA_REDIS_PASSWORD'] + '\n')
command = ['java', '-jar', str(ROOT / 'RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar'), command = ['java', '-jar', str(runtime_jar),
'--spring.profiles.active=druid', '--spring.profiles.active=druid',
'--spring.datasource.druid.master.url=' + env['QA_DB_URL'], '--spring.datasource.druid.master.url=' + runtime_url,
'--spring.datasource.druid.master.username=fusion_qa_app', '--spring.datasource.druid.master.username=fusion_qa_app',
'--spring.datasource.druid.master.connect-timeout=30000',
'--spring.datasource.druid.master.socket-timeout=60000',
'--spring.datasource.druid.master.initial-size=2',
'--spring.datasource.druid.master.min-idle=2',
'--spring.datasource.druid.master.max-active=8',
'--server.address=127.0.0.1', '--server.port=' + str(backend_port), '--server.address=127.0.0.1', '--server.port=' + str(backend_port),
'--spring.data.redis.host=127.0.0.1', '--spring.data.redis.port=' + str(redis_port), '--spring.data.redis.host=127.0.0.1', '--spring.data.redis.port=' + str(redis_port),
'--spring.data.redis.database=0', '--spring.quartz.auto-startup=false', '--spring.data.redis.database=0', '--spring.quartz.auto-startup=false',
@@ -153,6 +179,16 @@ def start(config, env, backend_port, redis_port):
launch('backend', command, env) launch('backend', command, env)
def wait_for_exit(pid):
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
state = subprocess.run(['ps', '-p', str(pid), '-o', 'stat='], text=True, capture_output=True).stdout.strip()
if not state or state.startswith('Z'):
return
time.sleep(0.1)
raise ValueError('Task-owned process did not stop; ledger retained, no forced kill')
def stop(): def stop():
private_directory() private_directory()
for name in ['frontend', 'backend', 'redis']: for name in ['frontend', 'backend', 'redis']:
@@ -174,6 +210,7 @@ def stop():
if not identity or ('n' + str(ROOT)) not in cwd.splitlines() or (item.get('started') and item['started'] != started): if not identity or ('n' + str(ROOT)) not in cwd.splitlines() or (item.get('started') and item['started'] != started):
raise ValueError('PID identity mismatch; refusing to signal ' + name) raise ValueError('PID identity mismatch; refusing to signal ' + name)
os.kill(item['pid'], signal.SIGTERM) os.kill(item['pid'], signal.SIGTERM)
wait_for_exit(item['pid'])
path.unlink() path.unlink()
print('Stopped task-owned ' + name) print('Stopped task-owned ' + name)
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Run only on the authorized host. Isolated rehearsal first; source phase is separately gated."""
import argparse
from collections import Counter
import hashlib
import json
from pathlib import Path
import re
import select
import subprocess
ROOT = Path('/opt/railwaysystem-qa-restore-round5v4')
RESTORE = 'railwaysystem-qa-restore-round5v4'
RESTORE_DB = 'railwaysystem_fusion_restore_round5v4'
RECOVERY_DB = 'railwaysystem_fusion_recovery_round5v4'
SOURCE_DB = 'railwaysystem_fusion_qa_20261013'
BIN = '/home/kingbase/install/kingbase/bin/'
MIGRATION_SHA = '90648b6ac67470f9a14f0795c4479dfb4811d4907e41a13ee5eeada16582b87f'
DUMP = Path('/opt/railway-fusion-qa-backup-20261014.YLjmJ0/fusion-qa-pre005-v2.dump')
DUMP_SHA = 'caa62bc985bd433b5d428b36e34d684b557c82576b69e5e5d9d348edfdf316b4'
RESULTS = {}
def argv(container, database):
assert (container == 'kingbase' and database == SOURCE_DB) or (container == RESTORE and database in [RESTORE_DB,RECOVERY_DB,'kingbase'])
role = 'kingbase' if container == 'kingbase' else 'round5v4_admin'
return ['docker','exec','-i',container,BIN+'ksql','-X','-w','-p','54321','-U',role,'-d',database,'-Atq']
def sql(container,database,body,label,expected=None):
result = subprocess.run(argv(container,database),input='\\set ON_ERROR_STOP on\n\\set VERBOSITY sqlstate\n'+body+'\n',text=True,capture_output=True,timeout=90)
(ROOT/(label+'.stdout')).write_text(result.stdout)
(ROOT/(label+'.stderr')).write_text(result.stderr)
if expected:
assert result.returncode != 0 and expected in result.stderr,(label,result.returncode,result.stderr[:100])
else:
assert result.returncode == 0,(label,result.returncode,result.stderr[:100])
return result.stdout.strip()
def manifest(container,database,label):
raw=sql(container,database,(ROOT/'manifest.sql').read_text(),label)
lines=raw.splitlines()
assert sum(l.startswith('TABLE|') for l in lines)==63
assert sum(l.startswith('DATA:') for l in lines)==63
return lines
def canonical(lines):
out=[]
for line in lines:
if line.startswith(('IDENTITY|','OWNER|')):continue
if line.startswith('CONSTRAINT|public|train_external_operation|chk_external_operation_status|'):
# Only this actual native dump/reparse cast difference is admitted, after SQL truth-table verification.
states=re.findall(r"'([^']*)'::varchar",line)
assert states==['CREATED','DISPATCHING','REQUESTED','ACCEPTED','PENDING','RETRY_WAIT','AMBIGUOUS','SUCCESS','FAILED','TIMEOUT']
line='CONSTRAINT_EQUIVALENT_STATUS_SET|'+','.join(states)
out.append(line)
return out
def verify_constraint_equivalence(source,restored):
prefix='CONSTRAINT|public|train_external_operation|chk_external_operation_status|'
a=next(l for l in source if l.startswith(prefix)).split('|',5)[5]
b=next(l for l in restored if l.startswith(prefix)).split('|',5)[5]
assert a.startswith('CHECK (') and b.startswith('CHECK (')
values=re.findall(r"'([^']*)'::varchar",a)+['created','INVALID','',' ']
rows=','.join("('%s'::varchar)"%v.replace("'","''") for v in values)+',(NULL::varchar)'
result=sql(RESTORE,RESTORE_DB,'BEGIN READ ONLY; SELECT count(*) FROM (VALUES '+rows+') AS cases(operation_status) WHERE ('+a[7:-1]+') IS DISTINCT FROM ('+b[7:-1]+'); ROLLBACK;','constraint-equivalence')
assert result=='0',result
RESULTS['constraint_equivalence_cases']=len(values)+1
def migration():
p=ROOT/'005.sql';raw=p.read_bytes();assert hashlib.sha256(raw).hexdigest()==MIGRATION_SHA
statements=[s.strip() for s in re.sub(r'--[^\n]*','',raw.decode()).split(';') if s.strip()]
assert len(statements)==20
assert all(re.match(r'^(ALTER TABLE iotlocks\.(lock_auth|lock_command) ADD COLUMN IF NOT EXISTS |CREATE (UNIQUE )?INDEX IF NOT EXISTS \w+ ON iotlocks\.(lock_auth|lock_command)\()',s) for s in statements)
return raw.decode()
def begin():
return "BEGIN; SET LOCAL lock_timeout='3s'; SET LOCAL statement_timeout='30s';\n"
def validate_post(container,database,label):
result=sql(container,database,"""BEGIN READ ONLY;
SELECT count(*) FROM information_schema.columns WHERE table_schema='iotlocks' AND
((table_name='lock_auth' AND column_name IN ('execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at')) OR
(table_name='lock_command' AND column_name IN ('execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at','device_sn','attempt_started_at')));
SELECT count(*) FROM pg_indexes WHERE schemaname='iotlocks' AND indexname IN ('uq_round5s_command_intent','uq_round5s_auth_intent','ix_round5s_command_recovery','ix_round5s_auth_recovery');
SELECT count(*) FROM iotlocks.lock_auth WHERE execution_mode<>'LEGACY' OR intent_version<>0 OR execution_state<>'QUARANTINED' OR intent_key IS NOT NULL OR environment_id IS NOT NULL OR intent_action IS NOT NULL;
SELECT count(*) FROM iotlocks.lock_command WHERE execution_mode<>'LEGACY' OR intent_version<>0 OR execution_state<>'QUARANTINED' OR intent_key IS NOT NULL OR environment_id IS NOT NULL OR intent_action IS NOT NULL OR attempt_started_at IS NOT NULL OR device_sn IS NOT NULL;
SELECT count(*) FROM iotlocks.lock_auth; SELECT count(*) FROM iotlocks.lock_command; ROLLBACK;""",label)
assert result.splitlines()==['16','4','0','0','10','15'],result
def isolated():
assert hashlib.sha256(DUMP.read_bytes()).hexdigest()==DUMP_SHA
before=manifest(RESTORE,RESTORE_DB,'restored-baseline')
source=manifest('kingbase',SOURCE_DB,'source-baseline')
verify_constraint_equivalence(source,before)
assert canonical(source)==canonical(before),'Source and restore contents differ'
assert all(l.split('|')[-1]=='round5v4_admin' for l in before if l.startswith('OWNER|'))
RESULTS['native_restore_integrity']='PASS: 63 tables/data fingerprints, schema/columns/indexes/constraints/triggers/sequences/extensions'
body=migration()
sql(RESTORE,RESTORE_DB,begin()+body+'\nSELECT round5v4_deliberate_missing_function();','rollback-error','42883')
assert canonical(manifest(RESTORE,RESTORE_DB,'after-error'))==canonical(before)
RESULTS['ddl_error_transaction_rollback']='PASS'
# EOF disconnects an uncommitted migration connection; native server must roll back.
sql(RESTORE,RESTORE_DB,begin()+body,'rollback-disconnect')
assert canonical(manifest(RESTORE,RESTORE_DB,'after-disconnect'))==canonical(before)
RESULTS['ddl_client_disconnect_rollback']='PASS'
holder=subprocess.Popen(argv(RESTORE,RESTORE_DB),stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
try:
holder.stdin.write("BEGIN; LOCK TABLE iotlocks.lock_auth IN ACCESS SHARE MODE; SELECT 'HOLDER_READY';\n");holder.stdin.flush()
assert select.select([holder.stdout],[],[],10)[0] and holder.stdout.readline().strip()=='HOLDER_READY'
sql(RESTORE,RESTORE_DB,"BEGIN; SET LOCAL lock_timeout='500ms'; SET LOCAL statement_timeout='3s';\n"+body,'lock-timeout','55P03')
finally:
holder.stdin.write('ROLLBACK;\n\\q\n');holder.stdin.flush();holder.wait(timeout=10)
assert canonical(manifest(RESTORE,RESTORE_DB,'after-lock-timeout'))==canonical(before)
RESULTS['ddl_lock_timeout_rollback']='PASS'
sql(RESTORE,RESTORE_DB,begin()+body+'\nCOMMIT;','migration-commit')
validate_post(RESTORE,RESTORE_DB,'post-migration-check')
post=manifest(RESTORE,RESTORE_DB,'isolated-post')
assert [l for l in before if l.startswith('LEGACY_DATA:')]==[l for l in post if l.startswith('LEGACY_DATA:')]
sql(RESTORE,RESTORE_DB,begin()+body+'\nCOMMIT;','migration-repeat')
assert canonical(manifest(RESTORE,RESTORE_DB,'after-repeat'))==canonical(post)
RESULTS['migration_commit_repeat_legacy_isolation']='PASS'
sql(RESTORE,RESTORE_DB,"""BEGIN;
INSERT INTO iotlocks.lock_command(command_id,command_no,command_type,target_type,intent_key) VALUES(-500000000001,'R5V4_UNIQUE_A','0012','1','round5v4-unique-intent');
INSERT INTO iotlocks.lock_command(command_id,command_no,command_type,target_type,intent_key) VALUES(-500000000002,'R5V4_UNIQUE_B','0012','1','round5v4-unique-intent');
COMMIT;""",'unique-intent-rejection','23505')
assert canonical(manifest(RESTORE,RESTORE_DB,'after-unique-rollback'))==canonical(post)
RESULTS['unique_intent_and_rollback']='PASS'
# Fresh target only: never DROP/overwrite either the source QA or the first restored database.
sql(RESTORE,'kingbase','CREATE DATABASE '+RECOVERY_DB+' OWNER round5v4_admin TEMPLATE template0;', 'create-recovery-db')
with DUMP.open('rb') as stream:
r=subprocess.run(['docker','exec','-i',RESTORE,BIN+'sys_restore','-w','-p','54321','-U','round5v4_admin','-d',RECOVERY_DB,'-O','-x','--no-tablespaces','-1','-e'],stdin=stream,capture_output=True,timeout=90)
(ROOT/'recovery-restore.stderr').write_bytes(r.stderr)
assert r.returncode==0 and not r.stderr
assert canonical(manifest(RESTORE,RECOVERY_DB,'fresh-recovered'))==canonical(before)
RESULTS['native_backup_failure_recovery']='PASS'
assert canonical(manifest('kingbase',SOURCE_DB,'source-still-unmodified'))==canonical(source)
RESULTS['source_unchanged_during_rehearsal']='PASS'
(ROOT/'isolated-results.json').write_text(json.dumps(RESULTS,indent=2))
print(json.dumps(RESULTS,indent=2))
def source_migrate():
proof=json.loads((ROOT/'isolated-results.json').read_text())
required=['native_restore_integrity','ddl_error_transaction_rollback','ddl_client_disconnect_rollback',
'ddl_lock_timeout_rollback','migration_commit_repeat_legacy_isolation',
'unique_intent_and_rollback','native_backup_failure_recovery','source_unchanged_during_rehearsal']
assert all(proof.get(k,'').startswith('PASS') for k in required)
assert hashlib.sha256(DUMP.read_bytes()).hexdigest()==DUMP_SHA
baseline=(ROOT/'source-baseline.stdout').read_text().strip().splitlines()
current=manifest('kingbase',SOURCE_DB,'source-immediately-before')
assert canonical(current)==canonical(baseline),'Source changed after the proven backup'
sessions=sql('kingbase',SOURCE_DB,'BEGIN READ ONLY; SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND pid<>pg_backend_pid(); ROLLBACK;','source-other-sessions')
assert sessions=='0','Other source QA sessions exist; coordinate writers first'
# Recheck affected data under the actual migration locks, not only before acquiring them.
guards=[]
for table,extra in [('lock_auth',[]),('lock_command',['device_sn','attempt_started_at'])]:
prefix='LEGACY_DATA:'+table+'|'
expected=next(l for l in current if l.startswith(prefix)).split('|')[2]
excluded=['execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at']+extra
array='ARRAY['+','.join("'%s'"%s for s in excluded)+']'
key='auth_id' if table=='lock_auth' else 'command_id'
expression="(SELECT md5(coalesce(string_agg((to_jsonb(t)-"+array+")::text,'' ORDER BY "+key+"),'')) FROM iotlocks."+table+" t)"
guards.append("IF "+expression+" IS DISTINCT FROM '"+expected+"' THEN RAISE EXCEPTION 'QA data changed; migration refused'; END IF;")
guard='DO $guard$ BEGIN '+''.join(guards)+' END $guard$;\n'
body=begin()+"LOCK TABLE iotlocks.lock_auth,iotlocks.lock_command IN ACCESS EXCLUSIVE MODE;\n"+guard+migration()+'\nCOMMIT;'
sql('kingbase',SOURCE_DB,body,'source-migration-commit')
validate_post('kingbase',SOURCE_DB,'source-post-migration-check')
post=manifest('kingbase',SOURCE_DB,'source-after-migration')
isolated_post=(ROOT/'isolated-post.stdout').read_text().strip().splitlines()
assert canonical(post)==canonical(isolated_post),'Unexpected source schema/data difference'
result={'source_qa_migration':'PASS','schema_and_data_match_rehearsal':'PASS',
'legacy_auth_count':10,'legacy_command_count':15,'added_columns':16,'added_indexes':4,
'privileges_expanded':False,'other_databases_modified':False}
(ROOT/'source-migration-results.json').write_text(json.dumps(result,indent=2));print(json.dumps(result,indent=2))
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--source-only',action='store_true');args=parser.parse_args()
source_migrate() if args.source_only else isolated()
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Bounded watchdog for ONLY the separately authorized Round5V4 restore container."""
import argparse
import json
import os
from pathlib import Path
import shutil
import subprocess
import time
NAME = 'railwaysystem-qa-restore-round5v4'
IMAGE = 'sha256:540fa4d0c8f365c8d363a801404e93f8401c7bc5035ecd7b621f0dba862f2515'
ROOT = Path('/opt/railwaysystem-qa-restore-round5v4')
LIMIT = 4 * 1024 ** 3 # stop below the approved 5GiB budget; bind has NO hard quota
RESERVE = 100 * 1024 ** 3
def command(*args):
return subprocess.check_output(args, text=True, timeout=20).strip()
def inspect(container_id):
item = json.loads(command('docker', 'inspect', container_id))[0]
if item['Id'] != container_id or item['Name'] != '/' + NAME or item['Image'] != IMAGE:
raise RuntimeError('Container identity mismatch; no unrelated process will be signaled')
if item['Config'].get('Labels', {}).get('railway.task') != 'round5v4':
raise RuntimeError('Missing task ownership label')
mounts = [m for m in item['Mounts'] if m['Destination'] == '/home/kingbase/userdata']
if len(mounts) != 1 or Path(mounts[0]['Source']).resolve() != ROOT / 'data':
raise RuntimeError('Unexpected data mount')
return item
def used_bytes(path):
return int(command('du', '-sk', str(path)).split()[0]) * 1024
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--once', action='store_true')
args = parser.parse_args()
if ROOT.is_symlink() or ROOT.resolve() != ROOT or (ROOT / 'data').is_symlink():
raise RuntimeError('Task paths must not be symlinks')
container_id = (ROOT / 'container.id').read_text().strip()
ever_running = False
while True:
item = inspect(container_id)
running = item['State']['Running']
ever_running = ever_running or running
upper = Path(item['GraphDriver']['Data']['UpperDir']).resolve()
if not str(upper).startswith('/var/lib/docker/overlay2/') or upper.name != 'diff':
raise RuntimeError('Unexpected writable-layer location')
used = used_bytes(ROOT) + used_bytes(upper)
log_path = item.get('LogPath')
used += Path(log_path).stat().st_size if log_path and Path(log_path).exists() else 0
free = shutil.disk_usage(ROOT).free
print(json.dumps({'time': time.time(), 'running': running, 'used_bytes': used,
'stop_threshold_bytes': LIMIT, 'host_free_bytes': free}), flush=True)
if used >= LIMIT or free < RESERVE:
# Revalidate immutable task ownership immediately before stopping.
inspect(container_id)
subprocess.run(['docker', 'stop', '--time', '15', container_id], check=True, timeout=25,
stdout=subprocess.DEVNULL)
print('STOPPED_TASK_CONTAINER_STORAGE_THRESHOLD', flush=True)
return
if args.once or (ever_running and not running):
return
time.sleep(2)
if __name__ == '__main__':
main()
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
"""Opt-in synthetic writes to the migrated, named Kingbase QA database only. No DDL or network devices."""
import argparse
import os
from pathlib import Path
import subprocess
import qa
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--approve-synthetic-qa-writes', action='store_true', required=True)
parser.add_argument('--config', type=Path, default=Path.home()/'.railwaysystem-qa.env')
args = parser.parse_args()
env = qa.environment(qa.load_config(args.config))
if qa.inspect(env, ['--require-migrated']):
raise SystemExit('QA schema preflight failed')
qa.private_directory()
directory = qa.RUNTIME/'round5v4'
directory.mkdir(mode=0o700, exist_ok=True)
env.update(ROUND5V4_KINGBASE_TESTS='1', ROUND5V4_LOG_DIR=str(directory))
command = ['mvn', '-o', '-q', '-f', 'RuoYi-Vue/pom.xml', '-pl', 'ruoyi-train', '-am',
'-Dtest=Round5v4KingbaseIntegrationTest', '-Dsurefire.failIfNoSpecifiedTests=false', 'test']
with open(directory/'kingbase-tests.log','w',opener=lambda p,f:os.open(p,f,0o600)) as log:
result = subprocess.run(command, cwd=qa.ROOT, env=env, stdout=log, stderr=subprocess.STDOUT)
print('KINGBASE_TEST_EXIT='+str(result.returncode))
print('PRIVATE_LOG='+str(directory/'kingbase-tests.log'))
return result.returncode
if __name__=='__main__':
raise SystemExit(main())
+5 -1
View File
@@ -34,6 +34,10 @@ class QaToolsTest(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
qa.environment({}) qa.environment({})
def test_druid_timeout_units_cannot_be_overridden_by_jdbc_url_seconds(self):
url = 'jdbc:kingbase8://192.168.139.218:4321/' + qa.DATABASE + '?connectTimeout=5&socketTimeout=15&characterEncoding=utf8'
self.assertEqual('jdbc:kingbase8://192.168.139.218:4321/' + qa.DATABASE + '?characterEncoding=utf8', qa.druid_url(url))
def test_unsafe_inherited_overrides_removed(self): def test_unsafe_inherited_overrides_removed(self):
with patch.dict(os.environ, {'SPRING_APPLICATION_JSON': '{"danger":true}', 'IOT_MQTT_ENABLED': 'true', 'JAVA_TOOL_OPTIONS': '-Dunsafe=true'}): with patch.dict(os.environ, {'SPRING_APPLICATION_JSON': '{"danger":true}', 'IOT_MQTT_ENABLED': 'true', 'JAVA_TOOL_OPTIONS': '-Dunsafe=true'}):
env = qa.environment({'FUSION_QA_APP_PASSWORD': 'synthetic'}) env = qa.environment({'FUSION_QA_APP_PASSWORD': 'synthetic'})
@@ -91,7 +95,7 @@ class QaToolsTest(unittest.TestCase):
def test_stop_accepts_python_wrapper_only_with_task_identity(self): def test_stop_accepts_python_wrapper_only_with_task_identity(self):
with tempfile.TemporaryDirectory() as directory, patch.object(qa, 'RUNTIME', Path(directory)), \ with tempfile.TemporaryDirectory() as directory, patch.object(qa, 'RUNTIME', Path(directory)), \
patch.object(qa.subprocess, 'run') as run, patch.object(qa.os, 'kill') as kill: patch.object(qa.subprocess, 'run') as run, patch.object(qa.os, 'kill') as kill, patch.object(qa, 'wait_for_exit'):
script = str(qa.ROOT / 'scripts/qa/serve_frontend.py') script = str(qa.ROOT / 'scripts/qa/serve_frontend.py')
command = ['/usr/local/bin/python3', script, '--port', '18088'] command = ['/usr/local/bin/python3', script, '--port', '18088']
(Path(directory)/'frontend.json').write_text(json.dumps({'pid':123,'command':command,'started':'same-birth-time'})) (Path(directory)/'frontend.json').write_text(json.dumps({'pid':123,'command':command,'started':'same-birth-time'}))