fix: 收口融合 QA 构建与验收安全基线

This commit is contained in:
wangzhibiao committed 2026-10-14 11:32:31 +08:00
1 parent 47bc6604e5
commit 5560a3ac04
28 files changed
+22944 -349

No files matched your search

+6
View File
@@ -68,3 +68,9 @@ docs/backups/production_backup_*.sql
docs/backups/*.dump
docs/backups/*.backup
docs/backups/*.sql
# Isolated local QA: private credentials, backups, runtime logs and browser captures
/.qa-runtime/
/output/playwright/
/.playwright-cli/
/scripts/qa/__pycache__/
+24 -90
View File
@@ -1,103 +1,37 @@
stages:
- build_backend
- build_frontend
- deploy
# Validation only. No automatic push pipelines or shared deployment from fusion work.
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "web" && $RUN_FUSION_VALIDATION == "true"'
- when: never
stages: [validate]
variables:
GIT_STRATEGY: clone
JAVA_HOME: "/data2/wzb/tools/jdk8"
M2_HOME: "/data2/wzb/tools/maven"
PATH: "/data2/wzb/tools/maven/bin:/data2/wzb/tools/jdk8/bin:/usr/local/bin:/usr/bin:/bin"
# 1. 编译打包后端
build_backend:
stage: build_backend
tags:
- railway-deploy
rules:
- if: '$CI_COMMIT_BRANCH == "feat/cicd-pipeline"'
- when: never
stage: validate
tags: [railway-deploy]
script:
- echo "=== 开始编译后端 RuoYi-Vue ==="
- export JAVA_HOME=/data2/wzb/tools/jdk8
- export PATH=/data2/wzb/tools/maven/bin:$JAVA_HOME/bin:$PATH
- javac -version
- test -n "$FUSION_JAVA21_HOME"
- export JAVA_HOME="$FUSION_JAVA21_HOME"
- export PATH="$JAVA_HOME/bin:$PATH"
- java -version
- test "$(javac -version 2>&1 | cut -d' ' -f2 | cut -d. -f1)" = 21
- bash scripts/bootstrap/check-kingbase-jdbc.sh
- cd RuoYi-Vue
- mvn clean package
- |
python3 -c 'import glob, re; runs = [int(m.group(1)) for f in glob.glob("ruoyi-*/target/surefire-reports/*.txt") for m in re.finditer(r"Tests run:\s*(\d+)", open(f, errors="ignore").read())]; print("=== 后端测试执行统计: Tests run: %d ===" % sum(runs))'
- echo "=== 后端测试与构建成功 ==="
- 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
artifacts:
expire_in: 2 days
paths:
- RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar
paths: [RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar]
# 2. 编译打包前端
build_frontend:
stage: build_frontend
tags:
- railway-deploy
rules:
- if: '$CI_COMMIT_BRANCH == "feat/cicd-pipeline"'
- when: never
stage: validate
tags: [railway-deploy]
script:
- echo "=== 开始编译前端 build_ui ==="
- export NODE_OPTIONS="--openssl-legacy-provider --dns-result-order=ipv4first"
- cd build_ui
- if [ ! -d "node_modules" ] && [ -d "/data2/wzb/tools/frontend-cache/node_modules" ]; then
echo "使用服务器本地缓存的 node_modules";
ln -sfn /data2/wzb/tools/frontend-cache/node_modules ./node_modules;
fi
- test "$(node --version)" = v22.23.2
- test "$(npm --version)" = 10.9.8
- npm ci --ignore-scripts --no-audit --no-fund
- npm run build:prod
- if [ ! -d "dist" ] || [ ! -f "dist/index.html" ]; then
echo "错误:前端构建产物 build_ui/dist/index.html 不存在,构建失败";
exit 1;
fi
- echo "=== 前端编译成功,build_ui/dist/index.html 校验通过 ==="
- test -f dist/index.html
artifacts:
expire_in: 2 days
paths:
- build_ui/dist/
# 3. 部署到业务目录并平滑重启
deploy_production:
stage: deploy
tags:
- railway-deploy
rules:
- if: '$CI_COMMIT_BRANCH == "feat/cicd-pipeline"'
- when: never
script:
- echo "=== 0. 验证部署运行时环境配置 ==="
- if [ -z "${TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY:-}" ] || [ -z "${TRAIN_STUDENT_IDENTITY_HASH_KEY:-}" ] || [ -z "${SPRING_REDIS_PASSWORD:-}" ]; then
echo "错误:缺少必需的运行时配置";
exit 1;
fi
- echo "required runtime configuration present."
- echo "=== 1. 执行部署备份 (Rollback 兜底) ==="
- if [ -d "/data2/wzb/deploy/frontend" ] && [ "$(ls -A /data2/wzb/deploy/frontend 2>/dev/null)" ]; then
echo "备份现有前端资源到 /data2/wzb/deploy/frontend.bak ...";
mkdir -p /data2/wzb/deploy/frontend.bak;
cp -a /data2/wzb/deploy/frontend/. /data2/wzb/deploy/frontend.bak/;
fi
- if [ -f "/data2/wzb/deploy/backend/ruoyi-admin.jar" ]; then
echo "备份现有后端 Jar 到 /data2/wzb/deploy/backend/ruoyi-admin.jar.bak ...";
cp -p /data2/wzb/deploy/backend/ruoyi-admin.jar /data2/wzb/deploy/backend/ruoyi-admin.jar.bak;
fi
- echo "=== 2. 部署前端静态资源与文档中心 ==="
- mkdir -p /data2/wzb/deploy/frontend
- rm -rf /data2/wzb/deploy/frontend/*
- cp -r build_ui/dist/* /data2/wzb/deploy/frontend/
- mkdir -p /data2/wzb/deploy/frontend/docs
- cp -r docs/* /data2/wzb/deploy/frontend/docs/
- echo "=== 3. 部署后端 Jar 包 ==="
- mkdir -p /data2/wzb/deploy/backend
- cp RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar /data2/wzb/deploy/backend/
- echo "=== 4. 执行平滑重启与生产自检 ==="
- chmod +x deploy/restart-all.sh
- bash deploy/restart-all.sh
- echo "=== 5. 输出访问地址 ==="
- echo "APPLICATION_URL=http://192.168.139.230:8082/"
- echo "DOCUMENTATION_URL=http://192.168.139.230:8082/docs/visual/index.html"
- echo "=== 部署与自检成功完成! ==="
paths: [build_ui/dist/]
# Deliberately no deploy job. Private JDBC provisioning is administrator-controlled.
+96 -130
View File
@@ -1,153 +1,119 @@
# SYFJGYD学员管理系统(融合演示版)— Clean Demo Candidate 运行与演示说明书
# SYFJGYD 学员管理系统 — Java 21 / Spring Boot 4 隔离 QA 运行手册
## 一、系统概况
本手册仅对应 `boot4-iot-fusion-archive` 的 `fix/iot-safety-hardening` 本地候选版本。
原 RailwaySystem 和历史实验工程只读;不执行 Push、Merge、共享部署或硬件操作。
本工程为 **SYFJGYD学员管理系统** 的隔离融合演示候选版本(Clean Demo Candidate),集成了 **Java 21 + Spring Boot 4** 架构升级、培训管理业务(学员、班级、住宿全周期)与智能门锁 IoT 协同授权中枢。
## 当前验收边界
### 核心演示原则
- **数据源隔离**:严格运行于独立研发 QA 数据库(`railwaysystem_fusion_qa_20261013`),采用最小权限应用账号连接,与共享试用库(`training`)及生产环境完全隔离。
- **仿真受控运行**:IoT 底层采用仿真受控模式,物理 MQTT 硬件与实体门锁处于安全隔离状态,防止误触发真实硬件动作。
- **真实完整业务闭环**:前端 UI、后端业务逻辑、Kingbase 事务状态流转均为 100% 真实执行。
Round 5V 已核对目标 Kingbase 版本、低权限账号及迁移前表结构。005 尚未执行:
`MIGRATION_WAITING_APPROVAL`。不能将 H2/替身测试、构建成功或登录页显示称为
Kingbase 事务/浏览器业务闭环验收。真实硬件验收始终未进行。
迁移前置、失败回滚和核验清单见 [迁移计划](docs/iot/ROUND5V_KINGBASE_MIGRATION_PLAN.md)。
**不要执行 qa-bootstrap 全量初始化、清空测试数据、重建库、重置已有账号或序列。**
---
## 1. 构建前提
## 二、系统访问与前置准备
- Java 21(本机验证 21.0.2)、Maven 3.9.x;全部模块统一 release 21。
- Node 22.23.2、npm 10.9.8(`build_ui/.nvmrc` / packageManager / lockfile)。
- Python 3.9+、本机 `redis-server`(新实例只监听 loopback 的独立端口)。
- 合法取得当前商业 JDBC 9.0.0;不向 Git 或公共 Maven 上传二进制。
### 1. 访问地址
- **前端 Web 控制台**:`http://127.0.0.1:8088`
- **默认演示账号**:`admin` / `admin123`
- **后端 API 服务**:`http://127.0.0.1:8096`(由前端通过 `/prod-api` 自动反向代理)
从本工作树根目录执行(路径以实际 checkout 为准,不引用 RuoYi-Vue-master):
### 2. 演示前服务状态核验
在启动系统前,请确保研发数据库 SSH 隧道处于连通状态:
- **隧道端口**:`127.0.0.1:14321`
- **若隧道未连通**,请在终端执行:
```bash
ssh -N -L 127.0.0.1:14321:192.168.139.218:4321 -o ServerAliveInterval=15 wzbmatebook
```
- **安全凭据文件**:系统启动依赖受限目录下的环境变量配置(`~/.railwaysystem-qa.env`),密码由启动脚本安全注入,无需人工输入。
---
## 三、一键启停操作指南
系统提供了平滑、健壮的一键管理脚本,支持自动端口清理与就绪健康探测:
### 1. 启动系统
在工程根目录下执行:
```bash
./start_demo.sh
java -version
mvn -version
export KINGBASE_JDBC_JAR='/absolute/path/to/authorized/kingbase8-9.0.0.jar'
bash scripts/bootstrap/install-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
python3 -m unittest discover -s scripts/qa -p test_qa_tools.py -v
```
**启动脚本自动完成:**
1. 校验环境变量与凭据完整性;
2. 探测数据库 SSH 隧道连通性(未连通时给出明确操作指引并安全终止);
3. 自动释放 8096 与 8088 端口历史旧进程;
4. 启动 Spring Boot 4 后端,轮询 `/captchaImage` 验证就绪(约 4~6 秒);
5. 启动前端反向代理服务并验证 HTTP 200 就绪;
6. 打印就绪提示与访问入口。
### 2. 停止系统
在演示结束或需要重置时执行:
驱动私有坐标:`cn.com.kingbase:kingbase8:9.0.0`;SHA256:
`e439bd1c48ce654748c4f333b874a61465702ceb18f29ee017c3f9bc47738c23`。
安装脚本先校验供应商文件,再用固定版本 Maven install plugin 安装到本机私有仓库。
`ruoyi-admin/lib` 不再是构建依赖来源。CI 由管理员合规预置同一私有依赖,缺失即失败。
```bash
./stop_demo.sh
```
**停止脚本自动完成:**
1. 优雅停止前端反向代理进程(释放端口 8088);
2. 优雅停止后端 Spring Boot 进程(释放端口 8096);
3. 清理 Headless Chrome 测试残留进程;
4. 确认所有演示端口已完全释放。
---
## 四、建议的五分钟核心业务演示路线
建议按照培训学员入宿、门锁授权、调宿换锁、退宿撤权的标准业务生命周期进行演示:
```
[1. 管理员登录]
↓
[2. 住宿管理:查询预分配学员] ──> 点击【入住】办理 ──> 状态流转为【在住】
↓
[3. 智能门锁:授权管理] ──> 查看自动生成的人脸授权意图 (待下发/待回执)
↓
[4. 仿真回执确认] ──> 受控驱动网关与门锁回执 ──> 页面刷新为【已生效/下发成功】
↓
[5. 住宿管理:办理调房] ──> 选择新宿舍 (致远楼 102) 提交 ──> 触发新门锁建权与旧门锁撤权
↓
[6. 住宿管理:办理退宿] ──> 点击【退宿】确认 ──> 状态更新为【已退宿】,授权彻底清除
↓
[7. 履历与流水核查] ──> 查看【住宿履历】对话框 & 【命令记录】指令流水
cd build_ui
node --version
npm --version
npm ci --ignore-scripts --no-audit --no-fund
npm run build:prod
cd ..
```
### 步骤详解:
不得用实验工程/node_modules1/node_modules2、服务器 symlink 或临时依赖目录替代
lockfile 安装。Quill 2 的转译配置与实验工程一致。Vue 2/CLI 4 的废弃依赖及旧版
node-ipc engine/ESLint peer 警告仍存在:本轮不做整套前端框架迁移,不声称无漏洞。
1. **登录系统**:
- 打开浏览器访问 `http://127.0.0.1:8088`,输入账号 `admin`,密码 `admin123`,点击登录,进入系统仪表盘首页。
2. **查询并办理入住**:
- 点击左侧菜单【培训管理】->【住宿安排】(或访问 `/train/accommodation`);
- 在学号输入框输入演示学员学号(例如 `S_DEMO_20261013`),点击【搜索】;
- 找到处于“预分配”状态的学员记录,点击操作列的【入住】按钮;
- 在弹出的确认对话框中点击【确定】;
- 页面提示“入住办理成功”,学员住宿状态实时刷新为绿色的【在住】标签。
3. **核查门锁授权意图**:
- 点击左侧菜单【门禁管理】->【授权管理】(或访问 `/iot/auth`);
- 看到系统已根据学员的人脸特征与所分配的宿舍房间(致远楼 101),自动建立门锁授权意图记录。
4. **演示仿真回执确认生效**:
- 在仿真环境下,系统调用受控 Mock 回执端点驱动状态;
- 在授权管理页面点击【搜索】刷新,授权同步状态变更为【已生效】(或【下发成功】),展示端到端联动效果。
5. **办理学员调房**:
- 返回【住宿安排】页面,点击学员右侧的【调房】按钮;
- 在弹出的【办理学员调房】对话框中,目标宿舍选择“致远楼 102”,输入床位号“2号床”,点击【确认调房】;
- 系统执行调房事务:原宿舍记录流转为“已退宿”,新宿舍记录建立并流转为“在住”;
- 后台自动向旧门锁下发撤权指令,向新门锁下发建权指令。
6. **办理退宿**:
- 在【住宿安排】页面中,点击学员所在行的【退宿】按钮,在确认框中点击【确定】;
- 提示“退宿办理成功”,住宿状态更新为红色的【已退宿】标签。
7. **查看历史履历与指令流水**:
- 点击该行操作列的【住宿履历】按钮,弹出【学员历史住宿记录】对话框,清晰展示从预分配、调房退宿到最终退宿的完整时间轴;
- 点击左侧菜单【门禁管理】->【命令记录】(或访问 `/iot/command`),查看全流程自动生成的授权下发(0011)与清除授权(0012)指令流水,证明全链路具备完整审计溯源能力。
## 2. 凭据和只读身份检查
---
将 `qa-bootstrap/round5v.env.example` 的键名交给管理员,在 Git 外受保护文件中
填写真实 QA 配置并设置 owner-only 权限 0600。不要把密码放命令行、截图或报告。
脚本也兼容本机已有 `~/.railwaysystem-qa.env` 中的 `FUSION_QA_APP_PASSWORD` 和
身份加密键,但**不会复用其中可能指向旧库的 QA_DB_URL**。
## 五、真实业务流程 vs 仿真(Mock)环节说明
```bash
python3 scripts/qa/qa.py inspect --config /protected/round5v.env
python3 scripts/qa/qa.py snapshot --config /protected/round5v.env
python3 scripts/qa/qa.py verify-snapshot --snapshot-dir .qa-runtime/snapshot-REPLACE_WITH_ACTUAL_ID
```
为了确保演示的真实性与合规性,系统严格区分业务逻辑与硬件模拟:
固定目标:`railwaysystem_fusion_qa_20261013`;固定运行账号:`fusion_qa_app`。
检查使用只读 repeatable-read 事务并 rollback,记录版本、schema、列、索引和计数;
不打印业务行。快照含两张受影响表的敏感内容,仅存 `.qa-runtime/`(目录0700,
数据0600);其 XML 解析和 SHA 验证不等同于原生全库备份或数据库恢复演练。
本机未找到原生备份客户端,完整备份及回退演练由管理员按迁移计划准备。
| 业务环节 | 实现方式 | 状态说明 |
| :--- | :--- | :--- |
| **管理员鉴权与 RBAC 权限** | 真实数据库与 Spring Security | 100% 真实执行 |
| **学员基本信息与班级档案** | 真实 Kingbase QA 数据库表 | 100% 真实执行 |
| **住宿全生命周期状态机** | 真实业务 Service 与事务 | 100% 真实执行(预分配/在住/退宿) |
| **住宿调房多阶段事务** | 真实业务 Service | 100% 真实执行(旧记退宿 + 新建在住) |
| **门锁授权意图关联生成** | 真实 IoT 业务桥接器 | 100% 真实执行(自动解析人脸与房间锁映射) |
| **指令流水与审计日志** | 真实 Kingbase `iot_command` 表 | 100% 真实执行,全链路持久化 |
| **前端交互与响应** | 真实 Vue SPA 页面与 Element UI | 100% 真实执行,无静态伪造 |
| **MQTT 指令双端回执** | Mock 仿真驱动(`iot.mqtt.mock=true`) | 仿真模拟网关与门锁硬件回执,隔绝物理设备 |
| **人脸识别特征提取** | 仿真服务 | 仿真特征向量映射,未连接物理人脸终端 |
| **实体门锁物理开锁/状态** | 物理隔离(未接入) | **BLOCKED / HARDWARE_PENDING** |
## 3. 启停隔离 QA(迁移批准并完成后)
---
```bash
python3 scripts/qa/qa.py start --config /protected/round5v.env
python3 scripts/qa/qa.py frontend
```
## 六、常见问题排查与快速恢复
默认端口:后端18096、前端18088、独立 Redis16389,仅监听127.0.0.1;端口占用即
失败,不杀旧进程、不改共享 Redis。启动器先确认目标数据库和16列/4索引存在,
缺迁移拒绝启动;列/索引的完整定义仍须按迁移计划人工核验。PID 输出不代表就绪。
### 1. 启动脚本提示 `[BLOCKED] 数据库 SSH 隧道未连接`
- **原因**:本地 14321 端口无法连接到 Kingbase 研发服务器。
- **排查方法**:
1. 检查 SSH 隧道进程:`ps aux | grep 14321`;
2. 重新建立隧道:`ssh -N -L 127.0.0.1:14321:192.168.139.218:4321 wzbmatebook`;
3. 验证端口连通:`nc -z 127.0.0.1 14321`。
安全值强制为 MQTT Mock=true / enabled=false / real-device-write-enabled=false /
whitelist空 / protocol-verified=false / retry-task-enabled=false;人脸 HTTP关闭;
OpenAPI、QA危险接口、Druid控制台和Quartz自动启动关闭;旧DMS关闭。
继承环境中的 Spring/IoT/DMS/JVM 注入覆盖被过滤,数据库 URL/账号由命令行固定。
JWT和独立Redis密钥由安全随机数首次生成至 `.qa-runtime/runtime-secrets.env`,0600,
以进程环境注入,不写源码/命令行;数据库及身份密钥仅取受保护配置文件。
不得为演示去打开真实门禁或危险接口。
### 2. 页面访问提示 502 Bad Gateway
- **原因**:后端 Spring Boot 正在启动中或由于数据库超时启动失败。
- **排查方法**:
1. 查看后端日志:`tail -n 50 backend.log`;
2. 检查是否有 Druid 数据源连接超时异常;
3. 确认后执行 `./stop_demo.sh && ./start_demo.sh` 重启。
```bash
curl --noproxy '*' --fail http://127.0.0.1:18096/captchaImage
# 真实 Chrome 访问 http://127.0.0.1:18088 ,使用管理员单独提供的 QA 账号。
python3 scripts/qa/qa.py stop
```
### 3. 演示过程中需要重置测试数据
- **方法**:
可直接在工程根目录下执行演示数据预置脚本:
```bash
java -cp RuoYi-Vue-master/ruoyi-admin/target/test-classes:RuoYi-Vue-master/ruoyi-admin/target/ruoyi-admin.jar com.ruoyi.train.PrepareDemoData
```
该命令将自动重置学员 `S_DEMO_20261013` 为初始的“预分配”住宿状态,并清理门锁历史旧授权,无需重启系统即可开始下一轮完整演示。
stop只处理本脚本账本中且进程命令身份仍匹配的PID;不会清端口、杀其他Chrome、
停止实验工程或修改系统网络。JWT、访问日志、快照、浏览器证据均不进入Git。
## 4. 迁移后验收清单(未完成项不得标 PASS)
1. 管理员真实登录、getInfo/getRouters、原品牌菜单和首页图表;培训核心查询。
2. IoT九页:group/gateway/device/face/auth/command/accesslog/mqttlog/config,
必须验证组件渲染及实际QA查询,不能把SPA fallback HTTP200当作无404证明。
3. 为本轮创建可追踪的合成业务样本,不复用/覆盖既有培训测试记录:报名、
住宿预分配、入住、调宿、退宿、授权意图与`iotlocks.lock_command`流水。
4. 用实际Kingbase Mapper/Service/事务管理器覆盖迁移计划中的崩溃、并发、晚到ACK、
提交/回滚场景;外部MQTT和人脸只能是可控替身。危险Mock HTTP端点保持关闭,
ACK由隔离的Service测试驱动,不通过开放生产式控制接口来换取通过。
5. 受控Mock ACK成功只能证明软件闭环,不能证明真实门锁已授权或撤权。
现有 `Round5sExecutionIntegrationTest` 的文件型H2证据不是第4项的替代品;
`Round5vAccommodationAtomicityTest`证明异常传播/事务声明,不证明Kingbase回滚。
## 5. CI与当前阻塞
`.gitlab-ci.yml` 已改为显式web+RUN_FUSION_VALIDATION=true才允许的验证流水线;
Java21、私有JDBC预检及npm ci,**无部署job**。本轮未Push,也未触发/验证远程Runner。
当前仍缺明确005迁移授权、原生备份/回退演练、真实Kingbase生命周期验证和
依托新QA后端的完整Chrome验收。后端未获准迁移时,单独前端会明确返回502,
不得用旧实验后端或伪造API响应冒充新版本验收。
+4 -6
View File
@@ -16,7 +16,7 @@
<ruoyi.version>3.9.2</ruoyi.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>17</java.version>
<java.version>21</java.version>
<spring-boot.version>4.1.0</spring-boot.version>
<mybatis-spring-boot.version>4.1.0</mybatis-spring-boot.version>
<druid.version>1.2.28</druid.version>
@@ -137,9 +137,8 @@
<version>${kaptcha.version}</version>
</dependency>
<!-- KingbaseES(人大金仓)驱动:官方未提供公网 Maven 仓库,驱动包直接放置在
ruoyi-admin/lib 目录,由 ruoyi-admin 模块的 kingbase profile 以 system scope 引入。
若 jar 文件名或版本变更,请同步修改 kingbase.version 属性及 ruoyi-admin 中的 systemPath。 -->
<!-- Approved vendor JDBC artifact: privately provision cn.com.kingbase:kingbase8.
See scripts/bootstrap/install-kingbase-jdbc.sh; no vendor binary in Git. -->
<dependency>
<groupId>cn.com.kingbase</groupId>
<artifactId>kingbase8</artifactId>
@@ -224,8 +223,7 @@
<version>3.13.0</version>
<configuration>
<parameters>true</parameters>
<source>${java.version}</source>
<target>${java.version}</target>
<release>${java.version}</release>
<encoding>${project.build.sourceEncoding}</encoding>
</configuration>
</plugin>
+1 -26
View File
@@ -94,7 +94,7 @@
</dependencies>
</profile>
<!-- KingbaseES(人大金仓):驱动包置于 ruoyi-admin/lib 目录,直接按文件路径引入 -->
<!-- KingbaseES: provision approved cn.com.kingbase:kingbase8 privately before build. -->
<profile>
<id>kingbase</id>
<activation>
@@ -104,31 +104,8 @@
<dependency>
<groupId>cn.com.kingbase</groupId>
<artifactId>kingbase8</artifactId>
<scope>system</scope>
<systemPath>${project.basedir}/lib/kingbase8-${kingbase.version}.jar</systemPath>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<configuration>
<!-- 打 war 包时把外部驱动一起放进 WEB-INF/lib -->
<webResources>
<resource>
<directory>${project.basedir}/lib</directory>
<targetPath>WEB-INF/lib</targetPath>
<filtering>false</filtering>
<includes>
<include>kingbase8-${kingbase.version}.jar</include>
</includes>
</resource>
</webResources>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
@@ -139,8 +116,6 @@
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<addResources>true</addResources>
<!-- KingbaseES 等 system scope 驱动需要打进可执行 jar -->
<includeSystemScope>true</includeSystemScope>
</configuration>
<executions>
<execution>
-12
View File
@@ -7,18 +7,6 @@
<groupId>com.ruoyi</groupId>
<version>3.9.2</version>
</parent>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>9</source>
<target>9</target>
</configuration>
</plugin>
</plugins>
</build>
<modelVersion>4.0.0</modelVersion>
<artifactId>ruoyi-common</artifactId>
-12
View File
@@ -7,18 +7,6 @@
<groupId>com.ruoyi</groupId>
<version>3.9.2</version>
</parent>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>16</source>
<target>16</target>
</configuration>
</plugin>
</plugins>
</build>
<modelVersion>4.0.0</modelVersion>
<artifactId>ruoyi-iot</artifactId>
@@ -224,14 +224,8 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
boolean isTransferInsert = accommodation.getRemark() != null && accommodation.getRemark().contains("调房迁入");
if (!isTransferInsert && TrainAccommodation.STATUS_CHECKED_IN.equals(accommodation.getAccommodationStatus()))
{
try
{
iotLockAuthBridgeService.reconcileCheckin(accommodation.getAccommodationId(), accommodation.getCreateBy());
}
catch (Exception ex)
{
log.warn("IoT lock auth reconcile on checkin failed for accommodation {}: {}", accommodation.getAccommodationId(), ex.getMessage());
}
// Intent persistence failure must abort the surrounding accommodation transaction.
iotLockAuthBridgeService.reconcileCheckin(accommodation.getAccommodationId(), accommodation.getCreateBy());
}
}
else if (accessAuthorizationOrchestrator != null)
@@ -302,6 +296,7 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
* @return 结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int checkIn(Long accommodationId, String operator)
{
TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId);
@@ -320,14 +315,8 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
boolean useIot = "iot".equalsIgnoreCase(authEngine) && iotLockAuthBridgeService != null;
if (useIot)
{
try
{
iotLockAuthBridgeService.reconcileCheckin(accommodationId, operator);
}
catch (Exception ex)
{
log.warn("IoT lock auth reconcile on checkIn failed for accommodation {}: {}", accommodationId, ex.getMessage());
}
// Intent persistence failure must abort the surrounding accommodation transaction.
iotLockAuthBridgeService.reconcileCheckin(accommodationId, operator);
}
else if (accessAuthorizationOrchestrator != null)
{
@@ -457,14 +446,8 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
if ("iot".equalsIgnoreCase(authEngine) && iotLockAuthBridgeService != null)
{
try
{
iotLockAuthBridgeService.reconcileTransfer(oldAccommodationId, newAcc.getAccommodationId(), operator);
}
catch (Exception ex)
{
log.error("IoT lock auth transfer reconcile failed for old {} -> new {}: {}", oldAccommodationId, newAcc.getAccommodationId(), ex.getMessage());
}
// Intent persistence failure must abort the surrounding accommodation transaction.
iotLockAuthBridgeService.reconcileTransfer(oldAccommodationId, newAcc.getAccommodationId(), operator);
}
return newAcc;
@@ -0,0 +1,59 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.*;
import java.lang.reflect.*;
import java.util.*;
import org.junit.Test;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.integration.lock.service.ITrainLockAuthBridgeService;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
import com.ruoyi.train.mapper.TrainDormitoryInfoMapper;
/** Actual business entry-point exception/transaction declarations, not a database rollback proof. */
public class Round5vAccommodationAtomicityTest {
private final RuntimeException failure = new IllegalStateException("synthetic-intent-persistence-failure");
@SuppressWarnings("unchecked")
private <T> T proxy(Class<T> type, InvocationHandler handler) {
return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[]{type}, handler);
}
private void inject(Object object, String name, Object value) throws Exception {
Field field = TrainAccommodationServiceImpl.class.getDeclaredField(name);
field.setAccessible(true); field.set(object,value);
}
private TrainAccommodationServiceImpl service(boolean transfer) throws Exception {
TrainAccommodationServiceImpl service = transfer ? new TrainAccommodationServiceImpl() {
@Override public int insertTrainAccommodation(TrainAccommodation value) {
value.setAccommodationId(2L); return 1; // isolate transfer exception boundary only
}
} : new TrainAccommodationServiceImpl();
TrainAccommodation old = new TrainAccommodation(); old.setAccommodationId(1L);
old.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN); old.setDormId("QA_A"); old.setEnrollmentId(3L);
inject(service,"trainAccommodationMapper",proxy(TrainAccommodationMapper.class,(p,m,a)-> {
if (m.getName().equals("selectTrainAccommodationByAccommodationId")) return old;
if (m.getReturnType()==int.class) return 1;
return null;
}));
inject(service,"trainDormitoryInfoMapper",proxy(TrainDormitoryInfoMapper.class,(p,m,a)->null));
service.setAuthEngine("iot");
service.setIotLockAuthBridgeService(proxy(ITrainLockAuthBridgeService.class,(p,m,a)-> { throw failure; }));
return service;
}
@Test public void checkinDoesNotSwallowIntentPersistenceFailure() throws Exception {
try { service(false).checkIn(1L,"qa-test"); fail("Failure must propagate"); }
catch (RuntimeException expected) { assertSame(failure,expected); }
}
@Test public void transferDoesNotSwallowIntentPersistenceFailure() throws Exception {
try { service(true).transfer(1L,"QA_B","1","qa-test"); fail("Failure must propagate"); }
catch (RuntimeException expected) { assertSame(failure,expected); }
}
@Test public void checkoutStillPropagatesIntentPersistenceFailure() throws Exception {
try { service(false).checkOut(1L,"qa-test"); fail("Failure must propagate"); }
catch (RuntimeException expected) { assertSame(failure,expected); }
}
@Test public void checkinDeclaresRollbackBoundary() throws Exception {
Transactional annotation = TrainAccommodationServiceImpl.class.getMethod("checkIn",Long.class,String.class).getAnnotation(Transactional.class);
assertNotNull(annotation); assertTrue(Arrays.asList(annotation.rollbackFor()).contains(Exception.class));
}
}
-1
View File
@@ -19,5 +19,4 @@ selenium-debug.log
*.sln
*.local
package-lock.json
yarn.lock
+2
View File
@@ -0,0 +1,2 @@
registry=https://registry.npmjs.org/
save-exact=true
+1
View File
@@ -0,0 +1 @@
22.23.2
+22021
View File
File diff suppressed because it is too large. Load diff
+28 -27
View File
@@ -1,6 +1,6 @@
{
"name": "ruoyi",
"version": "3.8.5",
"version": "3.9.2",
"description": "SYFJGYD学员管理系统",
"author": "若依",
"license": "MIT",
@@ -36,6 +36,28 @@
"url": "https://gitee.com/y_project/RuoYi-Vue.git"
},
"dependencies": {
"@riophae/vue-treeselect": "0.4.0",
"axios": "0.30.3",
"clipboard": "2.0.8",
"core-js": "3.37.1",
"echarts": "5.4.0",
"element-ui": "2.15.14",
"file-saver": "2.0.5",
"fuse.js": "6.4.3",
"highlight.js": "9.18.5",
"js-beautify": "1.13.0",
"js-cookie": "3.0.1",
"jsencrypt": "3.0.0-rc.1",
"nprogress": "0.2.0",
"quill": "2.0.2",
"screenfull": "5.0.2",
"sortablejs": "1.10.2",
"vue": "2.6.12",
"vue-count-to": "1.0.13",
"vue-cropper": "0.5.5",
"vue-router": "3.4.9",
"vuedraggable": "2.24.3",
"vuex": "3.6.0",
"@antv/g6": "^4.8.17",
"@antv/x6": "^2.11.1",
"@antv/x6-plugin-export": "^2.1.6",
@@ -43,37 +65,15 @@
"@antv/x6-plugin-selection": "^2.2.2",
"@antv/x6-vue-shape": "^2.1.2",
"@handsontable/vue": "^13.0.0",
"@riophae/vue-treeselect": "0.4.0",
"axios": "0.24.0",
"clipboard": "2.0.8",
"core-js": "3.25.3",
"dayjs": "^1.11.7",
"echarts": "5.4.0",
"element-ui": "2.15.12",
"file-saver": "2.0.5",
"file-server": "^2.2.1",
"fuse.js": "6.4.3",
"handsontable": "^13.0.0",
"highlight.js": "9.18.5",
"html2canvas": "^1.4.1",
"jquery": "^3.7.0",
"jquery-mousewheel": "^3.1.13",
"js-beautify": "1.13.0",
"js-cookie": "3.0.1",
"jsencrypt": "3.0.0-rc.1",
"mousetrap": "^1.6.5",
"nprogress": "0.2.0",
"papaparse": "^5.4.1",
"quill": "1.3.7",
"screenfull": "5.0.2",
"sortablejs": "1.10.2",
"vue": "2.6.12",
"vue-count-to": "1.0.13",
"vue-cropper": "0.5.5",
"vue-meta": "2.4.0",
"vue-router": "3.4.9",
"vuedraggable": "2.24.3",
"vuex": "3.6.0",
"xlsx": "^0.18.5",
"xlsx-style": "^0.8.13"
},
@@ -84,7 +84,7 @@
"babel-eslint": "10.1.0",
"babel-plugin-dynamic-import-node": "2.3.3",
"chalk": "4.1.0",
"compression-webpack-plugin": "5.0.2",
"compression-webpack-plugin": "6.1.2",
"connect": "3.6.6",
"eslint": "7.15.0",
"eslint-plugin-vue": "7.2.0",
@@ -98,11 +98,12 @@
"vue-template-compiler": "2.6.12"
},
"engines": {
"node": ">=8.9",
"npm": ">= 3.0.0"
"node": ">=22.23.2 <23",
"npm": ">=10.9.8 <11"
},
"browserslist": [
"> 1%",
"last 2 versions"
]
],
"packageManager": "npm@10.9.8"
}
+2 -2
View File
@@ -75,8 +75,8 @@ export default {
footerContent: defaultSettings.footerContent,
codeUrl: "",
loginForm: {
username: "admin",
password: "admin123",
username: "",
password: "",
rememberMe: false,
code: "",
uuid: ""
+2
View File
@@ -32,6 +32,8 @@ module.exports = {
lintOnSave: process.env.NODE_ENV === 'development',
// 如果你不需要生产环境的 source map,可以将其设置为 false 以加速生产环境构建。
productionSourceMap: false,
// Quill 2 ships class fields; match the tested fusion build under Webpack 4.
transpileDependencies: ['quill'],
// webpack-dev-server 相关配置
devServer: {
+79
View File
@@ -0,0 +1,79 @@
# Round 5V 验收记录 — 部分通过,迁移待授权
日期:2026-10-14;分支 `fix/iot-safety-hardening`;基线
`47bc6604e53b841e9070b890e65e0a75df1c1e1c`。本文件描述本轮实际结果,
不继承此前 Agent 的通过声明。最终本地提交号见 Git HEAD。
## 真实 Kingbase 结果
- JDBC 实际连接仅限 `192.168.139.218:4321/railwaysystem_fusion_qa_20261013`。
账号 `fusion_qa_app`,KingbaseES V009R003C018,current_schema=public。
只读 repeatable-read 事务结束 rollback;未执行 DDL、业务 INSERT/UPDATE/DELETE、
全量初始化、GRANT、账号修改或共享库访问。
- 迁移前 `iotlocks.lock_auth` 10条,`lock_command` 15条;最终只读复查不变。
005的新增列/索引缺失,迁移未执行,`MIGRATION_WAITING_APPROVAL`。
- 角色非 superuser/createdb/createrole,public/iotlocks 均无 CREATE,表属 kingbase。
但3张 public 表具有额外 TRUNCATE/TRIGGER/REFERENCES,见迁移计划,待管理员审查。
- 两张受影响表的 WebRowSet 私有快照10/15行,SHA256及XML解析通过:
`.qa-runtime/snapshot-baf7eb8fede1`。不是原生全库备份,未做数据库恢复演练。
- **八类生命周期/并发/跨事务 Kingbase 测试均未执行**。只读事务成功不是它们的证明。
当前历史数据未被改写;新增默认 LEGACY/QUARANTINED 的实际迁移效果尚未验证。
证据:`scripts/qa/QaDatabaseProbe.java`、`.qa-runtime/final-database-inspection.log`、
`.qa-runtime/snapshot-report.log`、[迁移/回退计划](ROUND5V_KINGBASE_MIGRATION_PLAN.md)。
## 构建与自动化结果
- Java 21.0.2 / Maven 3.9.x 打包成功;所有模块 release21,抽查 class major65。
当前85项选定JUnit测试,失败0、错误0:原81项加4项住宿异常/事务声明测试。
它们主要为H2/替身,不是Kingbase生命周期验收。
- 修复入住/直接新增在住/调宿吞掉IoT持久化异常;checkIn增加事务边界。
`Round5vAccommodationAtomicityTest`覆盖入住/调宿/退宿异常传播及事务声明。
新增修复的真实Kingbase回滚语义待验证,未夸称原子性已通过。
- 11项Python QA工具测试通过:目标库白名单、URL凭据拒绝、配置权限、无Shell
执行、过滤继承覆盖、迁移缺失不启动、强制安全参数和PID复用拒绝。
- JDBC已从授权本机文件校验后安装为私有Maven依赖9.0.0,正常打入Boot JAR,
不再依赖systemPath;未提交供应商二进制。安装/预检脚本从旧8.6.0同步至9.0.0。
- 前端Node22.23.2/npm10.9.8,`npm ci --ignore-scripts --no-audit --no-fund`
实际安装1753依赖,生产构建通过。核心版本对齐实验工程,新增受跟踪lockfile。
初次构建暴露Quill2语法转译缺失,补回实验已有transpileDependencies后通过。
旧Vue2/CLI4废弃依赖、node-ipc engine/ESLint peer警告未在本轮整体迁移。
- 登录表单残留固定测试账号/密码预填已清空,未重置任何数据库账号。
证据:`RuoYi-Vue/ruoyi-*/target/surefire-reports/`、
`.qa-runtime/backend-build.log`、`.qa-runtime/frontend-build.log`、
`.qa-runtime/qa-tools-tests.log`;准确复现命令见根目录DEMO_RUNBOOK.md。
## 实际 Chrome 与运行结果
- Playwright使用已安装的 `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
UA Chrome155。实际打开生产dist(loopback18088),可见原SYFJGYD品牌登录页。
截图 `output/playwright/round5v-login-backend-blocked.png`,已目视检查。
- 新后端未启动:`qa.py start`执行真实目标库预检后以MIGRATION_REQUIRED拒绝,
未启动Redis或Spring Boot。前端的captchaImage如实返回502,没有替身API或旧后端。
因此管理员登录、动态路由、首页图表、培训/IoT API、九页功能、住宿E2E均未验收。
九个IoT页面源码被生产构建编译不代表页面运行无404。
- 独立端口方案18096/18088/16389,实际只为登录页检查启动了前端服务;
交付前关闭本任务前端和本任务Chrome会话。既有Java PID51901/53305未被停止。
## 配置与工程边界
- 所有安全默认保持关闭真实MQTT/设备写入、空白名单、关闭人脸HTTP、OpenAPI、
QA危险接口、Druid控制台、补偿任务和Quartz自动启动;旧DMS关闭。
运行器不清端口、不杀既有进程、不修改共享Redis或Mac网络;JWT/Redis密钥在
私有0600外部运行配置中生成并仅经环境注入,数据库/身份密钥从受保护文件读取。
- DEMO_RUNBOOK已移除不存在脚本/错误路径/固定登录口令/100%真实执行等声明,
提供实际存在的inspect/snapshot/start/frontend/stop入口和未通过项。
- CI YAML静态解析通过:Java21、私有JDBC预检、npm ci,显式web+变量门禁,
无部署job、无push自动流水线。本轮未Push、Merge、触发远程CI或部署;Runner未验证。
- 变更范围私钥/常见Token模式扫描无发现,未将快照/日志/浏览器缓存/驱动加入Git。
原工程无tracked diff;历史实验工程clean,未改写两者源码或历史。
## 仍需完成
1. 明确仅针对目标QA库的005授权、合规原生备份及回退演练,协调该库写入窗口。
2. 授权后执行并核验增量迁移、历史LEGACY隔离和实际Kingbase八类事务测试。
3. 启动新隔离后端,以真实Chrome完成管理员/菜单/首页/九页/API/住宿Mock闭环。
4. 管理员审查3张表额外权限;老师协议/单台现场测试批准仍独立待办。
READY_FOR_SUPERVISED_HARDWARE_TEST = false。Mock/H2成功不等于真实门锁联调成功。
@@ -0,0 +1,86 @@
# Round 5V — isolated Kingbase migration and rollback gate
## Scope and observed prestate
2026-10-14 read-only JDBC probe: database `railwaysystem_fusion_qa_20261013`, user
`fusion_qa_app`, KingbaseES `V009R003C018`, current schema `public`, search path
`"$user", public`. Application account is not superuser and cannot create databases
or roles. Existing `iotlocks.lock_auth`: 10 rows; `lock_command`: 15 rows. Existing
sequence-backed primary keys, unique auth number, unique person/device/type, and
unique command number remain unchanged. Round 5S columns/indexes were absent.
No migration authorization is inferred from an instruction conditional on approval.
The application role has USAGE but not CREATE on both schemas and does not own
the affected tables. However, existing grants on `public.sys_menu`,
`public.sys_notice_read`, and `public.sys_role_menu` include TRUNCATE, TRIGGER and
REFERENCES. Administrator least-privilege review is pending; no grants were changed.
## Static review of 005
`RuoYi-Vue/sql/iot/005_round5s_execution_safety.sql` only adds nullable metadata,
NOT NULL defaults for mode/version/state, two unique intent-key indexes, and two
recovery indexes. All objects are schema-qualified; no sequences are reset, no
existing business uniqueness is removed, no data is deleted, and there is no REAL
backfill. Existing records must become `LEGACY / 0 / QUARANTINED`, not trusted Mock
or REAL. Two nullable intent-key indexes must allow multiple nulls on this actual
Kingbase instance; validate this with rollback-only synthetic rows after approval.
`ADD COLUMN IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` are PostgreSQL-style
syntax supported by the H2 substitute, **not yet a Kingbase execution result**.
Do not use existence checks as proof of correct definitions: inspect names,
types, nullability, defaults and index uniqueness/column order before/after.
Use a bounded lock timeout and a single explicit transaction. DDL rollback and
concurrent unique-index behavior still require actual Kingbase verification.
Application sessions use `iotlocks, public, pg_catalog, sys_catalog`; do not change
the global/database/user search path or accidentally query system `sys_user` views.
## Required approvals and backup
1. Record authorization explicitly naming this database, migration 005 and its
SHA256, maintenance window, account and approving administrator. App-account
credentials do not grant authority to migrate; do not expand its privileges.
2. Inspect active sessions in this target only. Have owners pause *only* writers
targeting this QA database. Do not kill the experiment, teacher or shared service.
3. Run `python3 scripts/qa/qa.py snapshot --config /protected/qa.env`. It captures
both affected tables with JDBC metadata in a repeatable-read **read-only**
transaction, private WebRowSet XML and SHA256 manifest under `.qa-runtime/`.
These contain credentials/personal data from rows: never commit, print or upload.
This is an affected-table snapshot, **not a native full-database backup**.
4. Before executing DDL, the administrator must also produce an authorized native
Kingbase full QA backup (schema/data/sequences/ACLs) using the matching licensed
vendor tools and validate it in an approved isolated restore destination. No
native backup utility or restore destination is assumed or provisioned here.
Do not use a mismatched PostgreSQL client or restore into any shared database.
## Execution and failure rollback (not executed in this round without approval)
With an approved migration account, enforce `current_database()` equals the exact
target before any change. Begin a transaction, set a local lock timeout (5s),
capture prechange schema/index definitions and row counts, execute only migration
005, then verify the following **before commit**:
- All 16 new columns have the reviewed types/defaults/nullability; index definitions
match exactly; existing constraints/sequences and row counts are unchanged.
- Existing rows have `execution_mode='LEGACY'`, `intent_version=0`,
`execution_state='QUARANTINED'`, null intent key/environment/action/attempt time.
- No existing row is REAL, READY or eligible for automatic dispatch.
- A savepoint-based synthetic insert validates null uniqueness and a duplicate
nonnull intent key is rejected; roll back the synthetic savepoint. Sequence gaps
from rolled-back inserts are expected and must not be "repaired" by setval.
Any error: **ROLLBACK the entire DDL transaction**, inspect from a second connection
to prove original schema/counts/data fingerprints are unchanged. If actual Kingbase
DDL is not transactional, stop; never guess or issue automated DROP/CASCADE.
After commit, prefer keeping the additive schema and stopping the new application
over a destructive down migration. Rollback to old software is NOT automatic once
new lifecycle rows exist. Administrator-reviewed selective/native restore requires
fresh approval and a proven isolated restore; no generic destructive down.sql.
## Required acceptance after migration
Run production XML Mappers, real Services and `DataSourceTransactionManager` using
`fusion_qa_app`, with MQTT/face transport substitutes only. Persist synthetic QA
rows with unique run marker; preserve existing training rows and history. Cover
check-in/out intents, commit-before-command crash, never-attempted restart recovery,
unknown outcome -> MANUAL_REVIEW without resend, unique-intent concurrent claims,
late ACK vs new version, cross-transaction visibility/rollback. Preserve test rows
for audit or explicitly scoped cleanup only. H2 results cannot satisfy this gate.
+3 -1
View File
@@ -1,3 +1,5 @@
> Round 5V:本目录的全量初始化包仅为历史资料,本轮禁止重新执行。当前隔离启动入口是 `scripts/qa/qa.py`,运行账号为已存在的 `fusion_qa_app`;不据此新建账号或扩大权限。详见根目录 `DEMO_RUNBOOK.md`。
# RailwaySystem 融合测试数据库初始化包 (qa-bootstrap)
本目录为 **Round 3B-1 收口成果**,用于在全新空白 KingbaseES 数据库 `railwaysystem_fusion_qa_20261013` 中实现可重复、可审计、零破坏性、纯合成数据的闭环初始化。
@@ -43,7 +45,7 @@ qa-bootstrap/
| :--- | :--- | :--- | :--- |
| **建库管理员 (DBA)** | `kingbase` | 仅用于执行 `CREATE DATABASE railwaysystem_fusion_qa_20261013;` 并创建初始化角色 | 仅限 DBA 手动在受控会话中操作,脚本绝不硬编码 |
| **初始化专用账号** | `fusion_bootstrap_user` | 对该库拥有 `CONNECT`;对 `public` 和 `iotlocks` 拥有 `CREATE, USAGE` 及所有对象的 DDL/DML 权限 | 仅用于执行 `01` ~ `05b` 初始化脚本,初始化完成后可锁定或仅留归档 |
| **应用运行账号** | `fusion_app_user` | 对该库拥有 `CONNECT`;对 `public` 和 `iotlocks` 拥有 `USAGE`;对所有表拥有 `SELECT, INSERT, UPDATE, DELETE`;对所有序列拥有 `USAGE, SELECT, UPDATE` | **严禁赋予 CREATEDB、SUPERUSER**;**禁止拥有 Schema 的 CREATE 权限**;禁止任何跨库访问 |
| **应用运行账号** | `fusion_qa_app` | 对该库拥有 `CONNECT`;对 `public` 和 `iotlocks` 拥有 `USAGE`;对所有表拥有 `SELECT, INSERT, UPDATE, DELETE`;对所有序列拥有 `USAGE, SELECT, UPDATE` | **严禁赋予 CREATEDB、SUPERUSER**;**禁止拥有 Schema 的 CREATE 权限**;禁止任何跨库访问 |
---
@@ -14,8 +14,8 @@ spring:
# currentSchema 设置为 public,基准模式为培训与若依系统底座
url: jdbc:kingbase8://${DB_HOST:192.168.139.218}:${DB_PORT:4321}/railwaysystem_fusion_qa_20261013?currentSchema=public
# 2. 数据库运行账号: 生产与集成测试使用最小权限账号 fusion_app_user (杜绝使用超级用户 kingbase)
username: ${DB_APP_USER:fusion_app_user}
# 2. 数据库运行账号: 生产与集成测试使用最小权限账号 fusion_qa_app (杜绝使用超级用户 kingbase)
username: ${DB_APP_USER:fusion_qa_app}
password: ${DB_APP_PASSWORD:<YOUR_DB_APP_PASSWORD>}
# 3. 【核心配置】多模式 search_path 会话初始化注入:
+1 -1
View File
@@ -19,5 +19,5 @@ DB_BOOTSTRAP_USER=fusion_bootstrap_user
DB_BOOTSTRAP_PASSWORD=<YOUR_BOOTSTRAP_PASSWORD>
# 3. 应用最小权限运行账号 (仅 SELECT, INSERT, UPDATE, DELETE,无任何 DDL/CREATEDB 权限)
DB_APP_USER=fusion_app_user
DB_APP_USER=fusion_qa_app
DB_APP_PASSWORD=<YOUR_APP_PASSWORD>
+8
View File
@@ -0,0 +1,8 @@
# COPY outside Git, chmod 600. Literal KEY=value only, no shell expansion.
# Never reuse the old QA_DB_URL from another database.
FUSION_QA_DB_URL='jdbc:kingbase8://192.168.139.218:4321/railwaysystem_fusion_qa_20261013?connectTimeout=5&socketTimeout=15'
FUSION_QA_APP_PASSWORD='REPLACE_WITH_APPROVED_APP_PASSWORD'
TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY='REPLACE_WITH_APPROVED_QA_ENCRYPTION_KEY'
TRAIN_STUDENT_IDENTITY_HASH_KEY='REPLACE_WITH_APPROVED_QA_HASH_KEY'
# qa.py creates a separate 0600 runtime-secrets.env for this isolated instance.
# Admin login credentials are provisioned externally, not seeded or reset by these scripts.
+5 -5
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
readonly EXPECTED_SHA256="346d5602477ed472b19c5e25729fff5a58b1d83cec56162a56ac4b86d9546c32"
readonly INSTALLED_JAR="${HOME}/.m2/repository/com/kingbase/kingbase8/8.6.0/kingbase8-8.6.0.jar"
readonly EXPECTED_SHA256="e439bd1c48ce654748c4f333b874a61465702ceb18f29ee017c3f9bc47738c23"
readonly INSTALLED_JAR="${HOME}/.m2/repository/cn/com/kingbase/kingbase8/9.0.0/kingbase8-9.0.0.jar"
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
@@ -16,7 +16,7 @@ sha256_file() {
}
if [[ ! -f "${INSTALLED_JAR}" ]]; then
echo "Kingbase JDBC 8.6.0 is not provisioned." >&2
echo "Kingbase JDBC 9.0.0 is not provisioned." >&2
echo "Run scripts/bootstrap/install-kingbase-jdbc.sh" >&2
echo "with KINGBASE_JDBC_JAR pointing to the authorized vendor driver." >&2
exit 1
@@ -24,8 +24,8 @@ fi
installed_sha256="$(sha256_file "${INSTALLED_JAR}")"
if [[ "${installed_sha256}" != "${EXPECTED_SHA256}" ]]; then
echo "Kingbase JDBC 8.6.0 checksum mismatch." >&2
echo "Kingbase JDBC 9.0.0 checksum mismatch." >&2
exit 1
fi
echo "Kingbase JDBC 8.6.0 preflight PASS."
echo "Kingbase JDBC 9.0.0 preflight PASS."
+10 -10
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
readonly EXPECTED_SHA256="346d5602477ed472b19c5e25729fff5a58b1d83cec56162a56ac4b86d9546c32"
readonly EXPECTED_SHA256="e439bd1c48ce654748c4f333b874a61465702ceb18f29ee017c3f9bc47738c23"
readonly MAVEN_REPOSITORY="${HOME}/.m2/repository"
readonly INSTALLED_JAR="${MAVEN_REPOSITORY}/com/kingbase/kingbase8/8.6.0/kingbase8-8.6.0.jar"
readonly INSTALLED_JAR="${MAVEN_REPOSITORY}/cn/com/kingbase/kingbase8/9.0.0/kingbase8-9.0.0.jar"
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
@@ -18,7 +18,7 @@ sha256_file() {
if [[ -z "${KINGBASE_JDBC_JAR:-}" ]]; then
echo "KINGBASE_JDBC_JAR is not set." >&2
echo "Set it to the absolute path of the authorized Kingbase JDBC 8.6.0 vendor driver." >&2
echo "Set it to the absolute path of the authorized Kingbase JDBC 9.0.0 vendor driver." >&2
exit 1
fi
@@ -29,28 +29,28 @@ fi
source_sha256="$(sha256_file "${KINGBASE_JDBC_JAR}")"
if [[ "${source_sha256}" != "${EXPECTED_SHA256}" ]]; then
echo "Kingbase JDBC 8.6.0 source checksum mismatch." >&2
echo "Kingbase JDBC 9.0.0 source checksum mismatch." >&2
exit 1
fi
mvn install:install-file \
mvn org.apache.maven.plugins:maven-install-plugin:3.1.2:install-file \
-Dfile="${KINGBASE_JDBC_JAR}" \
-DgroupId=com.kingbase \
-DgroupId=cn.com.kingbase \
-DartifactId=kingbase8 \
-Dversion=8.6.0 \
-Dversion=9.0.0 \
-Dpackaging=jar \
-DgeneratePom=true \
-Dmaven.repo.local="${MAVEN_REPOSITORY}"
if [[ ! -f "${INSTALLED_JAR}" ]]; then
echo "Kingbase JDBC 8.6.0 installation failed: local Maven artifact is missing." >&2
echo "Kingbase JDBC 9.0.0 installation failed: local Maven artifact is missing." >&2
exit 1
fi
installed_sha256="$(sha256_file "${INSTALLED_JAR}")"
if [[ "${installed_sha256}" != "${EXPECTED_SHA256}" ]]; then
echo "Kingbase JDBC 8.6.0 installed artifact checksum mismatch." >&2
echo "Kingbase JDBC 9.0.0 installed artifact checksum mismatch." >&2
exit 1
fi
echo "Kingbase JDBC 8.6.0 provisioning PASS."
echo "Kingbase JDBC 9.0.0 provisioning PASS."
+87
View File
@@ -0,0 +1,87 @@
import java.sql.*;
import java.util.*;
import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermissions;
import javax.sql.rowset.*;
/** Read-only identity/schema probe. Credentials only from process environment. */
public class QaDatabaseProbe {
static final String DATABASE = "railwaysystem_fusion_qa_20261013";
static void query(Connection c, String label, String sql) throws SQLException {
System.out.println("[" + label + "]");
try (Statement s = c.createStatement()) {
s.setQueryTimeout(15);
try (ResultSet r = s.executeQuery(sql)) {
int n = r.getMetaData().getColumnCount();
while (r.next()) {
List<String> row = new ArrayList<>();
for (int i = 1; i <= n; i++) row.add(String.valueOf(r.getObject(i)));
System.out.println(String.join(" | ", row));
}
}
}
}
public static void main(String[] args) throws Exception {
String url = System.getenv("QA_DB_URL");
// Validate target before even opening a connection; no URL credentials permitted.
if (url == null || !url.matches("jdbc:kingbase8://[A-Za-z0-9.:-]+/" + DATABASE + "(?:\\?.*)?")
|| url.matches("(?i).*[?&](user|password)=.*"))
throw new SecurityException("Refusing non-target URL or URL credentials");
if (!"fusion_qa_app".equals(System.getenv("QA_DB_USER")))
throw new SecurityException("Read-only probe requires fusion_qa_app");
Class.forName("com.kingbase8.Driver");
Properties p = new Properties(); p.setProperty("user", System.getenv("QA_DB_USER"));
p.setProperty("password", Objects.requireNonNull(System.getenv("QA_DB_PASSWORD")));
try (Connection c = DriverManager.getConnection(url, p)) {
c.setAutoCommit(false);
c.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
try (Statement s = c.createStatement()) { s.execute("SET TRANSACTION READ ONLY"); }
try (Statement s = c.createStatement(); ResultSet r = s.executeQuery("SELECT current_database()")) {
r.next(); if (!DATABASE.equals(r.getString(1))) throw new SecurityException("Database mismatch");
}
query(c,"identity", "SELECT current_database(), current_user, current_schema(), version()");
query(c,"search_path", "SHOW search_path");
query(c,"schemas", "SELECT schema_name FROM information_schema.schemata ORDER BY schema_name");
query(c,"columns", "SELECT table_schema,table_name,column_name,data_type,is_nullable,column_default "
+ "FROM information_schema.columns WHERE (table_schema='iotlocks' AND table_name IN ('lock_auth','lock_command')) ORDER BY table_name,ordinal_position");
query(c,"constraints", "SELECT table_schema,table_name,constraint_name,constraint_type FROM information_schema.table_constraints "
+ "WHERE table_schema IN ('iotlocks','public') AND table_name IN ('lock_auth','lock_command') ORDER BY table_name,constraint_name");
query(c,"counts", "SELECT 'lock_auth',count(*) FROM iotlocks.lock_auth UNION ALL SELECT 'lock_command',count(*) FROM iotlocks.lock_command");
query(c,"indexes", "SELECT schemaname,tablename,indexname,indexdef FROM pg_indexes WHERE schemaname='iotlocks' AND tablename IN ('lock_auth','lock_command') ORDER BY tablename,indexname");
query(c,"runtime_account", "SELECT rolname,rolsuper,rolcreatedb,rolcreaterole FROM pg_roles WHERE rolname=current_user");
query(c,"schema_privileges", "SELECT nspname,has_schema_privilege(current_user,oid,'USAGE'),has_schema_privilege(current_user,oid,'CREATE') FROM pg_namespace WHERE nspname IN ('public','iotlocks') ORDER BY nspname");
query(c,"table_owners", "SELECT schemaname,tablename,tableowner FROM pg_tables WHERE schemaname IN ('public','iotlocks') AND tablename IN ('sys_user','lock_auth','lock_command') ORDER BY schemaname,tablename");
query(c,"table_grants", "SELECT table_schema,privilege_type,count(*) FROM information_schema.role_table_grants WHERE grantee=current_user AND table_schema IN ('public','iotlocks') GROUP BY table_schema,privilege_type ORDER BY table_schema,privilege_type");
query(c,"extra_grant_review", "SELECT table_schema,table_name,privilege_type FROM information_schema.role_table_grants WHERE grantee=current_user AND table_schema IN ('public','iotlocks') AND privilege_type IN ('TRUNCATE','TRIGGER','REFERENCES') ORDER BY table_schema,table_name,privilege_type");
query(c,"target_sessions", "SELECT usename,application_name,state,count(*) FROM pg_stat_activity WHERE datname=current_database() GROUP BY usename,application_name,state ORDER BY usename,application_name,state");
if (args.length == 2 && "--backup".equals(args[0])) {
Path directory = Paths.get(args[1]);
Files.createDirectory(directory, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------")));
for (String table : List.of("lock_auth", "lock_command")) {
Path destination = directory.resolve(table + ".xml");
Files.createFile(destination, PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")));
try (Statement s = c.createStatement(); ResultSet rows = s.executeQuery("SELECT * FROM iotlocks." + table + " ORDER BY 1");
WebRowSet backup = RowSetProvider.newFactory().createWebRowSet();
java.io.Writer writer = Files.newBufferedWriter(destination)) {
backup.populate(rows); backup.writeXml(writer);
System.out.println("PRIVATE_TABLE_SNAPSHOT " + table + " rows=" + backup.size());
}
}
System.out.println("TABLE_SNAPSHOT_ONLY: no native full-database dump or restore drill claimed");
}
if (Arrays.asList(args).contains("--require-migrated")) {
String common = "'execution_mode','environment_id','intent_key','intent_version','execution_state','intent_action','intent_at'";
try (Statement s = c.createStatement(); ResultSet r = s.executeQuery("SELECT count(*) FROM information_schema.columns WHERE table_schema='iotlocks' AND ((table_name='lock_auth' AND column_name IN (" + common + ")) OR (table_name='lock_command' AND column_name IN (" + common + ",'device_sn','attempt_started_at')))")) {
r.next(); if (r.getInt(1) != 16) throw new SecurityException("MIGRATION_REQUIRED: runtime refused");
}
try (Statement s = c.createStatement(); ResultSet r = s.executeQuery("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')")) {
r.next(); if (r.getInt(1) != 4) throw new SecurityException("MIGRATION_INDEXES_REQUIRED: runtime refused");
}
}
c.rollback(); System.out.println("READ_ONLY_ROLLBACK_COMPLETE");
} catch (SQLException e) {
// Server exceptions can contain values; expose SQLSTATE only.
System.err.println("QA_DATABASE_PROBE_FAILED SQLSTATE=" + e.getSQLState()); System.exit(2);
}
}
}
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""Local QA only. No DDL, hardware, account provisioning or shared-service management."""
import argparse
import base64
import hashlib
import json
import os
from pathlib import Path
import secrets
import shlex
import signal
import socket
import stat
import subprocess
import sys
import urllib.parse
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parents[2]
DATABASE = 'railwaysystem_fusion_qa_20261013'
RUNTIME = ROOT / '.qa-runtime'
JAR = Path.home() / '.m2/repository/cn/com/kingbase/kingbase8/9.0.0/kingbase8-9.0.0.jar'
def load_config(path):
path = path.expanduser()
info = path.stat()
if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o077 or not stat.S_ISREG(info.st_mode):
raise ValueError('Config must be an owner-only regular file (0600)')
values = {}
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
key, value = line.removeprefix('export ').split('=', 1)
tokens = shlex.split(value, comments=True)
if len(tokens) != 1:
raise ValueError('Config requires literal KEY=value; no shell expansion')
values[key] = tokens[0]
return values
def environment(config):
# Legacy QA_DB_URL deliberately NOT reused: it may point to a different QA database.
url = config.get('FUSION_QA_DB_URL', 'jdbc:kingbase8://192.168.139.218:4321/' + DATABASE + '?connectTimeout=5&socketTimeout=15')
parsed = urllib.parse.urlsplit(url.removeprefix('jdbc:'))
if parsed.scheme != 'kingbase8' or parsed.path != '/' + DATABASE or parsed.username or parsed.password or parsed.fragment:
raise ValueError('Refusing non-target database URL')
if any(k.lower() in ('user', 'password') for k in urllib.parse.parse_qs(parsed.query)):
raise ValueError('URL credentials forbidden')
password = config.get('FUSION_QA_APP_PASSWORD', '')
if not password:
raise ValueError('Missing FUSION_QA_APP_PASSWORD')
inherited = {key: value for key, value in os.environ.items()
if not key.startswith(('SPRING_', 'IOT_', 'DMS_', 'QA_', 'TOKEN_', 'TRAIN_'))
and key not in ['JAVA_TOOL_OPTIONS', 'JDK_JAVA_OPTIONS', '_JAVA_OPTIONS']}
return dict(inherited, QA_DB_URL=url, QA_DB_USER='fusion_qa_app', QA_DB_PASSWORD=password)
def verify_snapshot(directory):
directory = directory.resolve()
if not directory.is_relative_to(RUNTIME.resolve()):
raise ValueError('Snapshot must stay inside this task private runtime directory')
expected = json.loads((directory / 'sha256.json').read_text())
for name in ['lock_auth.xml', 'lock_command.xml']:
raw = (directory / name).read_bytes()
if hashlib.sha256(raw).hexdigest() != expected[name]:
raise ValueError('Snapshot checksum mismatch')
tree = ET.fromstring(raw)
rows = sum(1 for node in tree.iter() if node.tag.rsplit('}', 1)[-1] in ['currentRow', 'insertRow'])
print('SNAPSHOT_PARSE_SHA256_VERIFIED ' + name + ' rows=' + str(rows))
print('NATIVE_DATABASE_RESTORE_NOT_VERIFIED')
def private_directory():
RUNTIME.mkdir(mode=0o700, exist_ok=True)
if RUNTIME.is_symlink() or RUNTIME.stat().st_uid != os.getuid() or stat.S_IMODE(RUNTIME.stat().st_mode) & 0o077:
raise ValueError('Unsafe runtime directory')
def inspect(env, extra):
command = ['java', '-cp', str(JAR), str(ROOT / 'scripts/qa/QaDatabaseProbe.java'), *extra]
result = subprocess.run(command, env=env, text=True, capture_output=True, timeout=60)
private_directory()
path = RUNTIME / 'database-inspection.log'
with open(path, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as stream:
stream.write(result.stdout + result.stderr)
# Probe reports only metadata/counts; SQL errors are reduced to SQLSTATE.
print(result.stdout + result.stderr, end='')
return result.returncode
def available(port):
with socket.socket() as probe:
probe.bind(('127.0.0.1', port))
def launch(name, command, env):
private_directory()
ledger = RUNTIME / (name + '.json')
if ledger.exists():
raise ValueError('Runtime ledger exists; inspect/stop this task-owned process first')
log = open(RUNTIME / (name + '.log'), 'w', opener=lambda p, f: os.open(p, f, 0o600))
child = subprocess.Popen(command, cwd=ROOT, env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
log.close()
started = subprocess.run(['ps', '-p', str(child.pid), '-o', 'lstart='], text=True, capture_output=True).stdout.strip()
ledger.write_text(json.dumps({'pid': child.pid, 'command': command, 'cwd': str(ROOT), 'started': started}))
print(name + ' PID=' + str(child.pid) + ' LOG=' + str(RUNTIME / (name + '.log')))
def start(config, env, backend_port, redis_port):
if inspect(env, ['--require-migrated']):
raise ValueError('Backend not started: QA migration preflight failed')
available(backend_port)
available(redis_port)
private_directory()
secret_path = RUNTIME / 'runtime-secrets.env'
if not secret_path.exists():
with open(secret_path, 'x', opener=lambda p, f: os.open(p, f, 0o600)) as f:
f.write('TOKEN_SECRET=' + base64.b64encode(secrets.token_bytes(64)).decode() + '\n')
f.write('QA_REDIS_PASSWORD=' + secrets.token_urlsafe(40) + '\n')
local_secrets = load_config(secret_path)
env.update(local_secrets)
for key in ['TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY', 'TRAIN_STUDENT_IDENTITY_HASH_KEY']:
if not config.get(key):
raise ValueError('Missing required protected identity configuration')
env[key] = config[key]
env.update(SPRING_DATASOURCE_DRUID_MASTER_URL=env['QA_DB_URL'],
SPRING_DATASOURCE_DRUID_MASTER_USERNAME='fusion_qa_app',
SPRING_DATASOURCE_DRUID_MASTER_PASSWORD=env['QA_DB_PASSWORD'],
SPRING_DATA_REDIS_PASSWORD=env['QA_REDIS_PASSWORD'])
redis_config = RUNTIME / 'redis.conf'
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')
command = ['java', '-jar', str(ROOT / 'RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar'),
'--spring.profiles.active=druid',
'--spring.datasource.druid.master.url=' + env['QA_DB_URL'],
'--spring.datasource.druid.master.username=fusion_qa_app',
'--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.database=0', '--spring.quartz.auto-startup=false',
'--ruoyi.profile=' + str(RUNTIME / 'upload'), '--logging.file.path=' + str(RUNTIME / 'logs'),
'--logging.level.com.ruoyi=INFO', '--iot.mqtt.mock=true', '--iot.mqtt.enabled=false',
'--iot.mqtt.real-device-write-enabled=false', '--iot.mqtt.device-whitelist=',
'--iot.mqtt.environment-id=round5v-local-mock', '--iot.mqtt.retry-task-enabled=false',
'--iot.mqtt.protocol-verified=false', '--iot.qa-api-enabled=false', '--iot.openapi.enabled=false',
'--iot.face.mock=true', '--iot.face.enabled=false', '--iot.face.real-http-enabled=false',
'--iot.face.allowed-endpoints=', '--dms.enabled=false', '--dms.mock=true',
'--spring.datasource.druid.stat-view-servlet.enabled=false']
if not Path(command[2]).is_file():
raise ValueError('Build backend jar first')
launch('redis', ['redis-server', str(redis_config)], env)
launch('backend', command, env)
def stop():
private_directory()
for name in ['frontend', 'backend', 'redis']:
path = RUNTIME / (name + '.json')
if not path.exists():
continue
item = json.loads(path.read_text())
actual = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'command='], text=True, capture_output=True).stdout.strip()
if actual:
# macOS Python wrappers exec Python.app; Redis rewrites its process title.
# Check task arguments + cwd + birth time, never select by port/name alone.
suffix = ' '.join(item['command'][1:])
cwd = subprocess.run(['lsof', '-a', '-p', str(item['pid']), '-d', 'cwd', '-Fn'], text=True, capture_output=True).stdout
started = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'lstart='], text=True, capture_output=True).stdout.strip()
comm = subprocess.run(['ps', '-p', str(item['pid']), '-o', 'comm='], text=True, capture_output=True).stdout.strip()
identity = actual.endswith(suffix) and suffix != ''
if name == 'redis':
identity = bool(item.get('started')) and Path(comm).name.startswith('redis-server')
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)
os.kill(item['pid'], signal.SIGTERM)
path.unlink()
print('Stopped task-owned ' + name)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('action', choices=['inspect', 'snapshot', 'verify-snapshot', 'start', 'frontend', 'stop'])
parser.add_argument('--config', type=Path, default=Path.home() / '.railwaysystem-qa.env')
parser.add_argument('--backend-port', type=int, default=18096)
parser.add_argument('--frontend-port', type=int, default=18088)
parser.add_argument('--redis-port', type=int, default=16389)
parser.add_argument('--snapshot-dir', type=Path)
args = parser.parse_args()
if args.action == 'verify-snapshot':
if not args.snapshot_dir:
raise ValueError('Specify --snapshot-dir')
verify_snapshot(args.snapshot_dir)
return 0
if args.action == 'stop':
stop()
return 0
if args.action == 'frontend':
available(args.frontend_port)
launch('frontend', [sys.executable, str(ROOT / 'scripts/qa/serve_frontend.py'),
'--port', str(args.frontend_port), '--backend-port', str(args.backend_port)], os.environ.copy())
return 0
config = load_config(args.config)
env = environment(config)
if args.action == 'inspect':
return inspect(env, [])
if args.action == 'snapshot':
private_directory()
path = RUNTIME / ('snapshot-' + secrets.token_hex(6))
rc = inspect(env, ['--backup', str(path)])
if rc == 0:
hashes = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in path.glob('*.xml')}
(path / 'sha256.json').write_text(json.dumps(hashes, indent=2))
print('SNAPSHOT_DIRECTORY=' + str(path))
return rc
start(config, env, args.backend_port, args.redis_port)
return 0
if __name__ == '__main__':
try:
sys.exit(main())
except (ValueError, OSError, subprocess.TimeoutExpired) as error:
# Do not echo environment or configuration values.
print('QA_OPERATION_REFUSED: ' + (str(error) if isinstance(error, ValueError) else type(error).__name__), file=sys.stderr)
sys.exit(2)
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Serve production SPA and proxy only /prod-api to the task-owned loopback backend."""
import argparse
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
import urllib.error
import urllib.request
DIST = Path(__file__).resolve().parents[2] / 'build_ui/dist'
class Handler(SimpleHTTPRequestHandler):
backend_port = 18096
def proxy(self):
path = self.path[len('/prod-api'):]
if not path.startswith('/') or path.startswith('//'):
self.send_error(400)
return
headers = {k: v for k, v in self.headers.items()
if k.lower() not in ['host', 'connection', 'content-length', 'transfer-encoding']}
length = int(self.headers.get('Content-Length', '0'))
if length < 0 or length > 10 * 1024 * 1024:
self.send_error(413)
return
request = urllib.request.Request('http://127.0.0.1:' + str(self.backend_port) + path,
data=self.rfile.read(length) if length else None, headers=headers, method=self.command)
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
# Ignore all system/environment HTTP proxies, never follow a backend redirect.
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
try:
response = opener.open(request, timeout=30)
except urllib.error.HTTPError as error:
response = error
except (urllib.error.URLError, TimeoutError):
self.send_error(502, 'QA backend unavailable; no substituted API response')
return
with response:
data = response.read()
self.send_response(response.code)
for key, value in response.headers.items():
if key.lower() not in ['connection', 'transfer-encoding', 'content-length']:
self.send_header(key, value)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if self.path.startswith('/prod-api/'):
return self.proxy()
requested = self.path.split('?', 1)[0]
if requested != '/' and not (DIST / requested.lstrip('/')).is_file() and '.' not in requested.rsplit('/', 1)[-1]:
self.path = '/index.html'
return super().do_GET()
def do_POST(self):
return self.proxy() if self.path.startswith('/prod-api/') else self.send_error(405)
do_PUT = do_POST
do_DELETE = do_POST
def log_message(self, *args):
# No query strings, headers, payloads, tokens or personal identifiers in access logs.
pass
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--port', type=int, default=18088)
parser.add_argument('--backend-port', type=int, default=18096)
args = parser.parse_args()
if not (DIST / 'index.html').is_file():
raise SystemExit('Build frontend before serving')
Handler.backend_port = args.backend_port
ThreadingHTTPServer(('127.0.0.1', args.port), partial(Handler, directory=str(DIST))).serve_forever()
+106
View File
@@ -0,0 +1,106 @@
import importlib.util
import json
import os
from pathlib import Path
import stat
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import patch
spec = importlib.util.spec_from_file_location('qa', Path(__file__).with_name('qa.py'))
qa = importlib.util.module_from_spec(spec)
spec.loader.exec_module(qa)
class QaToolsTest(unittest.TestCase):
def test_defaults_never_use_old_database_url(self):
env = qa.environment({'QA_DB_URL': 'jdbc:kingbase8://localhost/training', 'FUSION_QA_APP_PASSWORD': 'synthetic'})
self.assertIn('/' + qa.DATABASE, env['QA_DB_URL'])
self.assertEqual('fusion_qa_app', env['QA_DB_USER'])
def test_wrong_database_rejected(self):
for database in ['training', 'iotlocks', 'railwaysystem_integrated_qa_20260916']:
with self.assertRaises(ValueError):
qa.environment({'FUSION_QA_DB_URL': 'jdbc:kingbase8://localhost/' + database, 'FUSION_QA_APP_PASSWORD': 'synthetic'})
def test_url_credentials_rejected(self):
for url in ['jdbc:kingbase8://user:secret@localhost/' + qa.DATABASE,
'jdbc:kingbase8://localhost/' + qa.DATABASE + '?password=synthetic']:
with self.assertRaises(ValueError):
qa.environment({'FUSION_QA_DB_URL': url, 'FUSION_QA_APP_PASSWORD': 'synthetic'})
def test_missing_password_rejected(self):
with self.assertRaises(ValueError):
qa.environment({})
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'}):
env = qa.environment({'FUSION_QA_APP_PASSWORD': 'synthetic'})
self.assertNotIn('SPRING_APPLICATION_JSON', env)
self.assertNotIn('IOT_MQTT_ENABLED', env)
self.assertNotIn('JAVA_TOOL_OPTIONS', env)
def test_private_config_required(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'config.env'
path.write_text('FUSION_QA_APP_PASSWORD=synthetic\n')
path.chmod(0o644)
with self.assertRaises(ValueError):
qa.load_config(path)
path.chmod(0o600)
self.assertEqual('synthetic', qa.load_config(path)['FUSION_QA_APP_PASSWORD'])
def test_config_does_not_execute_shell(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'config.env'
path.write_text("KEY='$(touch should-not-exist)'\n")
path.chmod(0o600)
self.assertEqual('$(touch should-not-exist)', qa.load_config(path)['KEY'])
def test_migration_preflight_prevents_all_processes(self):
with patch.object(qa, 'inspect', return_value=2), patch.object(qa, 'launch') as launch:
with self.assertRaises(ValueError):
qa.start({}, {}, 18096, 16389)
launch.assert_not_called()
def test_start_flags_and_secret_handling(self):
with tempfile.TemporaryDirectory() as directory, patch.object(qa, 'RUNTIME', Path(directory)), \
patch.object(qa, 'inspect', return_value=0), patch.object(qa, 'available'), patch.object(qa, 'launch') as launch:
config = {'FUSION_QA_APP_PASSWORD':'synthetic', 'TRAIN_STUDENT_IDENTITY_ENCRYPTION_KEY':'synthetic', 'TRAIN_STUDENT_IDENTITY_HASH_KEY':'synthetic'}
qa.start(config, qa.environment(config), 18096, 16389)
self.assertEqual(2,launch.call_count)
name, command, env = launch.call_args.args
self.assertEqual('backend',name)
for flag in ['--iot.mqtt.enabled=false','--iot.mqtt.mock=true', '--iot.mqtt.real-device-write-enabled=false',
'--iot.mqtt.device-whitelist=', '--iot.mqtt.retry-task-enabled=false', '--iot.qa-api-enabled=false',
'--iot.openapi.enabled=false','--iot.face.real-http-enabled=false','--dms.enabled=false',
'--spring.quartz.auto-startup=false','--spring.datasource.druid.master.username=fusion_qa_app']:
self.assertIn(flag,command)
self.assertNotIn(env['TOKEN_SECRET'],' '.join(command))
self.assertEqual(0o600,stat.S_IMODE((Path(directory)/'runtime-secrets.env').stat().st_mode))
def test_stop_rejects_reused_pid(self):
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:
(Path(directory)/'backend.json').write_text(json.dumps({'pid':123,'command':['task-owned-java']}))
run.return_value.stdout='unrelated-user-process'
with self.assertRaises(ValueError):
qa.stop()
kill.assert_not_called()
def test_stop_accepts_python_wrapper_only_with_task_identity(self):
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:
script = str(qa.ROOT / 'scripts/qa/serve_frontend.py')
command = ['/usr/local/bin/python3', script, '--port', '18088']
(Path(directory)/'frontend.json').write_text(json.dumps({'pid':123,'command':command,'started':'same-birth-time'}))
run.side_effect = [SimpleNamespace(stdout='/Frameworks/Python.app/Python ' + ' '.join(command[1:])),
SimpleNamespace(stdout='p123\nfcwd\nn' + str(qa.ROOT) + '\n'),
SimpleNamespace(stdout='same-birth-time'), SimpleNamespace(stdout='/Frameworks/Python.app/Python')]
qa.stop()
kill.assert_called_once_with(123,qa.signal.SIGTERM)
if __name__ == '__main__':
unittest.main()