fix: 收口融合 QA 构建与验收安全基线
This commit is contained in:
1 parent
47bc6604e5
commit
5560a3ac04
28 files changed
+22947
-352
No files matched your search
@@ -68,3 +68,9 @@ docs/backups/production_backup_*.sql
|
|||||||
docs/backups/*.dump
|
docs/backups/*.dump
|
||||||
docs/backups/*.backup
|
docs/backups/*.backup
|
||||||
docs/backups/*.sql
|
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
@@ -1,103 +1,37 @@
|
|||||||
stages:
|
# Validation only. No automatic push pipelines or shared deployment from fusion work.
|
||||||
- build_backend
|
workflow:
|
||||||
- build_frontend
|
rules:
|
||||||
- deploy
|
- 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:
|
build_backend:
|
||||||
stage: build_backend
|
stage: validate
|
||||||
tags:
|
tags: [railway-deploy]
|
||||||
- railway-deploy
|
|
||||||
rules:
|
|
||||||
- if: '$CI_COMMIT_BRANCH == "feat/cicd-pipeline"'
|
|
||||||
- when: never
|
|
||||||
script:
|
script:
|
||||||
- echo "=== 开始编译后端 RuoYi-Vue ==="
|
- test -n "$FUSION_JAVA21_HOME"
|
||||||
- export JAVA_HOME=/data2/wzb/tools/jdk8
|
- export JAVA_HOME="$FUSION_JAVA21_HOME"
|
||||||
- export PATH=/data2/wzb/tools/maven/bin:$JAVA_HOME/bin:$PATH
|
- export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
- javac -version
|
- java -version
|
||||||
|
- test "$(javac -version 2>&1 | cut -d' ' -f2 | cut -d. -f1)" = 21
|
||||||
- bash scripts/bootstrap/check-kingbase-jdbc.sh
|
- bash scripts/bootstrap/check-kingbase-jdbc.sh
|
||||||
- cd RuoYi-Vue
|
- 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 clean package
|
- python3 -m unittest discover -s scripts/qa -p test_qa_tools.py -v
|
||||||
- |
|
|
||||||
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 "=== 后端测试与构建成功 ==="
|
|
||||||
artifacts:
|
artifacts:
|
||||||
expire_in: 2 days
|
expire_in: 2 days
|
||||||
paths:
|
paths: [RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar]
|
||||||
- RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar
|
|
||||||
|
|
||||||
# 2. 编译打包前端
|
|
||||||
build_frontend:
|
build_frontend:
|
||||||
stage: build_frontend
|
stage: validate
|
||||||
tags:
|
tags: [railway-deploy]
|
||||||
- railway-deploy
|
|
||||||
rules:
|
|
||||||
- if: '$CI_COMMIT_BRANCH == "feat/cicd-pipeline"'
|
|
||||||
- when: never
|
|
||||||
script:
|
script:
|
||||||
- echo "=== 开始编译前端 build_ui ==="
|
|
||||||
- export NODE_OPTIONS="--openssl-legacy-provider --dns-result-order=ipv4first"
|
|
||||||
- cd build_ui
|
- cd build_ui
|
||||||
- if [ ! -d "node_modules" ] && [ -d "/data2/wzb/tools/frontend-cache/node_modules" ]; then
|
- test "$(node --version)" = v22.23.2
|
||||||
echo "使用服务器本地缓存的 node_modules";
|
- test "$(npm --version)" = 10.9.8
|
||||||
ln -sfn /data2/wzb/tools/frontend-cache/node_modules ./node_modules;
|
- npm ci --ignore-scripts --no-audit --no-fund
|
||||||
fi
|
|
||||||
- npm run build:prod
|
- npm run build:prod
|
||||||
- if [ ! -d "dist" ] || [ ! -f "dist/index.html" ]; then
|
- test -f dist/index.html
|
||||||
echo "错误:前端构建产物 build_ui/dist/index.html 不存在,构建失败";
|
|
||||||
exit 1;
|
|
||||||
fi
|
|
||||||
- echo "=== 前端编译成功,build_ui/dist/index.html 校验通过 ==="
|
|
||||||
artifacts:
|
artifacts:
|
||||||
expire_in: 2 days
|
expire_in: 2 days
|
||||||
paths:
|
paths: [build_ui/dist/]
|
||||||
- build_ui/dist/
|
# Deliberately no deploy job. Private JDBC provisioning is administrator-controlled.
|
||||||
|
|
||||||
# 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 "=== 部署与自检成功完成! ==="
|
|
||||||
+96
-130
@@ -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 协同授权中枢。
|
## 当前验收边界
|
||||||
|
|
||||||
### 核心演示原则
|
Round 5V 已核对目标 Kingbase 版本、低权限账号及迁移前表结构。005 尚未执行:
|
||||||
- **数据源隔离**:严格运行于独立研发 QA 数据库(`railwaysystem_fusion_qa_20261013`),采用最小权限应用账号连接,与共享试用库(`training`)及生产环境完全隔离。
|
`MIGRATION_WAITING_APPROVAL`。不能将 H2/替身测试、构建成功或登录页显示称为
|
||||||
- **仿真受控运行**:IoT 底层采用仿真受控模式,物理 MQTT 硬件与实体门锁处于安全隔离状态,防止误触发真实硬件动作。
|
Kingbase 事务/浏览器业务闭环验收。真实硬件验收始终未进行。
|
||||||
- **真实完整业务闭环**:前端 UI、后端业务逻辑、Kingbase 事务状态流转均为 100% 真实执行。
|
迁移前置、失败回滚和核验清单见 [迁移计划](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. 访问地址
|
从本工作树根目录执行(路径以实际 checkout 为准,不引用 RuoYi-Vue-master):
|
||||||
- **前端 Web 控制台**:`http://127.0.0.1:8088`
|
|
||||||
- **默认演示账号**:`admin` / `admin123`
|
|
||||||
- **后端 API 服务**:`http://127.0.0.1:8096`(由前端通过 `/prod-api` 自动反向代理)
|
|
||||||
|
|
||||||
### 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
|
```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
|
```bash
|
||||||
./stop_demo.sh
|
cd build_ui
|
||||||
```
|
node --version
|
||||||
**停止脚本自动完成:**
|
npm --version
|
||||||
1. 优雅停止前端反向代理进程(释放端口 8088);
|
npm ci --ignore-scripts --no-audit --no-fund
|
||||||
2. 优雅停止后端 Spring Boot 进程(释放端口 8096);
|
npm run build:prod
|
||||||
3. 清理 Headless Chrome 测试残留进程;
|
cd ..
|
||||||
4. 确认所有演示端口已完全释放。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、建议的五分钟核心业务演示路线
|
|
||||||
|
|
||||||
建议按照培训学员入宿、门锁授权、调宿换锁、退宿撤权的标准业务生命周期进行演示:
|
|
||||||
|
|
||||||
```
|
|
||||||
[1. 管理员登录]
|
|
||||||
↓
|
|
||||||
[2. 住宿管理:查询预分配学员] ──> 点击【入住】办理 ──> 状态流转为【在住】
|
|
||||||
↓
|
|
||||||
[3. 智能门锁:授权管理] ──> 查看自动生成的人脸授权意图 (待下发/待回执)
|
|
||||||
↓
|
|
||||||
[4. 仿真回执确认] ──> 受控驱动网关与门锁回执 ──> 页面刷新为【已生效/下发成功】
|
|
||||||
↓
|
|
||||||
[5. 住宿管理:办理调房] ──> 选择新宿舍 (致远楼 102) 提交 ──> 触发新门锁建权与旧门锁撤权
|
|
||||||
↓
|
|
||||||
[6. 住宿管理:办理退宿] ──> 点击【退宿】确认 ──> 状态更新为【已退宿】,授权彻底清除
|
|
||||||
↓
|
|
||||||
[7. 履历与流水核查] ──> 查看【住宿履历】对话框 & 【命令记录】指令流水
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 步骤详解:
|
不得用实验工程/node_modules1/node_modules2、服务器 symlink 或临时依赖目录替代
|
||||||
|
lockfile 安装。Quill 2 的转译配置与实验工程一致。Vue 2/CLI 4 的废弃依赖及旧版
|
||||||
|
node-ipc engine/ESLint peer 警告仍存在:本轮不做整套前端框架迁移,不声称无漏洞。
|
||||||
|
|
||||||
1. **登录系统**:
|
## 2. 凭据和只读身份检查
|
||||||
- 打开浏览器访问 `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)指令流水,证明全链路具备完整审计溯源能力。
|
|
||||||
|
|
||||||
---
|
将 `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 验证不等同于原生全库备份或数据库恢复演练。
|
||||||
|
本机未找到原生备份客户端,完整备份及回退演练由管理员按迁移计划准备。
|
||||||
|
|
||||||
| 业务环节 | 实现方式 | 状态说明 |
|
## 3. 启停隔离 QA(迁移批准并完成后)
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| **管理员鉴权与 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** |
|
|
||||||
|
|
||||||
---
|
```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 隧道未连接`
|
安全值强制为 MQTT Mock=true / enabled=false / real-device-write-enabled=false /
|
||||||
- **原因**:本地 14321 端口无法连接到 Kingbase 研发服务器。
|
whitelist空 / protocol-verified=false / retry-task-enabled=false;人脸 HTTP关闭;
|
||||||
- **排查方法**:
|
OpenAPI、QA危险接口、Druid控制台和Quartz自动启动关闭;旧DMS关闭。
|
||||||
1. 检查 SSH 隧道进程:`ps aux | grep 14321`;
|
继承环境中的 Spring/IoT/DMS/JVM 注入覆盖被过滤,数据库 URL/账号由命令行固定。
|
||||||
2. 重新建立隧道:`ssh -N -L 127.0.0.1:14321:192.168.139.218:4321 wzbmatebook`;
|
JWT和独立Redis密钥由安全随机数首次生成至 `.qa-runtime/runtime-secrets.env`,0600,
|
||||||
3. 验证端口连通:`nc -z 127.0.0.1 14321`。
|
以进程环境注入,不写源码/命令行;数据库及身份密钥仅取受保护配置文件。
|
||||||
|
不得为演示去打开真实门禁或危险接口。
|
||||||
|
|
||||||
### 2. 页面访问提示 502 Bad Gateway
|
```bash
|
||||||
- **原因**:后端 Spring Boot 正在启动中或由于数据库超时启动失败。
|
curl --noproxy '*' --fail http://127.0.0.1:18096/captchaImage
|
||||||
- **排查方法**:
|
# 真实 Chrome 访问 http://127.0.0.1:18088 ,使用管理员单独提供的 QA 账号。
|
||||||
1. 查看后端日志:`tail -n 50 backend.log`;
|
python3 scripts/qa/qa.py stop
|
||||||
2. 检查是否有 Druid 数据源连接超时异常;
|
```
|
||||||
3. 确认后执行 `./stop_demo.sh && ./start_demo.sh` 重启。
|
|
||||||
|
|
||||||
### 3. 演示过程中需要重置测试数据
|
stop只处理本脚本账本中且进程命令身份仍匹配的PID;不会清端口、杀其他Chrome、
|
||||||
- **方法**:
|
停止实验工程或修改系统网络。JWT、访问日志、快照、浏览器证据均不进入Git。
|
||||||
可直接在工程根目录下执行演示数据预置脚本:
|
|
||||||
```bash
|
## 4. 迁移后验收清单(未完成项不得标 PASS)
|
||||||
java -cp RuoYi-Vue-master/ruoyi-admin/target/test-classes:RuoYi-Vue-master/ruoyi-admin/target/ruoyi-admin.jar com.ruoyi.train.PrepareDemoData
|
|
||||||
```
|
1. 管理员真实登录、getInfo/getRouters、原品牌菜单和首页图表;培训核心查询。
|
||||||
该命令将自动重置学员 `S_DEMO_20261013` 为初始的“预分配”住宿状态,并清理门锁历史旧授权,无需重启系统即可开始下一轮完整演示。
|
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响应冒充新版本验收。
|
||||||
+5
-7
@@ -16,7 +16,7 @@
|
|||||||
<ruoyi.version>3.9.2</ruoyi.version>
|
<ruoyi.version>3.9.2</ruoyi.version>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
<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>
|
<spring-boot.version>4.1.0</spring-boot.version>
|
||||||
<mybatis-spring-boot.version>4.1.0</mybatis-spring-boot.version>
|
<mybatis-spring-boot.version>4.1.0</mybatis-spring-boot.version>
|
||||||
<druid.version>1.2.28</druid.version>
|
<druid.version>1.2.28</druid.version>
|
||||||
@@ -137,9 +137,8 @@
|
|||||||
<version>${kaptcha.version}</version>
|
<version>${kaptcha.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- KingbaseES(人大金仓)驱动:官方未提供公网 Maven 仓库,驱动包直接放置在
|
<!-- Approved vendor JDBC artifact: privately provision cn.com.kingbase:kingbase8.
|
||||||
ruoyi-admin/lib 目录,由 ruoyi-admin 模块的 kingbase profile 以 system scope 引入。
|
See scripts/bootstrap/install-kingbase-jdbc.sh; no vendor binary in Git. -->
|
||||||
若 jar 文件名或版本变更,请同步修改 kingbase.version 属性及 ruoyi-admin 中的 systemPath。 -->
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.com.kingbase</groupId>
|
<groupId>cn.com.kingbase</groupId>
|
||||||
<artifactId>kingbase8</artifactId>
|
<artifactId>kingbase8</artifactId>
|
||||||
@@ -224,8 +223,7 @@
|
|||||||
<version>3.13.0</version>
|
<version>3.13.0</version>
|
||||||
<configuration>
|
<configuration>
|
||||||
<parameters>true</parameters>
|
<parameters>true</parameters>
|
||||||
<source>${java.version}</source>
|
<release>${java.version}</release>
|
||||||
<target>${java.version}</target>
|
|
||||||
<encoding>${project.build.sourceEncoding}</encoding>
|
<encoding>${project.build.sourceEncoding}</encoding>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
@@ -262,4 +260,4 @@
|
|||||||
</pluginRepository>
|
</pluginRepository>
|
||||||
</pluginRepositories>
|
</pluginRepositories>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
</dependencies>
|
</dependencies>
|
||||||
</profile>
|
</profile>
|
||||||
|
|
||||||
<!-- KingbaseES(人大金仓):驱动包置于 ruoyi-admin/lib 目录,直接按文件路径引入 -->
|
<!-- KingbaseES: provision approved cn.com.kingbase:kingbase8 privately before build. -->
|
||||||
<profile>
|
<profile>
|
||||||
<id>kingbase</id>
|
<id>kingbase</id>
|
||||||
<activation>
|
<activation>
|
||||||
@@ -104,31 +104,8 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.com.kingbase</groupId>
|
<groupId>cn.com.kingbase</groupId>
|
||||||
<artifactId>kingbase8</artifactId>
|
<artifactId>kingbase8</artifactId>
|
||||||
<scope>system</scope>
|
|
||||||
<systemPath>${project.basedir}/lib/kingbase8-${kingbase.version}.jar</systemPath>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</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>
|
</profile>
|
||||||
</profiles>
|
</profiles>
|
||||||
|
|
||||||
@@ -139,8 +116,6 @@
|
|||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
<configuration>
|
<configuration>
|
||||||
<addResources>true</addResources>
|
<addResources>true</addResources>
|
||||||
<!-- KingbaseES 等 system scope 驱动需要打进可执行 jar -->
|
|
||||||
<includeSystemScope>true</includeSystemScope>
|
|
||||||
</configuration>
|
</configuration>
|
||||||
<executions>
|
<executions>
|
||||||
<execution>
|
<execution>
|
||||||
@@ -163,4 +138,4 @@
|
|||||||
<finalName>${project.artifactId}</finalName>
|
<finalName>${project.artifactId}</finalName>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
@@ -7,18 +7,6 @@
|
|||||||
<groupId>com.ruoyi</groupId>
|
<groupId>com.ruoyi</groupId>
|
||||||
<version>3.9.2</version>
|
<version>3.9.2</version>
|
||||||
</parent>
|
</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>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<artifactId>ruoyi-common</artifactId>
|
<artifactId>ruoyi-common</artifactId>
|
||||||
@@ -132,4 +120,4 @@
|
|||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
@@ -7,18 +7,6 @@
|
|||||||
<groupId>com.ruoyi</groupId>
|
<groupId>com.ruoyi</groupId>
|
||||||
<version>3.9.2</version>
|
<version>3.9.2</version>
|
||||||
</parent>
|
</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>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<artifactId>ruoyi-iot</artifactId>
|
<artifactId>ruoyi-iot</artifactId>
|
||||||
|
|||||||
+7
-24
@@ -224,14 +224,8 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
|
|||||||
boolean isTransferInsert = accommodation.getRemark() != null && accommodation.getRemark().contains("调房迁入");
|
boolean isTransferInsert = accommodation.getRemark() != null && accommodation.getRemark().contains("调房迁入");
|
||||||
if (!isTransferInsert && TrainAccommodation.STATUS_CHECKED_IN.equals(accommodation.getAccommodationStatus()))
|
if (!isTransferInsert && TrainAccommodation.STATUS_CHECKED_IN.equals(accommodation.getAccommodationStatus()))
|
||||||
{
|
{
|
||||||
try
|
// Intent persistence failure must abort the surrounding accommodation transaction.
|
||||||
{
|
iotLockAuthBridgeService.reconcileCheckin(accommodation.getAccommodationId(), accommodation.getCreateBy());
|
||||||
iotLockAuthBridgeService.reconcileCheckin(accommodation.getAccommodationId(), accommodation.getCreateBy());
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
log.warn("IoT lock auth reconcile on checkin failed for accommodation {}: {}", accommodation.getAccommodationId(), ex.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (accessAuthorizationOrchestrator != null)
|
else if (accessAuthorizationOrchestrator != null)
|
||||||
@@ -302,6 +296,7 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
|
|||||||
* @return 结果
|
* @return 结果
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public int checkIn(Long accommodationId, String operator)
|
public int checkIn(Long accommodationId, String operator)
|
||||||
{
|
{
|
||||||
TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId);
|
TrainAccommodation exist = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId);
|
||||||
@@ -320,14 +315,8 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
|
|||||||
boolean useIot = "iot".equalsIgnoreCase(authEngine) && iotLockAuthBridgeService != null;
|
boolean useIot = "iot".equalsIgnoreCase(authEngine) && iotLockAuthBridgeService != null;
|
||||||
if (useIot)
|
if (useIot)
|
||||||
{
|
{
|
||||||
try
|
// Intent persistence failure must abort the surrounding accommodation transaction.
|
||||||
{
|
iotLockAuthBridgeService.reconcileCheckin(accommodationId, operator);
|
||||||
iotLockAuthBridgeService.reconcileCheckin(accommodationId, operator);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
log.warn("IoT lock auth reconcile on checkIn failed for accommodation {}: {}", accommodationId, ex.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else if (accessAuthorizationOrchestrator != null)
|
else if (accessAuthorizationOrchestrator != null)
|
||||||
{
|
{
|
||||||
@@ -457,14 +446,8 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
|
|||||||
|
|
||||||
if ("iot".equalsIgnoreCase(authEngine) && iotLockAuthBridgeService != null)
|
if ("iot".equalsIgnoreCase(authEngine) && iotLockAuthBridgeService != null)
|
||||||
{
|
{
|
||||||
try
|
// Intent persistence failure must abort the surrounding accommodation transaction.
|
||||||
{
|
iotLockAuthBridgeService.reconcileTransfer(oldAccommodationId, newAcc.getAccommodationId(), operator);
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return newAcc;
|
return newAcc;
|
||||||
|
|||||||
+59
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,5 +19,4 @@ selenium-debug.log
|
|||||||
*.sln
|
*.sln
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
package-lock.json
|
|
||||||
yarn.lock
|
yarn.lock
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
registry=https://registry.npmjs.org/
|
||||||
|
save-exact=true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
22.23.2
|
||||||
Generated
+22021
File diff suppressed because it is too large.
Load diff
+28
-27
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ruoyi",
|
"name": "ruoyi",
|
||||||
"version": "3.8.5",
|
"version": "3.9.2",
|
||||||
"description": "SYFJGYD学员管理系统",
|
"description": "SYFJGYD学员管理系统",
|
||||||
"author": "若依",
|
"author": "若依",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -36,6 +36,28 @@
|
|||||||
"url": "https://gitee.com/y_project/RuoYi-Vue.git"
|
"url": "https://gitee.com/y_project/RuoYi-Vue.git"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/g6": "^4.8.17",
|
||||||
"@antv/x6": "^2.11.1",
|
"@antv/x6": "^2.11.1",
|
||||||
"@antv/x6-plugin-export": "^2.1.6",
|
"@antv/x6-plugin-export": "^2.1.6",
|
||||||
@@ -43,37 +65,15 @@
|
|||||||
"@antv/x6-plugin-selection": "^2.2.2",
|
"@antv/x6-plugin-selection": "^2.2.2",
|
||||||
"@antv/x6-vue-shape": "^2.1.2",
|
"@antv/x6-vue-shape": "^2.1.2",
|
||||||
"@handsontable/vue": "^13.0.0",
|
"@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",
|
"dayjs": "^1.11.7",
|
||||||
"echarts": "5.4.0",
|
|
||||||
"element-ui": "2.15.12",
|
|
||||||
"file-saver": "2.0.5",
|
|
||||||
"file-server": "^2.2.1",
|
"file-server": "^2.2.1",
|
||||||
"fuse.js": "6.4.3",
|
|
||||||
"handsontable": "^13.0.0",
|
"handsontable": "^13.0.0",
|
||||||
"highlight.js": "9.18.5",
|
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"jquery": "^3.7.0",
|
"jquery": "^3.7.0",
|
||||||
"jquery-mousewheel": "^3.1.13",
|
"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",
|
"mousetrap": "^1.6.5",
|
||||||
"nprogress": "0.2.0",
|
|
||||||
"papaparse": "^5.4.1",
|
"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-meta": "2.4.0",
|
||||||
"vue-router": "3.4.9",
|
|
||||||
"vuedraggable": "2.24.3",
|
|
||||||
"vuex": "3.6.0",
|
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"xlsx-style": "^0.8.13"
|
"xlsx-style": "^0.8.13"
|
||||||
},
|
},
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
"babel-eslint": "10.1.0",
|
"babel-eslint": "10.1.0",
|
||||||
"babel-plugin-dynamic-import-node": "2.3.3",
|
"babel-plugin-dynamic-import-node": "2.3.3",
|
||||||
"chalk": "4.1.0",
|
"chalk": "4.1.0",
|
||||||
"compression-webpack-plugin": "5.0.2",
|
"compression-webpack-plugin": "6.1.2",
|
||||||
"connect": "3.6.6",
|
"connect": "3.6.6",
|
||||||
"eslint": "7.15.0",
|
"eslint": "7.15.0",
|
||||||
"eslint-plugin-vue": "7.2.0",
|
"eslint-plugin-vue": "7.2.0",
|
||||||
@@ -98,11 +98,12 @@
|
|||||||
"vue-template-compiler": "2.6.12"
|
"vue-template-compiler": "2.6.12"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.9",
|
"node": ">=22.23.2 <23",
|
||||||
"npm": ">= 3.0.0"
|
"npm": ">=10.9.8 <11"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"> 1%",
|
"> 1%",
|
||||||
"last 2 versions"
|
"last 2 versions"
|
||||||
]
|
],
|
||||||
|
"packageManager": "npm@10.9.8"
|
||||||
}
|
}
|
||||||
@@ -75,8 +75,8 @@ export default {
|
|||||||
footerContent: defaultSettings.footerContent,
|
footerContent: defaultSettings.footerContent,
|
||||||
codeUrl: "",
|
codeUrl: "",
|
||||||
loginForm: {
|
loginForm: {
|
||||||
username: "admin",
|
username: "",
|
||||||
password: "admin123",
|
password: "",
|
||||||
rememberMe: false,
|
rememberMe: false,
|
||||||
code: "",
|
code: "",
|
||||||
uuid: ""
|
uuid: ""
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ module.exports = {
|
|||||||
lintOnSave: process.env.NODE_ENV === 'development',
|
lintOnSave: process.env.NODE_ENV === 'development',
|
||||||
// 如果你不需要生产环境的 source map,可以将其设置为 false 以加速生产环境构建。
|
// 如果你不需要生产环境的 source map,可以将其设置为 false 以加速生产环境构建。
|
||||||
productionSourceMap: false,
|
productionSourceMap: false,
|
||||||
|
// Quill 2 ships class fields; match the tested fusion build under Webpack 4.
|
||||||
|
transpileDependencies: ['quill'],
|
||||||
// webpack-dev-server 相关配置
|
// webpack-dev-server 相关配置
|
||||||
|
|
||||||
devServer: {
|
devServer: {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
> Round 5V:本目录的全量初始化包仅为历史资料,本轮禁止重新执行。当前隔离启动入口是 `scripts/qa/qa.py`,运行账号为已存在的 `fusion_qa_app`;不据此新建账号或扩大权限。详见根目录 `DEMO_RUNBOOK.md`。
|
||||||
|
|
||||||
# RailwaySystem 融合测试数据库初始化包 (qa-bootstrap)
|
# RailwaySystem 融合测试数据库初始化包 (qa-bootstrap)
|
||||||
|
|
||||||
本目录为 **Round 3B-1 收口成果**,用于在全新空白 KingbaseES 数据库 `railwaysystem_fusion_qa_20261013` 中实现可重复、可审计、零破坏性、纯合成数据的闭环初始化。
|
本目录为 **Round 3B-1 收口成果**,用于在全新空白 KingbaseES 数据库 `railwaysystem_fusion_qa_20261013` 中实现可重复、可审计、零破坏性、纯合成数据的闭环初始化。
|
||||||
@@ -43,7 +45,7 @@ qa-bootstrap/
|
|||||||
| :--- | :--- | :--- | :--- |
|
| :--- | :--- | :--- | :--- |
|
||||||
| **建库管理员 (DBA)** | `kingbase` | 仅用于执行 `CREATE DATABASE railwaysystem_fusion_qa_20261013;` 并创建初始化角色 | 仅限 DBA 手动在受控会话中操作,脚本绝不硬编码 |
|
| **建库管理员 (DBA)** | `kingbase` | 仅用于执行 `CREATE DATABASE railwaysystem_fusion_qa_20261013;` 并创建初始化角色 | 仅限 DBA 手动在受控会话中操作,脚本绝不硬编码 |
|
||||||
| **初始化专用账号** | `fusion_bootstrap_user` | 对该库拥有 `CONNECT`;对 `public` 和 `iotlocks` 拥有 `CREATE, USAGE` 及所有对象的 DDL/DML 权限 | 仅用于执行 `01` ~ `05b` 初始化脚本,初始化完成后可锁定或仅留归档 |
|
| **初始化专用账号** | `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,基准模式为培训与若依系统底座
|
# currentSchema 设置为 public,基准模式为培训与若依系统底座
|
||||||
url: jdbc:kingbase8://${DB_HOST:192.168.139.218}:${DB_PORT:4321}/railwaysystem_fusion_qa_20261013?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)
|
# 2. 数据库运行账号: 生产与集成测试使用最小权限账号 fusion_qa_app (杜绝使用超级用户 kingbase)
|
||||||
username: ${DB_APP_USER:fusion_app_user}
|
username: ${DB_APP_USER:fusion_qa_app}
|
||||||
password: ${DB_APP_PASSWORD:<YOUR_DB_APP_PASSWORD>}
|
password: ${DB_APP_PASSWORD:<YOUR_DB_APP_PASSWORD>}
|
||||||
|
|
||||||
# 3. 【核心配置】多模式 search_path 会话初始化注入:
|
# 3. 【核心配置】多模式 search_path 会话初始化注入:
|
||||||
|
|||||||
@@ -19,5 +19,5 @@ DB_BOOTSTRAP_USER=fusion_bootstrap_user
|
|||||||
DB_BOOTSTRAP_PASSWORD=<YOUR_BOOTSTRAP_PASSWORD>
|
DB_BOOTSTRAP_PASSWORD=<YOUR_BOOTSTRAP_PASSWORD>
|
||||||
|
|
||||||
# 3. 应用最小权限运行账号 (仅 SELECT, INSERT, UPDATE, DELETE,无任何 DDL/CREATEDB 权限)
|
# 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>
|
DB_APP_PASSWORD=<YOUR_APP_PASSWORD>
|
||||||
@@ -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.
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
readonly EXPECTED_SHA256="346d5602477ed472b19c5e25729fff5a58b1d83cec56162a56ac4b86d9546c32"
|
readonly EXPECTED_SHA256="e439bd1c48ce654748c4f333b874a61465702ceb18f29ee017c3f9bc47738c23"
|
||||||
readonly INSTALLED_JAR="${HOME}/.m2/repository/com/kingbase/kingbase8/8.6.0/kingbase8-8.6.0.jar"
|
readonly INSTALLED_JAR="${HOME}/.m2/repository/cn/com/kingbase/kingbase8/9.0.0/kingbase8-9.0.0.jar"
|
||||||
|
|
||||||
sha256_file() {
|
sha256_file() {
|
||||||
if command -v sha256sum >/dev/null 2>&1; then
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
@@ -16,7 +16,7 @@ sha256_file() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [[ ! -f "${INSTALLED_JAR}" ]]; then
|
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 "Run scripts/bootstrap/install-kingbase-jdbc.sh" >&2
|
||||||
echo "with KINGBASE_JDBC_JAR pointing to the authorized vendor driver." >&2
|
echo "with KINGBASE_JDBC_JAR pointing to the authorized vendor driver." >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -24,8 +24,8 @@ fi
|
|||||||
|
|
||||||
installed_sha256="$(sha256_file "${INSTALLED_JAR}")"
|
installed_sha256="$(sha256_file "${INSTALLED_JAR}")"
|
||||||
if [[ "${installed_sha256}" != "${EXPECTED_SHA256}" ]]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Kingbase JDBC 8.6.0 preflight PASS."
|
echo "Kingbase JDBC 9.0.0 preflight PASS."
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
readonly EXPECTED_SHA256="346d5602477ed472b19c5e25729fff5a58b1d83cec56162a56ac4b86d9546c32"
|
readonly EXPECTED_SHA256="e439bd1c48ce654748c4f333b874a61465702ceb18f29ee017c3f9bc47738c23"
|
||||||
readonly MAVEN_REPOSITORY="${HOME}/.m2/repository"
|
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() {
|
sha256_file() {
|
||||||
if command -v sha256sum >/dev/null 2>&1; then
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
@@ -18,7 +18,7 @@ sha256_file() {
|
|||||||
|
|
||||||
if [[ -z "${KINGBASE_JDBC_JAR:-}" ]]; then
|
if [[ -z "${KINGBASE_JDBC_JAR:-}" ]]; then
|
||||||
echo "KINGBASE_JDBC_JAR is not set." >&2
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -29,28 +29,28 @@ fi
|
|||||||
|
|
||||||
source_sha256="$(sha256_file "${KINGBASE_JDBC_JAR}")"
|
source_sha256="$(sha256_file "${KINGBASE_JDBC_JAR}")"
|
||||||
if [[ "${source_sha256}" != "${EXPECTED_SHA256}" ]]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mvn install:install-file \
|
mvn org.apache.maven.plugins:maven-install-plugin:3.1.2:install-file \
|
||||||
-Dfile="${KINGBASE_JDBC_JAR}" \
|
-Dfile="${KINGBASE_JDBC_JAR}" \
|
||||||
-DgroupId=com.kingbase \
|
-DgroupId=cn.com.kingbase \
|
||||||
-DartifactId=kingbase8 \
|
-DartifactId=kingbase8 \
|
||||||
-Dversion=8.6.0 \
|
-Dversion=9.0.0 \
|
||||||
-Dpackaging=jar \
|
-Dpackaging=jar \
|
||||||
-DgeneratePom=true \
|
-DgeneratePom=true \
|
||||||
-Dmaven.repo.local="${MAVEN_REPOSITORY}"
|
-Dmaven.repo.local="${MAVEN_REPOSITORY}"
|
||||||
|
|
||||||
if [[ ! -f "${INSTALLED_JAR}" ]]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
installed_sha256="$(sha256_file "${INSTALLED_JAR}")"
|
installed_sha256="$(sha256_file "${INSTALLED_JAR}")"
|
||||||
if [[ "${installed_sha256}" != "${EXPECTED_SHA256}" ]]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Kingbase JDBC 8.6.0 provisioning PASS."
|
echo "Kingbase JDBC 9.0.0 provisioning PASS."
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user