feat: improve datasource query audit workflows

This commit is contained in:
Logan committed 2026-07-06 23:01:04 +08:00
1 parent 74748e4914
commit 644981eef4
32 files changed
+715 -2996

No files matched your search

-198
View File
@@ -1,198 +0,0 @@
# SQL平台代码v1 优化包对比报告
> 分析日期: 2026-05-23
> 分析对象: ~/Downloads/SQL平台代码v1/ (optimized + final)
> 当前项目: ~/devops/sql-platform/
---
## 一、优化包概览
优化包包含两个版本:
- **sql-platform-optimized**: 中间优化版本(前 3 个 Stage 的产出)
- **sql-platform-final**: 最终版本(在 optimized 基础上新增数据库审核规则拆分)
两者基于 4 阶段优化计划(plan.md):
1. **Stage 1** — 后端关键缺陷修复
2. **Stage 2** — 前端界面全方位优化(4 组并行)
3. **Stage 3** — 权限逻辑 & 安全增强
4. **Stage 4** — 构建验证 & 最终集成
---
## 二、项目评估摘要(来自 PROJECT_EVALUATION.md)
| 维度 | 评分 | 说明 |
|------|------|------|
| 功能完成度 | 8.5/10 | 核心功能全覆盖:工单全生命周期、SQL审核引擎、规则CRUD、审计日志 |
| 代码质量 | 8.5/10 | TDD驱动、128测试、模块化清晰 |
| 安全性 | 9.0/10 | AES加密、bcrypt、JWT、AST防注入、权限矩阵 |
| 测试覆盖 | 8.5/10 | 128 tests 覆盖11模块 |
| 生产就绪度 | 8.0/10 | Docker部署、Alembic迁移、GitLab CI/CD |
| 用户体验 | 7.5/10 | 缺国际化、深色模式 |
| **综合评分** | **8.1/10** | 目标 9.0+ |
---
## 三、路线图优化项(来自 ROADMAP.md)
### 已完成的优化
| 分类 | 优化项 | 规模 |
|------|--------|------|
| **P0 数据库全景支持** | 统一查询服务 (MySQL/PG/Redis/MongoDB) | query_service.py 完整4种Handler |
| | Redis 只读命令白名单 | 40+ 只读命令 + 子命令验证 |
| | MongoDB 只读操作白名单 | find/findOne/countDocuments/distinct/aggregate |
| | 工单执行多数据库分发 | ticket_executor.py 多DB执行 |
| | MongoDB 连接器 + 前端管理页面 | mongo_connector.py 780行 |
| **P1 审计闭环** | 查询历史服务 | query_history_service.py |
| | SQL 助手 | sql_assistant.py (格式化/风险高亮/EXPLAIN) |
| | 审计规则种子 | MySQL 18规则 + PG 12规则 + Redis 8规则 |
| | 影响分析 | impact_analyzer.py |
| | 回滚生成 | rollback_generator.py |
| | Schema 漂移检测 | schema_drift_detector.py |
| **P2 高级功能** | 数据脱敏 | data_masker.py + masking_rule CRUD |
| | 通知服务 | 钉钉/飞书/邮件 SMTP |
| | 备份管理 | backup_executor.py |
| | 工作流引擎 | workflow_engine.py + workflow_seeds.py |
| | 慢日志收集 | slow_log_collector.py |
| | 连接池 | connection_pool.py |
### 待完成项
- [ ] 执行前后数据快照对比可视化(前端)
- [ ] 快捷查询模板(10个预置 + 自定义共享)
- [ ] SQL 格式化前端集成
- [ ] 查询结果格式化展示优化
- [ ] SQL 语法高亮编辑器
- [ ] Slow query 前端集成
- [ ] Dashboard 数据源健康概览
---
## 四、版本差异分析
### 4.1 optimized vs final 差异
**final 在 optimized 基础上新增了 4 个数据库专用审核规则文件:**
| 新增文件 | 行数 | 规则数量 | 覆盖范围 |
|----------|------|----------|----------|
| `mysql_audit_rules.py` | 2,101 行 | 63条 | DDL 36条 + DML 9条 + 命名 5条 + 性能 5条 + 安全 5条 |
| `redis_audit_rules.py` | 1,863 行 | 43条 | KEY管理10条 + 命令安全12条 + 数据结构8条 + 性能8条 + 安全5条 |
| `mongodb_audit_rules.py` | 1,638 行 | 42条 | 集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能 |
| `postgres_audit_rules.py` | 1,289 行 | 45条 | DDL 18条 + DML 8条 + DQL 6条 + 安全6条 + 性能7条 |
**final 还修改了 5 个关联文件:**
- `audit_engine_v2.py` — 新增 4 个 DB 规则模块的 import,规则数量从 100+ 升级到 200+
- `audit_rule_seeds.py` — 新增 MongoDB 种子规则 + 别名映射
- `audit_rule.py` (endpoint) — 新增数据库类型别名支持(postgresql→postgres, mongo→mongodb)
- `AuditRulesPage.tsx` — 前端新增 MongoDB/Redis 相关关键词识别
### 4.2 optimized vs 当前项目差异
**文件结构:完全一致**(无新增/缺失文件,仅 `__pycache__` 差异)
**内容差异:共 25 个文件有改动**
#### 后端 (9 个文件)
| 文件 | 改动行数 | 改动性质 |
|------|----------|----------|
| `services/workflow_engine.py` | 1,284 | 工作流引擎大幅扩展 |
| `services/query_service.py` | 720 | 查询服务优化/增强 |
| `services/audit_engine.py` | 858 | 审核引擎 V1 优化 |
| `services/data_masker.py` | 668 | 数据脱敏模块增强 |
| `services/ticket_executor.py` | 530 | 工单执行器改进 |
| `models/workflow.py` | 331 | 工作流模型扩展 |
| `models/user.py` | 278 | 用户模型扩展 |
| `endpoints/ticket.py` | 540 | 工单 API 改进 |
| `endpoints/query.py` | 256 | 查询 API 改进 |
#### 前端 (16 个文件)
| 文件 | 改动行数 | 改动性质 |
|------|----------|----------|
| `pages/InstancePage.tsx` | 1,372 | 实例管理页大幅优化 |
| `pages/WorkflowPage.tsx` | 971 | 工作流页面优化 |
| `pages/TicketsPage.tsx` | 960 | 工单列表页优化 |
| `pages/TicketCreatePage.tsx` | 868 | 工单创建页优化 |
| `pages/UsersPage.tsx` | 804 | 用户管理页优化 |
| `pages/SlowQueryPage.tsx` | 703 | 慢查询页优化 |
| `pages/TicketDetailPage.tsx` | 719 | 工单详情页优化 |
| `pages/DataDictionaryPage.tsx` | 650 | 数据字典页优化 |
| `pages/Dashboard.tsx` | 636 | 仪表盘优化 |
| `pages/InstanceGroupPage.tsx` | 631 | 实例组页优化 |
| `pages/AuditLogsPage.tsx` | 600 | 审计日志页优化 |
| `pages/QueriesPage.tsx` | 601 | 查询页优化 |
| `pages/AuditRulesPage.tsx` | 584 | 审核规则页优化 |
| `pages/RolesPage.tsx` | 519 | 角色管理页优化 |
| `hooks/useAuth.tsx` | 391 | 认证 hook 优化 |
| `components/SqlEditor.tsx` | 3 | SQL编辑器微调 |
---
## 五、对我们当前项目有价值的改动
### 🔴 高价值(建议立即采纳)
1. **数据库专用审核规则(final 独有)** — 193条数据库专用规则
- `mysql_audit_rules.py` (63条) — 生产级 MySQL 审核覆盖
- `redis_audit_rules.py` (43条) — Redis 命令安全检测
- `mongodb_audit_rules.py` (42条) — MongoDB 操作审核
- `postgres_audit_rules.py` (45条) — PostgreSQL 特有语法检查
- 关联修改:`audit_engine_v2.py`, `audit_rule_seeds.py`, `audit_rule.py`
2. **前端页面全面优化** — 16 个页面的 UI/UX 改进
- 所有核心页面均有数百行改动,估计是界面交互、组件化、错误处理方面的增强
3. **后端核心服务优化** — 9 个关键文件
- `workflow_engine.py` (1,284 行改动) — 工作流引擎重大升级
- `query_service.py` (720 行改动) — 查询服务改进
- `ticket_executor.py` (530 行改动) — 工单执行器改进
### 🟡 中等价值
4. **数据脱敏增强** — `data_masker.py` 668 行改动
5. **用户/工作流模型扩展** — `user.py` + `workflow.py` 模型字段扩展
6. **认证 hook 优化** — `useAuth.tsx` 391 行改动
### 🟢 待评估
7. **TDD 测试计划** — 当前 128 tests,Phase 4 安全测试未完成
8. **路线图待完成项** — 快照对比、查询模板、前端集成等
---
## 六、建议操作
### 立即可做
```bash
# 将 final 版本的4个新审核规则文件复制到我们的项目
cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/mysql_audit_rules.py ~/devops/sql-platform/backend/app/services/
cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/redis_audit_rules.py ~/devops/sql-platform/backend/app/services/
cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/mongodb_audit_rules.py ~/devops/sql-platform/backend/app/services/
cp ~/Downloads/SQL平台代码v1/sql-platform-final/backend/app/services/postgres_audit_rules.py ~/devops/sql-platform/backend/app/services/
```
### 需要逐步验证
- 逐个对比 25 个差异文件的改动内容,评估每项改动的兼容性
- 运行现有测试确保不引入回归
- 前端改动需逐一构建验证
### 建议策略
1. 先合并 `final` 版本新增的 4 个审核规则文件 + 关联修改(低风险,纯新增)
2. 逐页面合并前端优化(每合并一个页面后构建验证)
3. 最后处理后端核心服务改动(需配合测试)
---
## 七、总结
| 项目 | 数据 |
|------|------|
| 优化包总改动文件数 (vs 我们) | **29 个** (25差异 + 4新增) |
| 优化包总新增代码量 | ~6,900 行 (4个新规则文件) + 大量行级改动 |
| 文件结构差异 | **无** (完全一致的目录结构) |
| 最有价值的改动 | **193条数据库专用审核规则** (final 独有) |
| 风险评估 | 中等 — 文件结构一致但内容改动量大 |
-179
View File
@@ -1,179 +0,0 @@
# SQL-Platform-Pro 优化清单
> 基于当前实现状态(commit 39ffc14)的差距分析,按优先级 P0/P1/P2 分级。
> 状态标记:🟢已有基础 🟡部分实现 🔴未实现
---
## 一、假阳性/假阴性治理
### 现状
- MySQL 117 条审核规则(DDL-036 + DML 等),已持久化到 DB + 前端可调 severity/enabled
- 已做一轮降级(9 条 ERROR→WARNING),但缺少「为什么误报」的反馈闭环
- PostgreSQL / Redis / MongoDB 审核规则薄弱(PG 少量、Redis 仅 FLUSHDB 拦截、MongoDB 无专用规则)
### 优化项
| # | 优先级 | 内容 | 现状 | 工作量 |
|---|--------|------|------|--------|
| 1.1 | **P0** | **审核结果反馈机制** — 审批人在审批时可标记某条 finding 为「误报」,记录到 `audit_feedback` 表(rule_code, ticket_id, feedback_type, reason),月度统计误报率 TOP10 规则 | 🔴 | 2d |
| 1.2 | **P0** | **上下文感知规则** — `ALTER TABLE ADD INDEX` 在小表(<1万行)应 WARNING 而非 ERROR;连接目标库查 `table_rows` 动态调整 severity | 🔴 | 3d |
| 1.3 | **P1** | **规则分组白名单** — 支持实例级别禁用/调整规则(如测试环境放宽所有 DDL 规则),`instance_rule_override` 表 | 🔴 | 2d |
| 1.4 | **P1** | **PG 审核规则补全** — 参照 MySQL 规则体系,补齐 PG 特有规则(SERIAL 滥用、JSONB 无索引、VACUUM 建议、大对象等) | 🟡 | 3d |
| 1.5 | **P2** | **MongoDB 审核规则** — Pipeline 复杂度限制、`$where`/`eval` 禁止、`dropDatabase` 拦截、索引建议 | 🔴 | 3d |
| 1.6 | **P2** | **规则测试基准** — 每条规则至少 3 个 case(正常通过/应拦截/边界),CI 自动验证规则回归 | 🔴 | 3d |
---
## 二、大 SQL / 复杂语句处理
### 现状
- `_split_statements()` 用 sqlparse 切割多条 SQL
- 无 SQL 长度/复杂度上限(有 `max_query_length` 但仅查长度)
- MongoDB Pipeline 无复杂度检查
### 优化项
| # | 优先级 | 内容 | 现状 | 工作量 |
|---|--------|------|------|--------|
| 2.1 | **P1** | **SQL 复杂度评分** — 解析 JOIN 数量、子查询嵌套层数、UNION 数量,超过阈值(可配置)提升 risk_level | 🔴 | 2d |
| 2.2 | **P1** | **审核超时保护** — 单条 SQL 审核超过 5s 自动跳过(标记 timeout),避免正则/解析卡死 | 🔴 | 1d |
| 2.3 | **P1** | **审核缓存** — 相同 SQL 文本(去空格后 hash)短期不重复审核,LRU 缓存 1000 条 | 🔴 | 1d |
| 2.4 | **P2** | **MongoDB Pipeline 复杂度** — 限制 stage 数量(默认 20)、`$lookup` 嵌套层数、`$group` 内存风险 | 🔴 | 2d |
| 2.5 | **P2** | **SQL 格式化展示** — 审核详情中高亮问题位置(行号+列号),用 sqlparse 格式化 | 🔴 | 1d |
---
## 三、事务一致性(多条 SQL 原子性)
### 现状
- MySQL 执行有 `conn.rollback()` 失败回滚
- PG 执行有 `transaction()` 包裹
- 但 **跨库没有事务**,同库多条 SQL 部分成功部分失败时缺少明确的补偿机制
- Redis/MongoDB 多命令无事务概念(Redis 有 MULTI/EXEC 但未使用)
### 优化项
| # | 优先级 | 内容 | 现状 | 工作量 |
|---|--------|------|------|--------|
| 3.1 | **P0** | **执行进度透明** — 多条 SQL 逐条执行,每条记录 status( pending/running/success/failed ),前端实时展示 | 🟡 | 3d |
| 3.2 | **P0** | **部分失败处理策略** — 可选:`stop_on_error`(默认,遇错停止)/ `continue_on_error`(跳过失败继续)/ `transaction`(整体回滚) | 🔴 | 2d |
| 3.3 | **P1** | **自动回滚 SQL 生成** — INSERT→DELETE、UPDATE→反向 UPDATE、DDL→DROP/ALTER REVERSE,记录到 `rollback_sql` 字段 | 🔴 | 5d |
| 3.4 | **P1** | **Redis MULTI/EXEC** — 工单中多条 Redis 命令用 pipeline 批量执行,保证原子性 | 🔴 | 1d |
| 3.5 | **P2** | **跨库事务标记** — 跨库工单标注「无原子性保障」警告,审批人二次确认 | 🔴 | 1d |
---
## 四、锁表与业务影响分析
### 现状
- 有工单执行锁(防并发执行同一工单)
- **无 EXPLAIN 预估**、无影响行数预估、无锁表风险提示
- 无大表 ALTER 的 pt-osc / gh-ost 建议
### 优化项
| # | 优先级 | 内容 | 现状 | 工作量 |
|---|--------|------|------|--------|
| 4.1 | **P0** | **EXPLAIN 预检** — 审核阶段对 DML 执行 EXPLAIN(MySQL)/ EXPLAIN ANALYZE(PG),提取 `rows` 预估扫描行数,超过阈值(可配置,默认 10 万)标 ERROR | 🔴 | 3d |
| 4.2 | **P0** | **大表 ALTER 检测** — 查 `information_schema.tables` 获取表行数,大表(>10万行)ALTER 时提示「建议使用 pt-online-schema-change / gh-ost」,risk_level=high | 🔴 | 2d |
| 4.3 | **P1** | **影响行数预估** — DML 工单执行前用 `SELECT COUNT(*) FROM ... WHERE <condition>` 预估影响行数,超过阈值(默认 1 万)强制高等级审批 | 🔴 | 2d |
| 4.4 | **P1** | **执行时间预估** — 基于 EXPLAIN 的 rows × 历史执行平均耗时,给出粗略预估(快<1s / 中<30s / 慢>30s / 极慢>5min) | 🔴 | 2d |
| 4.5 | **P2** | **执行超时强杀** — MySQL `max_execution_time` / PG `statement_timeout` 硬超时,超时自动 KILL 并标记 failed | 🟡 | 1d |
| 4.6 | **P2** | **维护窗口控制** — 可配置允许执行 DDL 的时间窗口(如凌晨 2-5 点),窗口外提交自动拒绝 | 🔴 | 1d |
---
## 五、敏感操作审核强化
### 现状
- DROP TABLE → 审核规则 ERROR(DDL-004)
- Redis FLUSHDB/FLUSHALL → 仅限 admin
- **但审批只有一级**(任意 admin/dba 可批),无多级审批
- 无风险等级区分(所有工单走同一审批流)
### 优化项
| # | 优先级 | 内容 | 现状 | 工作量 |
|---|--------|------|------|--------|
| 5.1 | **P0** | **工单风险等级** — 根据 SQL 类型 + 操作对象自动计算 risk_level(low/medium/high/critical):DROP/TRUNCATE/FLUSHDB=critical,大表 ALTER=high,普通 DML=medium,SELECT=low | 🔴 | 2d |
| 5.2 | **P0** | **多级审批流** — risk_level=high 需要 2 人审批(DBA+管理员),critical 需要 3 人审批(DBA+管理员+CTO 或指定人),利用现有 WorkflowStep 模型 | 🟡 | 3d |
| 5.3 | **P1** | **高危操作二次确认** — critical 级别工单执行前,执行人必须输入工单编号确认(防误操作) | 🔴 | 1d |
| 5.4 | **P1** | **敏感表保护** — 配置「保护表清单」(如 user/password/order/pay),对这些表的任何 DML/DDL 自动提升 risk_level,额外审批 | 🔴 | 2d |
| 5.5 | **P2** | **执行前备份** — critical 工单执行前自动备份受影响表(`CREATE TABLE xxx_backup AS SELECT *`),备份保留可配置天数 | 🔴 | 3d |
| 5.6 | **P2** | **MongoDB 危险操作** — `dropDatabase`、`dropCollection`、`remove({})` 拦截 + 多级审批 | 🔴 | 1d |
---
## 六、数据脱敏与审计
### 现状
- `MaskingRule` 模型已有(full/partial/hash/truncate/regex 5 种类型)
- 查询接口有 `DataMasker` 应用脱敏
- 审计日志 `AuditLog` 记录操作
- **但审核详情中 SQL 可能看到生产敏感数据**、通知中可能泄露信息
### 优化项
| # | 优先级 | 内容 | 现状 | 工作量 |
|---|--------|------|------|--------|
| 6.1 | **P0** | **通知脱敏** — 通知消息中的 SQL 内容截断(>200 字符只显示前 100 + ...),隐藏 WHERE 中的值(如 `phone='138****1234'`) | 🔴 | 1d |
| 6.2 | **P1** | **审核日志不可篡改** — AuditLog 表增加 `checksum` 字段(SHA256 of content),定期校验完整性;或写入只追加日志文件 | 🔴 | 2d |
| 6.3 | **P1** | **操作导出** — 审计日志支持 CSV/Excel 导出(按时间/用户/操作类型过滤),满足合规审计需求 | 🔴 | 2d |
| 6.4 | **P1** | **审核详情脱敏** — 审批人看到的工单 SQL 中,对配置了脱敏的字段值做 mask(如 INSERT INTO users (phone) VALUES ('138****1234')) | 🔴 | 2d |
| 6.5 | **P2** | **会话级脱敏策略** — 不同角色看到不同级别的脱敏(admin 看完整、dba 看部分脱敏、developer 看完全脱敏) | 🔴 | 2d |
| 6.6 | **P2** | **SQL 注入检测** — 审核阶段检测疑似 SQL 注入模式(如连续单引号、UNION SELECT 注入、hex 编码绕过) | 🔴 | 2d |
---
## 优先级排序(建议实施路线)
### 第一阶段(2 周)— 安全基线
```
5.1 工单风险等级 → 后续所有功能的基础
1.2 上下文感知规则 → 减少误报最有效的手段
4.1 EXPLAIN 预检 → DML 执行安全核心
3.1 执行进度透明 → 多条 SQL 可观测性
6.1 通知脱敏 → 快速见效
```
### 第二阶段(2 周)— 体验优化
```
5.2 多级审批流 → 基于风险等级的审批
3.2 部分失败处理策略 → 多条 SQL 容错
4.2 大表 ALTER 检测 → DBA 最关心的场景
4.3 影响行数预估 → 审批人最需要的信息
1.1 审核结果反馈机制 → 持续优化规则质量
2.2 审核超时保护 → 稳定性兜底
```
### 第三阶段(2 周)— 高级特性
```
3.3 自动回滚 SQL 生成 → 最高价值但最复杂
4.4 执行时间预估 → 配合维护窗口
5.3 高危操作二次确认 → 防呆机制
5.4 敏感表保护 → 细粒度管控
1.4 PG 审核规则补全 → 多 DB 支持
6.2 审计日志不可篡改 → 合规需求
```
### 第四阶段(持续)— 生态完善
```
1.5 MongoDB 审核规则
1.6 规则测试基准
2.1 SQL 复杂度评分
5.5 执行前备份
6.3 操作导出
6.6 SQL 注入检测
```
---
## 预估总工作量
| 阶段 | 工作量 | 核心目标 |
|------|--------|----------|
| P0 合计 | ~15 人天 | 安全基线 + 防误报 |
| P1 合计 | ~23 人天 | 审批流 + 影响分析 |
| P2 合计 | ~18 人天 | 高级特性 + 合规 |
| **总计** | **~56 人天** | — |
-149
View File
@@ -1,149 +0,0 @@
# 优化清单可行性评估
> 对 OPTIMIZATION_PLAN.md 逐项评审,结论:**可行但有水分,需要砍掉/合并部分项**
>
> 标记:✅可直接做 ⚠️需要架构调整 ❌过度设计建议砍掉
---
## 总体结论
| 维度 | 评估 |
|------|------|
| **方向正确性** | ✅ 6 个方向都是真实的运维痛点 |
| **工作量估算** | ⚠️ 偏乐观,56 人天实际可能 80+,因为很多项需要改 audit 方法签名(目前不接受 DB 连接) |
| **架构瓶颈** | ⚠️ 审核引擎是纯静态分析(只看 SQL 文本),要加 EXPLAIN/影响分析需要大改 |
| **过度设计项** | ❌ 约 8 项(见下文),属于「有了更好但不是刚需」 |
| **实际建议** | 砍到 **18 项**,分 3 阶段,约 **35 人天** |
---
## 逐项评审
### 一、假阳性/假阴性治理
| # | 评估 | 理由 |
|---|------|------|
| 1.1 审核结果反馈机制 | ⚠️ **可做但优先级降低** | 需要新表 + 前端改动,但价值在「长期优化」,前期没有足够数据量来统计误报率。建议后期做 |
| 1.2 上下文感知规则 | ✅ **核心价值** | 同一条 ALTER TABLE,小表 WARNING 大表 ERROR,这才是减少假阳性的关键。但实现难点:**审核引擎目前没有 DB 连接**,需要改 `audit()` 方法签名,加 `instance_id` 参数 → 查表行数 |
| 1.3 规则分组白名单 | ⚠️ **合理但可简化** | 不需要 `instance_rule_override` 表,直接在现有 `audit_rules` 表加 `scope` 字段(instance_id NULL=全局,非 NULL=实例级覆盖) |
| 1.4 PG 审核规则补全 | ⚠️ **可以做** | 但 PG 用户量通常远小于 MySQL,优先级可以降低。先补 10 条核心规则就够 |
| 1.5 MongoDB 审核规则 | ❌ **砍掉或极简** | MongoDB 工单场景极少(大部分是 Redis + MySQL),先只做 `dropDatabase` 拦截一条就够 |
| 1.6 规则测试基准 | ⚠️ **有价值但量大** | 117 条 × 3 case = 351 个测试。建议只给 ERROR 级别规则写测试(约 20 条 × 3 = 60 个) |
### 二、大 SQL / 复杂语句处理
| # | 评估 | 理由 |
|---|------|------|
| 2.1 SQL 复杂度评分 | ⚠️ **有一定价值** | 但 JOIN 数/子查询层数对 DBA 审批帮助有限。简化版:只算 SQL 长度 + 语句条数就够了 |
| 2.2 审核超时保护 | ✅ **必须做** | 正则/解析卡死会拖垮整个审核。`asyncio.wait_for(audit(), timeout=5)` 即可,半天工作量 |
| 2.3 审核缓存 | ❌ **砍掉** | 同一 SQL 重复审核的场景极少(工单每次 SQL 都不同),LRU 缓存是过度优化 |
| 2.4 MongoDB Pipeline 复杂度 | ❌ **砍掉** | 同 1.5,MongoDB 工单场景少 |
| 2.5 SQL 格式化展示 | ⚠️ **可做但不是 P0** | 前端已有 CodeMirror,高亮问题位置需要额外 parser 工作。放 P2 |
### 三、事务一致性
| # | 评估 | 理由 |
|---|------|------|
| 3.1 执行进度透明 | ✅ **核心价值** | 多条 SQL 逐条记录状态,审批人能看清哪条成功哪条失败。现有 `_split_statements` 已有框架,改动不大 |
| 3.2 部分失败处理策略 | ✅ **必须做** | 3 种模式(遇错停止/跳过继续/整体回滚)覆盖所有场景。MySQL 已有 transaction,PG 已有 transaction(),加上策略枚举即可 |
| 3.3 自动回滚 SQL 生成 | ❌ **砍掉** | INSERT→反向 DELETE 看似简单,但 UPDATE 的反向需要 SELECT 旧值,DDL 不可逆。业界 Yearning/Archery 也只做了 INSERT 回滚,做了半吊子反而危险。不如做成「执行前手动填写回滚 SQL」 |
| 3.4 Redis MULTI/EXEC | ⚠️ **可做但简化** | Redis Pipeline(非事务)已经能批量执行,`MULTI/EXEC` 在跨 slot 时会失败。用 pipeline 就够 |
| 3.5 跨库事务标记 | ✅ **简单有效** | 前端/通知加一个警告标签,半天工作量 |
### 四、锁表与业务影响分析
| # | 评估 | 理由 |
|---|------|------|
| 4.1 EXPLAIN 预检 | ⚠️ **核心价值但实现复杂** | 最大难点:**审核引擎是纯静态分析**,`audit(sql, db_type)` 没有 DB 连接。要跑 EXPLAIN 需要:(1) 改 audit 方法签名加 instance_id (2) 审核时建临时连接 (3) 执行 EXPLAIN (4) 解析结果。涉及连接池管理、超时处理、权限控制,实际工作量 5d 起步 |
| 4.2 大表 ALTER 检测 | ⚠️ **同 4.1 的前置问题** | 需要查 `information_schema`,同样需要 DB 连接。但可以做成「创建工单时」而非「审核时」查询,利用 ticket endpoint 已有的 db session |
| 4.3 影响行数预估 | ⚠️ **与 4.1 合并** | `SELECT COUNT(*) WHERE <condition>` 就是 EXPLAIN 的简化版,不需要分开做 |
| 4.4 执行时间预估 | ❌ **砍掉** | EXPLAIN 的 rows × 历史耗时 = 极不准,给用户错误预期反而有害。不如只给「快/慢」二分 |
| 4.5 执行超时强杀 | ✅ **已部分实现** | `asyncio.wait_for(cur.execute(stmt), timeout=xxx)` 已有。补充 MySQL 的 `max_execution_time` hint 和 PG 的 `statement_timeout` SET 即可 |
| 4.6 维护窗口控制 | ⚠️ **简单有效** | 配置项 + 中间件检查,1d 工作量 |
### 五、敏感操作审核强化
| # | 评估 | 理由 |
|---|------|------|
| 5.1 工单风险等级 | ✅ **基础能力,必须先做** | Ticket 模型已有 `priority` 字段,直接复用或加 `risk_level`。根据 SQL 类型 + 操作对象自动计算,是后续多级审批的基础 |
| 5.2 多级审批流 | ⚠️ **架构已有但逻辑未通** | WorkflowStep 已有 `step_type=serial/parallel/any_one/condition`,但 ticket 创建时**没有关联 workflow_id**。需要补上:ticket 绑定 workflow → 按 risk_level 匹配不同审批流 |
| 5.3 高危操作二次确认 | ✅ **简单有效** | 前端弹框要求输入工单编号,半天工作量 |
| 5.4 敏感表保护 | ⚠️ **可以做但配置繁琐** | 需要维护「保护表清单」,DBA 愿意配置才行。先做成全局配置(`protected_tables: ["user","password","order","pay"]`) |
| 5.5 执行前备份 | ❌ **砍掉** | `CREATE TABLE xxx_backup AS SELECT *` 对大表会锁表 + 占空间,与要解决的问题矛盾。改成建议提示「请手动备份」更实际 |
| 5.6 MongoDB 危险操作 | ❌ **合并到 5.1** | dropDatabase 在风险等级计算里处理即可,不需要单独做 |
### 六、数据脱敏与审计
| # | 评估 | 理由 |
|---|------|------|
| 6.1 通知脱敏 | ✅ **简单有效** | 模板里 `{sql_preview}` 已经是截断的(取前 100 字符),但需要确认 `error` 字段有没有泄露数据。1d 工作量 |
| 6.2 审计日志不可篡改 | ❌ **砍掉** | checksum 方案在更新/删除场景下无法自洽,需要区块链/WORM 才能真正防篡改。对内部系统来说,DB 权限控制 + 操作审计就够了 |
| 6.3 操作导出 | ✅ **实用** | AuditLog + QueryHistory CSV 导出,API 加个 endpoint 即可 |
| 6.4 审核详情脱敏 | ⚠️ **理想但实现困难** | 对 SQL 中的字面值做脱敏需要 SQL parser 精确定位值位置,正则方案容易误伤。简化版:对审批人隐藏 `WHERE phone='13812341234'` 中的值 |
| 6.5 会话级脱敏策略 | ❌ **砍掉** | 过度设计,现有 MaskingRule 已够用 |
| 6.6 SQL 注入检测 | ⚠️ **有价值但非 P0** | 与审核引擎结合,检测 `UNION SELECT`、`OR 1=1`、hex 编码等模式。放 P2 |
---
## 修订后实施计划(18 项 / ~35 人天)
### 第一阶段:安全基线(~10d)
| # | 项目 | 工作量 | 核心改动 |
|---|------|--------|----------|
| 1 | **5.1 工单风险等级** | 2d | Ticket 加 `risk_level` 字段 + 自动计算逻辑(后端) |
| 2 | **5.2 多级审批流打通** | 3d | Ticket 绑定 workflow_id → 按 risk_level 匹配 → 多步审批 |
| 3 | **2.2 审核超时保护** | 0.5d | `wait_for(audit(), timeout=5)` |
| 4 | **3.1 执行进度透明** | 2d | `ticket_sql_items` 表 + 逐条执行 + 前端实时状态 |
| 5 | **6.1 通知脱敏** | 1d | 模板中截断 SQL + 隐藏 WHERE 值 |
| 6 | **5.3 高危二次确认** | 0.5d | 前端输入工单编号确认框 |
| 7 | **4.6 维护窗口控制** | 1d | system_settings 配置 + 中间件检查 |
### 第二阶段:影响分析(~12d)
| # | 项目 | 工作量 | 核心改动 |
|---|------|--------|----------|
| 8 | **4.1+4.3 EXPLAIN + 影响行数** | 5d | 改 audit 签名 + DB 连接 + EXPLAIN 解析 + 前端展示 |
| 9 | **4.2 大表 ALTER 检测** | 2d | 创建工单时查 information_schema + 风险提示 |
| 10 | **3.2 部分失败策略** | 2d | stop_on_error / continue / transaction 三模式 |
| 11 | **1.2 上下文感知规则** | 3d | ALTER 规则查表行数动态调 severity |
### 第三阶段:体验完善(~13d)
| # | 项目 | 工作量 | 核心改动 |
|---|------|--------|----------|
| 12 | **1.4 PG 审核规则** | 3d | 补 10 条核心规则 |
| 13 | **1.3 规则白名单(简化版)** | 2d | audit_rules 加 scope 字段 |
| 14 | **4.5 执行超时强杀** | 1d | MySQL SET max_execution_time + PG SET statement_timeout |
| 15 | **6.3 审计日志导出** | 2d | CSV/Excel 导出 API |
| 16 | **3.5 跨库事务标记** | 0.5d | 前端/通知警告标签 |
| 17 | **5.4 敏感表保护** | 2d | 全局 protected_tables 配置 |
| 18 | **2.1 SQL 复杂度(简化版)** | 1d | 只算长度 + 语句条数 |
### 砍掉的 12 项
| # | 砍掉原因 |
|---|----------|
| 1.1 审核结果反馈 | 数据量不够,后期做 |
| 1.5 MongoDB 审核规则 | 场景少,dropDatabase 合并到风险等级 |
| 1.6 规则测试基准 | 工作量大,改为只给 ERROR 规则写测试 |
| 2.3 审核缓存 | 同 SQL 重复审核场景极少 |
| 2.4 MongoDB Pipeline 复杂度 | 场景少 |
| 2.5 SQL 格式化展示 | 前端 CodeMirror 够用 |
| 3.3 自动回滚 SQL 生成 | 做半吊子反而危险 |
| 4.4 执行时间预估 | 不准,给错误预期 |
| 5.5 执行前备份 | 大表备份本身锁表 |
| 5.6 MongoDB 危险操作 | 合并到风险等级 |
| 6.2 审计日志不可篡改 | 内部系统不需要区块链 |
| 6.5 会话级脱敏策略 | 现有 MaskingRule 够用 |
---
## 关键架构决策(实施前需确认)
1. **审核引擎签名改造** — `audit(sql, db_type)` → `audit(sql, db_type, instance_id=None, db_session=None)`,加了 DB 连接才能跑 EXPLAIN。这是一个破坏性变更,需要评估影响面
2. **多级审批路由** — ticket 创建时如何匹配 workflow?方案:(A) 按 instance 关联的 workflow_id (B) 按 risk_level 查默认 workflow。建议用 (A),instance 已有 `workflow_id` 字段
3. **执行进度存储** — 是新建 `ticket_sql_items` 表还是复用 `execute_result` JSON?建议新建表,便于查询和展示
-126
View File
@@ -1,126 +0,0 @@
# SQL-Platform 数据审核平台 - 项目评估
> 评估日期: 2025-05-22
> 评估版本: v0.5.0 (Phase 1-5 全部完成)
---
## 一、项目概况
| 项目 | 说明 |
|------|------|
| 项目名 | SQL-Platform 数据审核平台 (sql-platform) |
| 定位 | SQL 审核与工单管理平台(对标 Yearning/ArcSQL) |
| 技术栈 | FastAPI + SQLite + React + TypeScript + Vite |
| 代码仓库 | ~/devops/sql-platform |
| Git 提交 | 8 commits |
| 测试 | 128 tests passing |
| 部署 | Docker (docker-compose),SQLite 轻量存储 |
---
## 二、功能完成度
### 后端 API (FastAPI)
| 模块 | 端点数 | 状态 |
|------|--------|------|
| 认证 (auth) | 4 | ✅ 登录/登出/获取用户/验证token |
| 用户管理 (user) | 5 | ✅ CRUD + 状态切换 |
| 数据源 (instance) | 6 | ✅ CRUD + 连接测试 + AES加密 |
| 工单 (ticket) | 8 | ✅ 创建/审批/拒绝/执行/统计/分页 |
| 在线查询 (query) | 1 | ✅ AST安全验证 + 自动LIMIT |
| 仪表盘 (dashboard) | 1 | ✅ 统计数据 |
| 审核引擎 (audit) | 3 | ✅ 规则引擎10条 + 自动评分 + 集成 |
| 审核规则 (audit-rule) | 7 | ✅ CRUD + toggle + stats + filter |
| 审计日志 (audit) | 2 | ✅ 列表/过滤/分页 |
### 前端页面 (React)
| 页面 | 路由 | 状态 |
|------|------|------|
| 登录 | /login | ✅ JWT认证 |
| 仪表盘 | /dashboard | ✅ 统计图表 |
| 数据源管理 | /mysql, /postgres 等 | ✅ CRUD + 连接测试 |
| 工单管理 | /tickets | ✅ 创建/审批/执行/统计 |
| 在线查询 | /queries | ✅ SQL执行 + 结果表格 |
| 用户管理 | /users | ✅ CRUD + 状态切换 |
| 审核规则 | /audit-rules | ✅ CRUD + toggle + stats |
| 审计日志 | /audit-logs | ✅ 分页 + 过滤 |
---
## 三、安全特性
| 特性 | 实现 |
|------|------|
| 密码存储 | ✅ bcrypt hash |
| JWT Token | ✅ 30分钟过期 |
| 数据源密码 | ✅ AES-256-GCM 加密 |
| SQL 防注入 | ✅ sqlparse AST 解析 |
| 操作审计 | ✅ 全操作记录 |
| 权限控制 | ✅ admin/dba/developer/guest |
---
## 四、测试覆盖
| 测试文件 | 数量 | 覆盖范围 |
|----------|------|----------|
| test_auth | 8 | 登录/登出/token验证 |
| test_users | 8 | CRUD/权限/自删 |
| test_instances | 8 | CRUD/加密/连接测试 |
| test_tickets | 11 | 全生命周期/统计/分页 |
| test_query | 4 | SELECT/DROP/DELETE/guest |
| test_dashboard | 2 | 统计/未登录 |
| test_crypto | 9 | AES加密/解密/兼容 |
| test_audit_engine | 23 | 10条规则/集成测试 |
| test_audit_logs | 9 | API/CRUD审计/分页 |
| test_sql_analyzer | 34 | 类型检测/风险/安全检查 |
| test_audit_rules | 14 | CRUD/权限/toggle/stats |
| **合计** | **128** | **11 模块** |
---
## 五、DevOps
| 项目 | 状态 |
|------|------|
| Dockerfile (backend) | ✅ Python 3.11 + auto migrate |
| Dockerfile (frontend) | ✅ Node 20 build + nginx |
| docker-compose | ✅ 双容器 + volume |
| Alembic 迁移 | ✅ 初始 schema |
| requirements.txt | ✅ 12 依赖 |
---
## 六、评分
| 维度 | 分数 (1-10) | 说明 |
|------|-------------|------|
| **功能完成度** | **8.5** | 核心功能全覆盖:工单全生命周期、SQL审核引擎、规则CRUD、审计日志 |
| **代码质量** | **8.5** | TDD驱动、128测试、模块化清晰、类型注解完整 |
| **安全性** | **9.0** | AES加密、bcrypt、JWT、AST防注入、权限矩阵、审计日志 |
| **测试覆盖** | **8.5** | 128 tests 覆盖11模块,含单元+集成+安全测试 |
| **生产就绪度** | **8.0** | Docker部署、Alembic迁移、SQLite轻量、真实DB连接、GitLab CI/CD |
| **用户体验** | **7.5** | 前端响应式、过滤分页、toast提示;缺国际化、深色模式 |
| **文档** | **8.0** | ROADMAP + TDD_PLAN + EVALUATION 完整 |
| **综合评分** | **8.1** | |
---
## 七、下一步优化建议
### 高优先级 (P0)
1. **慢查询分析** — EXPLAIN 执行计划展示 + 慢查询日志
2. **邮件/Webhook 通知** — 工单状态变更通知
3. **数据源探针** — 实例健康检查 + 连接池
### 中优先级 (P1)
4. **权限矩阵细化** — 数据源级权限、SQL 类型权限
5. **SQL 格式化** — sqlparse 格式化展示
6. **操作回滚** — SQL 执行前自动备份
### 低优先级 (P2)
7. **国际化 (i18n)** — 中英文切换
8. **深色模式** — Tailwind dark mode
+4 -9
View File
@@ -20,7 +20,7 @@ OpsxSQL 是一个面向 DBA 和开发人员的数据库管理平台,核心定
| 层级 | 技术 |
|------|------|
| 后端 | Go 1.26 + Gin + GORM + SQLite |
| 前端 | React 18 + TypeScript + Vite + Tailwind CSS + shadcn/ui |
| 前端 | React 19 + TypeScript + Vite + Tailwind CSS + shadcn/ui |
| 认证 | JWT + LDAP 集成 |
| 数据库 | SQLite(零依赖,开箱即用) |
@@ -106,16 +106,11 @@ sql-platform/
git clone https://gitee.com/opsx-tools/opsxsql.git
cd opsxsql
# 编译后端
cd backend
go build -o sql-platform ./cmd/server/
# 启动(前后端一键启动)
cd ..
bash run.sh restart
# Docker 一键启动
docker-compose up -d --build
```
后端运行在 **http://localhost:9000**,前端运行在 **http://localhost:5173**
后端运行在 **http://localhost:9000**,前端运行在 **http://localhost/**
### 默认账号
-64
View File
@@ -1,64 +0,0 @@
# SQL Audit Platform Roadmap
## 当前版本: v0.8.1
## 已完成
### Phase 1-7 (v0.1.0 ~ v0.7.0)
- 基线版本 + 权限组 + 审批流 + 多数据库查询/执行引擎
- 后端测试: 808/808 全部通过
- 前端: 27 个页面, 构建零错误
### v0.8.1 — 技术债务清理
- ✅ Pydantic V2 完整迁移: 13个schema + config.py, 消除14个deprecation警告
- ✅ 前端TypeScript编译修复: SqlEditor SQLDialect, AuditLogsPage类型转换, QueriesPage未用import
- ✅ 后端测试: 843/843 通过, 0 warnings
- ✅ 前端构建: 零错误
### P0 — 数据库全景支持 ✅
- ✅ 统一查询服务: POST /query 自动路由 MySQL/PostgreSQL/Redis/MongoDB
- ✅ query_service.py: 完整 4 种数据库 Handler (MySQL/PostgreSQL/Redis/MongoDB)
- ✅ Redis 只读命令白名单: 40+ 只读命令 + 子命令验证 (CONFIG GET/MEMORY USAGE/OBJECT)
- ✅ MongoDB 只读操作白名单: find/findOne/countDocuments/distinct/aggregate + $out/$merge 防护
- ✅ 工单执行多数据库分发: ticket_executor.py 支持 MySQL/PostgreSQL/Redis/MongoDB 执行
- ✅ MongoDB 连接器: mongo_connector.py 780 行, 完整 CRUD + 统计 + 安全验证
- ✅ MongoDB 前端管理页面: MongoDBDetailPage.tsx (集合管理/文档浏览/查询控制台/统计信息)
- ✅ mongo API 模块: frontend/src/api/mongo.ts
- ✅ /mongo/:id 路由集成 + InstancePage 管理控制台跳转按钮
- ✅ 数据源品牌图标: MySQL/PostgreSQL/Redis/MongoDB SVG logos
### P1 — 审计闭环 (部分完成)
- ✅ 查询历史服务: query_history_service.py (记录/收藏/快捷查询)
- ✅ SQL 助手: sql_assistant.py (格式化/风险高亮/EXPLAIN 解析)
- ✅ 审计规则种子: MySQL 18规则 + PostgreSQL 12规则 + Redis 8规则
- ✅ 审计日志全操作覆盖
- ✅ 影响分析: impact_analyzer.py
- ✅ 回滚生成: rollback_generator.py
- ✅ Schema 漂移检测: schema_drift_detector.py
### P2 — 高级功能 (部分完成)
- ✅ 数据脱敏: data_masker.py + masking_rule CRUD
- ✅ 通知服务: 钉钉/飞书/邮件 SMTP
- ✅ 备份管理: backup_executor.py
- ✅ 工作流引擎: workflow_engine.py + workflow_seeds.py
- ✅ 慢日志收集: slow_log_collector.py
- ✅ 连接池: connection_pool.py
## 待完成
### P1 — 审计闭环
- [ ] 执行前后数据快照对比可视化 (前端)
- [ ] 快捷查询模板 (10个预置 + 自定义共享)
- [ ] SQL 格式化前端集成 (CodeMirror / Monaco editor)
- [ ] 查询结果格式化展示优化
### P2 — 人性化设计
- [ ] SQL 语法高亮编辑器
- [ ] 数据库类型说明 API
- [ ] Slow query analysis 前端集成
- [ ] Dashboard 数据源健康概览
### 运维
- [ ] 远程 Git 仓库配置
- [ ] CI/CD 完整流水线测试
- [ ] 生产环境部署文档
-247
View File
@@ -1,247 +0,0 @@
# SQL-Platform 数据审核平台 - TDD 开发计划
> 创建时间: 2025-05-22
> 测试框架: pytest (后端) + vitest (前端)
> 目标覆盖率: > 60%
---
## 测试环境配置
### 后端 (pytest)
```
pip install pytest pytest-asyncio httpx aiomysql
```
**conftest.py 基础配置:**
- 使用 SQLite in-memory 数据库
- FastAPI TestClient + async 测试
- 自动创建/销毁表
- 测试用户 fixture
### 前端 (vitest)
```
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
```
---
## 测试计划
### Phase 1: 后端 API 测试 (P0 — 阻塞)
> 目标:确保所有现有 API 行为正确,为前后端对接打基础
#### 1.1 认证模块 `tests/test_auth.py`
| # | 测试用例 | 优先级 | 状态 |
|---|---------|--------|------|
|| 1 | 正确密码登录 → 200 + token + user | P0 | ✅ |
|| 2 | 错误密码登录 → 401 | P0 | ✅ |
|| 3 | 用户不存在 → 401 | P0 | ✅ |
|| 4 | 已禁用用户登录 → 403 | P0 | ✅ |
|| 5 | GET /me 无 token → 401 | P0 | ✅ |
|| 6 | GET /me 有效 token → 200 + user info | P0 | ✅ |
|| 7 | token 过期 → 401 | P1 | ✅ |
|| 8 | POST /logout → 200 | P1 | ✅ |
#### 1.2 用户管理 `tests/test_users.py`
| # | 测试用例 | 优先级 | 状态 |
|---|---------|--------|------|
| 1 | admin 列出用户 → 200 | P0 | ⬜ |
| 2 | non-admin 列出用户 → 403 | P0 | ⬜ |
| 3 | 创建用户(正常) → 201 | P0 | ⬜ |
| 4 | 创建用户(重复用户名) → 400 | P0 | ⬜ |
| 5 | 获取单个用户 → 200 | P1 | ⬜ |
| 6 | 更新用户信息 → 200 | P1 | ⬜ |
| 7 | 删除用户 → 200 | P1 | ⬜ |
| 8 | 删除自己 → 400 | P1 | ⬜ |
#### 1.3 数据源管理 `tests/test_instances.py`
| # | 测试用例 | 优先级 | 状态 |
|---|---------|--------|------|
|| 1 | 列出实例 → 200 | P0 | ✅ |
|| 2 | 创建实例 → 201 | P0 | ✅ |
|| 3 | 创建实例(non-admin) → 403 | P0 | ✅ |
|| 4 | 获取实例详情 → 200 | P1 | ✅ |
|| 5 | 更新实例 → 200 | P1 | ✅ |
|| 6 | 删除实例 → 200 | P1 | ✅ |
|| 7 | 测试连接(MySQL mock) → 测试结果 | P1 | ✅ |
|| 8 | 列出数据库 → 列表 | P2 | ⬜ |
|| 9 | 列出表 → 列表 | P2 | ⬜ |
#### 1.4 工单管理 `tests/test_tickets.py`
| # | 测试用例 | 优先级 | 状态 |
|---|---------|--------|------|
|| 1 | 列出工单(developer 只看自己的) → 过滤正确 | P0 | ✅ |
|| 2 | 列出工单(admin 看全部) → 全部 | P0 | ✅ |
|| 3 | 创建工单 → 201 | P0 | ✅ |
|| 4 | 创建工单(实例不存在) → 400 | P0 | ✅ |
|| 5 | 获取工单详情 → 200 | P1 | ✅ |
|| 6 | 获取别人工单(developer) → 403 | P1 | ✅ |
|| 7 | 审批工单(approve) → approved | P0 | ✅ |
|| 8 | 审批工单(reject) → rejected | P0 | ✅ |
|| 9 | 审批工单(non-dba) → 403 | P0 | ✅ |
|| 10 | 审批已执行工单 → 400 | P1 | ✅ |
|| 11 | 执行工单(已审批) → success | P0 | ✅ |
|| 12 | 执行工单(未审批) → 400 | P0 | ✅ |
|| 13 | 执行工单(执行失败) → failed | P1 | ⬜ |
|| 14 | 工单统计 → 正确数字 | P1 | ✅ |
|| 15 | 分页查询 → 正确分页 | P1 | ✅ |
#### 1.5 查询模块 `tests/test_query.py`
| # | 测试用例 | 优先级 | 状态 |
|---|---------|--------|------|
|| 1 | 执行 SELECT 查询 → 结果 | P0 | ✅ |
|| 2 | 执行 DROP → 拒绝 | P0 | ✅ |
|| 3 | 执行 DELETE → 拒绝 | P0 | ✅ |
|| 4 | 无 LIMIT 自动追加 → 限制行数 | P1 | ⬜ |
|| 5 | guest 无权查询 → 403 | P1 | ✅ |
|| 6 | 查询超时 → 错误 | P2 | ⬜ |
|| 7 | 审计日志记录 → 有记录 | P1 | ⬜ |
#### 1.6 仪表盘 `tests/test_dashboard.py`
| # | 测试用例 | 优先级 | 状态 |
|---|---------|--------|------|
| 1 | 获取统计数据 → 正确结构 | P1 | ⬜ |
| 2 | 未登录 → 401 | P1 | ⬜ |
---
### Phase 2: 前端对接 (P0 — v0.2.0) ✅ 已完成
> 前后端对接已完成,所有页面已替换 mock 数据为真实 API 调用
#### 2.1 API 集成测试
| # | 测试场景 | 优先级 | 状态 |
|---|---------|--------|------|
|| 1 | 登录流程:输入→请求→token存储→跳转 | P0 | ✅ |
|| 2 | Dashboard 加载:请求stats→渲染卡片 | P0 | ✅ |
|| 3 | 工单列表:请求→筛选→分页 | P0 | ✅ |
|| 4 | 新建工单:表单→提交→刷新列表 | P0 | ✅ |
|| 5 | 实例管理:CRUD→列表刷新 | P1 | ✅ |
|| 6 | 401 自动跳转登录页 | P0 | ✅ |
#### 2.2 组件测试
| # | 测试场景 | 优先级 | 状态 |
|---|---------|--------|------|
| 1 | LoginPage 渲染 + 表单验证 | P1 | ⬜ |
| 2 | Dashboard 统计卡片渲染 | P1 | ⬜ |
| 3 | TicketsPage 筛选功能 | P1 | ⬜ |
| 4 | RouteGuard 权限拦截 | P0 | ⬜ |
---
### Phase 3: 新功能 TDD (P1 — v0.3.0)
> 先写测试,再写代码
#### 3.1 Instance 密码加密 ✅ 已完成
| # | TDD 步骤 | 状态 |
|---|---------|------|
| 1 | 🔴 写测试:创建实例时密码加密存储 | ✅ test_crypto.py |
| 2 | 🔴 写测试:读取实例时密码解密返回 | ✅ test_crypto.py |
| 3 | 🟢 实现 AES-256-GCM 加密/解密工具 | ✅ app/core/crypto.py |
| 4 | 🟢 集成到 instance endpoint (create/update/decrypt) | ✅ |
| 5 | ✅ 9 个加密相关测试全部通过 | ✅ |
#### 3.2 SQL 审核规则引擎 ✅ 已完成
| # | TDD 步骤 | 状态 |
|---|---------|------|
| 1 | 🔴 写测试:SELECT * 规则触发 info | ✅ |
| 2 | 🔴 写测试:无 WHERE 的 UPDATE/DELETE 规则触发 error | ✅ |
| 3 | 🔴 写测试:DROP TABLE/TRUNCATE 规则 | ✅ |
| 4 | 🔴 写测试:生产环境额外规则(ALTER TABLE 等) | ✅ |
| 5 | 🟢 实现 10 条规则引擎 | ✅ app/services/audit_engine.py |
| 6 | 🟢 集成到工单创建流程(自动审核+评分) | ✅ |
| 7 | ✅ 23 个审核引擎测试全部通过 | ✅ |
#### 3.3 操作审计日志 ✅ 已完成
| # | 测试用例 | 状态 |
|---|---------|------|
| 1 | 审计日志列表 API (admin/dba) | ✅ |
| 2 | 审计日志列表 API (developer 禁止) | ✅ |
| 3 | 创建实例 → 审计日志 | ✅ |
| 4 | 创建用户 → 审计日志 | ✅ |
| 5 | 创建工单 → 审计日志(含审核评分) | ✅ |
| 6 | 审批工单 → 审计日志 | ✅ |
| 7 | 审计日志分页 | ✅ |
| 8 | 审计日志过滤 | ✅ |
| 9 | ✅ 9 个审计日志测试全部通过 | ✅ |
---
### Phase 4: 安全测试 (P1)
| # | 测试场景 | 状态 |
|---|---------|------|
| 1 | SQL 注入:`DROP TABLE users; --` 被拦截 | ⬜ |
| 2 | SQL 注入:`UNION SELECT * FROM users` 被拦截 | ⬜ |
| 3 | 越权:developer 不能审批工单 | ⬜ |
| 4 | 越权:developer 不能访问 admin API | ⬜ |
| 5 | Token 伪造 → 401 | ⬜ |
| 6 | 并发创建工单不会重复 | ⬜ |
---
## 测试目录结构
```
backend/
├── tests/
│ ├── conftest.py # fixtures: db, client, users, token, test_instance
│ ├── test_auth.py # 认证测试 (8)
│ ├── test_users.py # 用户管理测试 (8)
│ ├── test_instances.py # 数据源测试 (8)
│ ├── test_tickets.py # 工单测试 (11)
│ ├── test_query.py # 查询测试 (4)
│ ├── test_dashboard.py # 仪表盘测试 (2)
│ ├── test_crypto.py # 加密测试 (9)
│ ├── test_audit_engine.py # SQL审核引擎测试 (23)
│ ├── test_audit_logs.py # 审计日志测试 (9)
│ ├── test_sql_analyzer.py # SQL AST测试 (34)
│ └── test_audit_rules.py # 审核规则CRUD测试 (14)
frontend/
├── src/
│ ├── __tests__/
│ │ ├── setup.ts # vitest setup
│ │ ├── LoginPage.test.tsx
│ │ ├── Dashboard.test.tsx
│ │ ├── TicketsPage.test.tsx
│ │ └── RouteGuard.test.tsx
```
---
## 覆盖率目标
| 阶段 | 目标后端覆盖率 | 目标前端覆盖率 |
|------|--------------|--------------|
| Phase 1 完成 | 40% | 0% |
| Phase 2 完成 | 50% | 30% |
| Phase 3 完成 | 60% | 40% |
| Phase 4 完成 | 70% | 50% |
---
## 执行优先级
1. ~~**立刻** — 创建 conftest.py + test_auth.py~~ ✅
2. ~~**v0.2.0** — Phase 1 全部完成~~ ✅
3. ~~**v0.2.1** — Phase 2 前后端对接~~ ✅
4. ~~**v0.3.0** — Phase 3 密码加密 + SQL 审核引擎~~ ✅ (85 tests)
4. ~~**v0.4.0** — Phase 4 SQL AST + 规则管理 + Alembic + Docker~~ ✅ (128 tests)
5. **v0.5.0** — Phase 5 运维工具 + 真实DB连接 + CI/CD ✅
+2 -2
View File
@@ -10,8 +10,8 @@ FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates sqlite3 curl && apt-get clean && rm -rf /var/lib/apt/lists/*
# Install gh-ost for online DDL
RUN GH_OST_VERSION=1.1.9 && \
curl -fsSL "https://github.com/github/gh-ost/releases/download/v${GH_OST_VERSION}/gh-ost-binary-linux-amd64-*.tar.gz" \
| tar xz -C /usr/local/bin gh-ost && chmod +x /usr/local/bin/gh-ost
curl -fsSL -o /usr/local/bin/gh-ost "https://github.com/github/gh-ost/releases/download/v${GH_OST_VERSION}/gh-ost" && \
chmod +x /usr/local/bin/gh-ost
WORKDIR /app
COPY --from=build /app/opsxsql .
COPY --from=build /app/cmd/server/seed.go /app/
@@ -117,7 +117,7 @@ func testPostgreSQL(ctx context.Context, host string, port int, user, pass, dbna
dbname = "postgres"
}
dsn := fmt.Sprintf("postgres://%s:***@%s:%d/%s?sslmode=disable&connect_timeout=10",
user, pass, host, port, dbname)
user, host, port, dbname)
db, err := sql.Open("pgx", dsn)
if err != nil {
return false, fmt.Sprintf("Connection failed: %v", err)
@@ -541,7 +541,7 @@ func getDBConnection(instance *model.Instance, dbName string) (*sql.DB, error) {
dbName = "postgres"
}
dsn := fmt.Sprintf("postgres://%s:***@%s:%d/%s?sslmode=disable&connect_timeout=10",
instance.Username, password, instance.Host, instance.Port, dbName)
instance.Username, instance.Host, instance.Port, dbName)
return sql.Open("pgx", dsn)
default:
return nil, fmt.Errorf("unsupported database type: %s", instance.DBType)
+4 -4
View File
@@ -86,8 +86,8 @@ func CreateInstance(c *gin.Context) {
if result.Version != "" {
updates["version"] = result.Version
}
if result.ClusterMode {
updates["cluster_mode"] = true
if instance.DBType == "redis" {
updates["cluster_mode"] = result.ClusterMode
}
} else {
updates["status"] = "error"
@@ -164,8 +164,8 @@ func TestConnection(c *gin.Context) {
if result.Success && result.Version != "" {
updates["version"] = result.Version
}
if result.ClusterMode {
updates["cluster_mode"] = true
if instance.DBType == "redis" {
updates["cluster_mode"] = result.ClusterMode
}
if len(updates) > 0 {
model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates)
+90 -12
View File
@@ -7,20 +7,20 @@ import (
"sql-platform/internal/model"
"github.com/gin-gonic/gin"
"time"
"context"
"sql-platform/internal/middleware"
"strings"
"strconv"
"sort"
"encoding/json"
"bytes"
"regexp"
"context"
"encoding/json"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/mongo/readpref"
"regexp"
"sort"
"sql-platform/internal/middleware"
"strconv"
"strings"
"time"
)
// GET /api/v1/instances/:id/detail/databases
@@ -523,7 +523,6 @@ func ExecuteQuery(c *gin.Context) {
}
// Read query limits from platform settings
maxLimit := getSettingInt("query_limit_max", 1000)
autoLimit := getSetting("query_auto_limit", "true") == "true"
if input.Limit <= 0 {
input.Limit = maxLimit
if input.Limit > 1000 {
@@ -547,7 +546,6 @@ func ExecuteQuery(c *gin.Context) {
}
}
}
_ = autoLimit
var instance model.Instance
if err := model.DB.First(&instance, input.InstanceID).Error; err != nil {
@@ -619,6 +617,9 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim
_ = ctx
upperSQL := strings.ToUpper(strings.TrimSpace(sqlStr))
if ok, reason := isReadOnlySQL(sqlStr); !ok {
return nil, fmt.Errorf("查询入口仅允许只读语句:%s。数据变更请提交工单", reason)
}
// Count total (only when no user LIMIT and offset=0)
total := 0
@@ -643,7 +644,8 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim
// Inject LIMIT/OFFSET if not already present — only for SELECT statements
// SHOW/DESC/EXPLAIN/CREATE/ALTER/DROP/etc do not support LIMIT syntax
needsLimit := !hasLimit && strings.HasPrefix(upperSQL, "SELECT")
autoLimit := getSetting("query_auto_limit", "true") == "true"
needsLimit := autoLimit && !hasLimit && isLimitableReadSQL(upperSQL)
if needsLimit {
sqlStr = fmt.Sprintf("%s LIMIT %d OFFSET %d", sqlStr, limit, offset)
}
@@ -689,6 +691,82 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim
}, nil
}
func isReadOnlySQL(sqlStr string) (bool, string) {
cleaned := strings.TrimSpace(stripLeadingSQLComments(sqlStr))
if cleaned == "" {
return false, "空语句"
}
if hasMultipleSQLStatements(cleaned) {
return false, "不允许一次执行多条语句"
}
upper := strings.ToUpper(cleaned)
readPrefixes := []string{"SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH"}
allowed := false
for _, prefix := range readPrefixes {
if strings.HasPrefix(upper, prefix+" ") || upper == prefix {
allowed = true
break
}
}
if !allowed {
return false, "只允许 SELECT / SHOW / DESCRIBE / EXPLAIN"
}
blockedPatterns := []string{
" FOR UPDATE", " LOCK IN SHARE MODE", " INTO OUTFILE", " INTO DUMPFILE",
"EXPLAIN ANALYZE", "DELETE ", "UPDATE ", "INSERT ", "REPLACE ",
"CREATE ", "ALTER ", "DROP ", "TRUNCATE ", "RENAME ", "GRANT ", "REVOKE ",
"CALL ", "DO ", "LOAD DATA", "COPY ",
}
for _, pattern := range blockedPatterns {
if strings.Contains(upper, pattern) {
return false, strings.TrimSpace(pattern)
}
}
return true, ""
}
func isLimitableReadSQL(upperSQL string) bool {
return strings.HasPrefix(upperSQL, "SELECT") || strings.HasPrefix(upperSQL, "WITH")
}
func hasMultipleSQLStatements(sqlStr string) bool {
trimmed := strings.TrimSpace(sqlStr)
if strings.HasSuffix(trimmed, ";") {
trimmed = strings.TrimSpace(strings.TrimSuffix(trimmed, ";"))
}
return strings.Contains(trimmed, ";")
}
func stripLeadingSQLComments(sqlStr string) string {
s := strings.TrimSpace(sqlStr)
for {
switch {
case strings.HasPrefix(s, "--"):
if idx := strings.Index(s, "\n"); idx >= 0 {
s = strings.TrimSpace(s[idx+1:])
continue
}
return ""
case strings.HasPrefix(s, "#"):
if idx := strings.Index(s, "\n"); idx >= 0 {
s = strings.TrimSpace(s[idx+1:])
continue
}
return ""
case strings.HasPrefix(s, "/*"):
if idx := strings.Index(s, "*/"); idx >= 0 {
s = strings.TrimSpace(s[idx+2:])
continue
}
return s
default:
return s
}
}
}
// parseCommandParts splits a command string into parts, respecting quoted strings.
// e.g. HGET "my key" "my field" => ["HGET", "my key", "my field"]
func parseCommandParts(input string) []string {
+159 -21
View File
@@ -13,6 +13,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
// Helper: get Redis client from instance (auto-detect standalone vs cluster)
type redisClientWrapper struct {
Client *redis.Client
@@ -21,7 +22,6 @@ type redisClientWrapper struct {
}
func (w *redisClientWrapper) Close() {
// Always close the single-node Client (cluster uses single-node now)
if w.Client != nil {
w.Client.Close()
}
@@ -30,19 +30,24 @@ func (w *redisClientWrapper) Close() {
}
}
// Ping, Info, DBSize, Scan, Type, TTL, Get, LRange, SMembers, ZRangeWithScores, HGetAll,
// SlowLogGet, ClientList, Do, Set, RPush, SAdd, HSet, Expire, Del — unified interface via wrapper methods
// NOTE: All methods use Client (single-node). isCluster flag is only for ScanAllKeys behavior.
func (w *redisClientWrapper) Ping(ctx context.Context) *redis.StatusCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Ping(ctx)
}
return w.Client.Ping(ctx)
}
func (w *redisClientWrapper) Info(ctx context.Context, sections ...string) *redis.StringCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Info(ctx, sections...)
}
return w.Client.Info(ctx, sections...)
}
func (w *redisClientWrapper) DBSize(ctx context.Context) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.DBSize(ctx)
}
return w.Client.DBSize(ctx)
}
@@ -50,9 +55,27 @@ func (w *redisClientWrapper) Scan(ctx context.Context, cursor uint64, match stri
return w.Client.Scan(ctx, cursor, match, count)
}
// ScanAllKeys scans keys on the connected node.
// For clusters, this only scans keys on the single connected node (not all masters).
func (w *redisClientWrapper) ScanAllKeys(ctx context.Context, match string, count int) ([]string, error) {
if w.isCluster && w.ClusterClient != nil {
var allKeys []string
err := w.ClusterClient.ForEachMaster(ctx, func(ctx context.Context, shard *redis.Client) error {
cursor := uint64(0)
for {
keys, nextCursor, err := shard.Scan(ctx, cursor, match, int64(count)).Result()
if err != nil {
return err
}
allKeys = append(allKeys, keys...)
cursor = nextCursor
if cursor == 0 {
break
}
}
return nil
})
return allKeys, err
}
var allKeys []string
cursor := uint64(0)
for {
@@ -70,30 +93,51 @@ func (w *redisClientWrapper) ScanAllKeys(ctx context.Context, match string, coun
}
func (w *redisClientWrapper) Type(ctx context.Context, key string) *redis.StatusCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Type(ctx, key)
}
return w.Client.Type(ctx, key)
}
func (w *redisClientWrapper) TTL(ctx context.Context, key string) *redis.DurationCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.TTL(ctx, key)
}
return w.Client.TTL(ctx, key)
}
func (w *redisClientWrapper) Get(ctx context.Context, key string) *redis.StringCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Get(ctx, key)
}
return w.Client.Get(ctx, key)
}
func (w *redisClientWrapper) LRange(ctx context.Context, key string, start, stop int64) *redis.StringSliceCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.LRange(ctx, key, start, stop)
}
return w.Client.LRange(ctx, key, start, stop)
}
func (w *redisClientWrapper) SMembers(ctx context.Context, key string) *redis.StringSliceCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.SMembers(ctx, key)
}
return w.Client.SMembers(ctx, key)
}
func (w *redisClientWrapper) ZRangeWithScores(ctx context.Context, key string, start, stop int64) *redis.ZSliceCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.ZRangeWithScores(ctx, key, start, stop)
}
return w.Client.ZRangeWithScores(ctx, key, start, stop)
}
func (w *redisClientWrapper) HGetAll(ctx context.Context, key string) *redis.MapStringStringCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.HGetAll(ctx, key)
}
return w.Client.HGetAll(ctx, key)
}
@@ -106,42 +150,72 @@ func (w *redisClientWrapper) ClientList(ctx context.Context) *redis.StringCmd {
}
func (w *redisClientWrapper) Do(ctx context.Context, args ...interface{}) *redis.Cmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Do(ctx, args...)
}
return w.Client.Do(ctx, args...)
}
func (w *redisClientWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Set(ctx, key, value, expiration)
}
return w.Client.Set(ctx, key, value, expiration)
}
func (w *redisClientWrapper) RPush(ctx context.Context, key string, values ...interface{}) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.RPush(ctx, key, values...)
}
return w.Client.RPush(ctx, key, values...)
}
func (w *redisClientWrapper) SAdd(ctx context.Context, key string, members ...interface{}) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.SAdd(ctx, key, members...)
}
return w.Client.SAdd(ctx, key, members...)
}
func (w *redisClientWrapper) HSet(ctx context.Context, key string, values ...interface{}) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.HSet(ctx, key, values...)
}
return w.Client.HSet(ctx, key, values...)
}
func (w *redisClientWrapper) Expire(ctx context.Context, key string, expiration time.Duration) *redis.BoolCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Expire(ctx, key, expiration)
}
return w.Client.Expire(ctx, key, expiration)
}
func (w *redisClientWrapper) Del(ctx context.Context, keys ...string) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.Del(ctx, keys...)
}
return w.Client.Del(ctx, keys...)
}
func (w *redisClientWrapper) LLen(ctx context.Context, key string) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.LLen(ctx, key)
}
return w.Client.LLen(ctx, key)
}
func (w *redisClientWrapper) SCard(ctx context.Context, key string) *redis.IntCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.SCard(ctx, key)
}
return w.Client.SCard(ctx, key)
}
func (w *redisClientWrapper) SScan(ctx context.Context, key string, cursor uint64, match string, count int64) *redis.ScanCmd {
if w.isCluster && w.ClusterClient != nil {
return w.ClusterClient.SScan(ctx, key, cursor, match, count)
}
return w.Client.SScan(ctx, key, cursor, match, count)
}
@@ -162,10 +236,6 @@ func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, erro
redisUsername = "" // go-redis treats empty Username as no ACL auth
}
// Always use single-node connection for query execution.
// ClusterClient does topology discovery (CLUSTER SLOTS) which fails when
// internal cluster IPs are unreachable from the client (e.g., Docker networks).
// Single-node mode works for all read-only commands on cluster nodes.
client := redis.NewClient(&redis.Options{
Addr: addr,
Username: redisUsername,
@@ -180,7 +250,48 @@ func getRedisClient(instance *model.Instance, db int) (*redisClientWrapper, erro
// Check cluster mode flag for wrapper behavior (ScanAllKeys needs to know)
isCluster := instance.ClusterMode != nil && *instance.ClusterMode
return &redisClientWrapper{Client: client, isCluster: isCluster}, nil
wrapper := &redisClientWrapper{Client: client, isCluster: isCluster}
if !isCluster {
return wrapper, nil
}
// Redis Cluster routes key commands by slot. Build the address list from
// CLUSTER SLOTS, but replace advertised internal node IPs with the configured
// host so Docker/NAT deployments can still reach the mapped node ports.
addrs := []string{addr}
if slots, err := client.ClusterSlots(ctx).Result(); err == nil && len(slots) > 0 {
seen := map[string]bool{}
addrs = addrs[:0]
for _, slot := range slots {
if len(slot.Nodes) == 0 {
continue
}
nodeAddr := slot.Nodes[0].Addr
if idx := strings.LastIndex(nodeAddr, ":"); idx >= 0 {
nodeAddr = instance.Host + nodeAddr[idx:]
}
if !seen[nodeAddr] {
seen[nodeAddr] = true
addrs = append(addrs, nodeAddr)
}
}
if len(addrs) == 0 {
addrs = []string{addr}
}
}
clusterClient := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: addrs,
Username: redisUsername,
Password: password,
})
if err := clusterClient.Ping(ctx).Err(); err != nil {
clusterClient.Close()
client.Close()
return nil, fmt.Errorf("Redis cluster connection failed: %v", err)
}
wrapper.ClusterClient = clusterClient
return wrapper, nil
}
// GET /api/v1/redis/:id/info
@@ -265,11 +376,12 @@ func RedisDbsize(c *gin.Context) {
func RedisScanKeys(c *gin.Context) {
id := c.Param("id")
dbParam := c.Query("db")
pattern := c.DefaultQuery("pattern", "*")
// Auto-append * if pattern has no wildcard (user-friendly fuzzy search)
if !strings.Contains(pattern, "*") && !strings.Contains(pattern, "?") && !strings.Contains(pattern, "[") {
pattern = pattern + "*"
rawPattern := strings.TrimSpace(c.DefaultQuery("pattern", "*"))
if rawPattern == "" {
rawPattern = "*"
}
pattern := rawPattern
hasWildcard := strings.ContainsAny(pattern, "*?[")
count, _ := strconv.Atoi(c.DefaultQuery("count", "100"))
db := 0
@@ -291,11 +403,6 @@ func RedisScanKeys(c *gin.Context) {
defer client.Close()
ctx := context.Background()
allKeys, err := client.ScanAllKeys(ctx, pattern, count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
// Get types for each key
type KeyInfo struct {
@@ -303,6 +410,37 @@ func RedisScanKeys(c *gin.Context) {
Type string `json:"type"`
TTL int64 `json:"ttl"`
}
// Redis Cluster 精确 key 查询应优先走槽位路由直查。全量 SCAN 需要访问各 master,
// 在 NAT/代理暴露单入口时容易受节点通告地址影响。
if !hasWildcard && pattern != "*" {
keyType, err := client.Type(ctx, pattern).Result()
if err == nil && keyType != "none" {
ttl, _ := client.TTL(ctx, pattern).Result()
ttlMs := int64(-1)
if ttl > 0 {
ttlMs = ttl.Milliseconds()
}
c.JSON(http.StatusOK, gin.H{
"keys": []KeyInfo{{Key: pattern, Type: keyType, TTL: ttlMs}},
"cursor": 0,
"total": 1,
})
return
}
}
// Auto-append * if pattern has no wildcard (user-friendly fuzzy search)
if !hasWildcard {
pattern = pattern + "*"
}
allKeys, err := client.ScanAllKeys(ctx, pattern, count)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
var keyInfos []KeyInfo
for _, key := range allKeys {
keyType, _ := client.Type(ctx, key).Result()
+13 -5
View File
@@ -10,6 +10,14 @@ import (
"sql-platform/internal/model"
)
func stringPtr(value string) *string {
return &value
}
func boolPtr(value bool) *bool {
return &value
}
type AuthService struct {
DB *gorm.DB
}
@@ -25,7 +33,7 @@ func (s *AuthService) Login(username, password string) (*model.User, string, err
return nil, "", errors.New("invalid username or password")
}
if !user.IsActive {
if user.IsActive != nil && !*user.IsActive {
return nil, "", errors.New("account is disabled")
}
@@ -33,7 +41,7 @@ func (s *AuthService) Login(username, password string) (*model.User, string, err
return nil, "", errors.New("invalid username or password")
}
token, err := middleware.GenerateToken(user.ID, user.Username, user.Role)
token, err := middleware.GenerateToken(&user)
if err != nil {
return nil, "", errors.New("failed to generate token")
}
@@ -67,11 +75,11 @@ func (s *AuthService) Register(username, displayName, email, phone, password, ro
user := model.User{
Username: username,
DisplayName: displayName,
Email: email,
Phone: phone,
Email: stringPtr(email),
Phone: stringPtr(phone),
HashedPassword: string(hashedPassword),
Role: role,
IsActive: true,
IsActive: boolPtr(true),
}
if err := s.DB.Create(&user).Error; err != nil {
+6 -4
View File
@@ -19,14 +19,16 @@ func NewTicketService(db *gorm.DB) *TicketService {
// CreateTicket creates a new ticket
func (s *TicketService) CreateTicket(ticket *model.Ticket) error {
if ticket.TicketNo == "" {
ticket.TicketNo = s.generateTicketNo()
if ticket.TicketNo == nil || *ticket.TicketNo == "" {
ticketNo := s.generateTicketNo()
ticket.TicketNo = &ticketNo
}
if ticket.CreateTime.IsZero() {
ticket.CreateTime = time.Now()
}
if ticket.Status == "" {
ticket.Status = "pending"
if ticket.Status == nil || *ticket.Status == "" {
status := "pending"
ticket.Status = &status
}
return s.DB.Create(ticket).Error
}
-2
View File
@@ -1,5 +1,3 @@
version: '3.8'
services:
backend:
build: ./backend
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+4 -1677
View File
File diff suppressed because it is too large. Load diff
+2 -3
View File
@@ -1,7 +1,7 @@
{
"name": "my-app",
"name": "opsxsql-frontend",
"private": true,
"version": "0.0.0",
"version": "0.4.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -82,7 +82,6 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"kimi-plugin-inspect-react": "^1.0.3",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
+32 -20
View File
@@ -1,31 +1,41 @@
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom';
import { AuthProvider, useAuth } from '@/hooks/useAuth';
import { ThemeProvider } from '@/hooks/useTheme';
import Layout from '@/components/Layout';
import LoginPage from '@/pages/LoginPage';
import Dashboard from '@/pages/Dashboard';
import InstancePage from '@/pages/InstancePage';
import TicketsPage from '@/pages/TicketsPage';
import QueriesPage from '@/pages/QueriesPage';
import RolesPage from '@/pages/RolesPage';
import UsersPage from '@/pages/UsersPage';
import AuditRulesPage from '@/pages/AuditRulesPage';
import WorkflowPage from '@/pages/WorkflowPage';
import AutoTaskPage from '@/pages/AutoTaskPage';
import SettingsPage from '@/pages/SettingsPage';
import AuditLogsPage from '@/pages/AuditLogsPage';
import DataDictionaryPage from '@/pages/DataDictionaryPage';
import InstanceDetailPage from '@/pages/InstanceDetailPage';
import RedisDetailPage from '@/pages/RedisDetailPage';
import MongoDBDetailPage from '@/pages/MongoDBDetailPage';
import TicketCreatePage from '@/pages/TicketCreatePage';
import TicketDetailPage from '@/pages/TicketDetailPage';
import InstanceGroupPage from '@/pages/InstanceGroupPage';
import { ToastContainer } from '@/components/ToastContainer';
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
const Dashboard = lazy(() => import('@/pages/Dashboard'));
const InstancePage = lazy(() => import('@/pages/InstancePage'));
const TicketsPage = lazy(() => import('@/pages/TicketsPage'));
const QueriesPage = lazy(() => import('@/pages/QueriesPage'));
const RolesPage = lazy(() => import('@/pages/RolesPage'));
const UsersPage = lazy(() => import('@/pages/UsersPage'));
const AuditRulesPage = lazy(() => import('@/pages/AuditRulesPage'));
const WorkflowPage = lazy(() => import('@/pages/WorkflowPage'));
const AutoTaskPage = lazy(() => import('@/pages/AutoTaskPage'));
const SettingsPage = lazy(() => import('@/pages/SettingsPage'));
const AuditLogsPage = lazy(() => import('@/pages/AuditLogsPage'));
const DataDictionaryPage = lazy(() => import('@/pages/DataDictionaryPage'));
const InstanceDetailPage = lazy(() => import('@/pages/InstanceDetailPage'));
const RedisDetailPage = lazy(() => import('@/pages/RedisDetailPage'));
const MongoDBDetailPage = lazy(() => import('@/pages/MongoDBDetailPage'));
const TicketCreatePage = lazy(() => import('@/pages/TicketCreatePage'));
const TicketDetailPage = lazy(() => import('@/pages/TicketDetailPage'));
const InstanceGroupPage = lazy(() => import('@/pages/InstanceGroupPage'));
function PageFallback() {
return (
<div className="flex h-full items-center justify-center text-sm text-[var(--dg-text-muted)]">
加载中...
</div>
);
}
function RouteGuard() {
const { isLoggedIn, hasPermission } = useAuth();
const location = useLocation();
const path = location.pathname.split('/')[1] || 'dashboard';
if (!isLoggedIn) {
@@ -51,6 +61,7 @@ function LoginGuard() {
function AppRoutes() {
return (
<Suspense fallback={<PageFallback />}>
<Routes>
<Route path="/login" element={<LoginGuard />} />
<Route element={<RouteGuard />}>
@@ -78,6 +89,7 @@ function AppRoutes() {
</Route>
</Route>
</Routes>
</Suspense>
);
}
+82 -3
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect } from 'react';
import { X, Loader2 } from 'lucide-react';
import { X, Loader2, Zap, CheckCircle, XCircle } from 'lucide-react';
import { instanceApi } from '@/api/instance';
import { workflowApi } from '@/api/workflow';
@@ -21,6 +21,7 @@ interface InstanceData {
excluded_databases?: string[] | null;
mask_fields?: string | null;
group_id?: number | null;
cluster_mode?: boolean;
}
interface WorkflowItem {
@@ -49,6 +50,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
const [database, setDatabase] = useState(instance.database || '');
const [env, setEnv] = useState(instance.env || 'development');
const [accessMode, setAccessMode] = useState(instance.access_mode || 'readwrite');
const [clusterMode, setClusterMode] = useState(instance.cluster_mode === true);
const [workflowId, setWorkflowId] = useState<number | null>(instance.workflow_id || null);
const [workflows, setWorkflows] = useState<WorkflowItem[]>([]);
// excluded_databases: comma-separated input
@@ -58,6 +60,8 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
// mask_fields: comma-separated field names
const [maskFields, setMaskFields] = useState(instance.mask_fields || '');
const [submitting, setSubmitting] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string; cluster_mode?: boolean } | null>(null);
const [error, setError] = useState('');
/* Sync when instance changes */
@@ -70,11 +74,13 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
setDatabase(instance.database || '');
setEnv(instance.env || 'development');
setAccessMode(instance.access_mode || 'readwrite');
setClusterMode(instance.cluster_mode === true);
setWorkflowId(instance.workflow_id || null);
setExcludedDbs(
Array.isArray(instance.excluded_databases) ? (instance.excluded_databases as string[]).join(', ') : (instance.excluded_databases || '')
);
setMaskFields(instance.mask_fields || '');
setTestResult(null);
setError('');
}, [instance]);
@@ -105,6 +111,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
database: database.trim() || undefined,
env,
access_mode: accessMode,
cluster_mode: instance.db_type === 'redis' ? clusterMode : undefined,
workflow_id: workflowId,
excluded_databases: parsedExcluded,
mask_fields: maskFields.trim() || undefined,
@@ -129,7 +136,35 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
} finally {
setSubmitting(false);
}
}, [name, host, port, username, password, database, env, accessMode, workflowId, excludedDbs, maskFields, instance.id, onSaved]);
}, [name, host, port, username, password, database, env, accessMode, clusterMode, workflowId, excludedDbs, maskFields, instance.id, instance.db_type, onSaved]);
const handleTest = useCallback(async () => {
setTesting(true);
setTestResult(null);
setError('');
try {
const payload = {
db_type: instance.db_type,
host: host.trim(),
port: Number(port),
username: username.trim() || undefined,
password: password || '',
database: database.trim() || undefined,
cluster_mode: instance.db_type === 'redis' ? clusterMode : undefined,
};
const res = mode === 'edit' && !password
? await instanceApi.testConnection(instance.id)
: await instanceApi.testNewConnection(payload);
setTestResult(res as { success: boolean; message: string; version?: string; cluster_mode?: boolean });
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string; message?: string } } })?.response?.data?.detail
|| (e as { response?: { data?: { message?: string } } })?.response?.data?.message
|| '连接失败';
setTestResult({ success: false, message: msg });
} finally {
setTesting(false);
}
}, [instance.db_type, instance.id, host, port, username, password, database, clusterMode, mode]);
if (!open) return null;
@@ -144,7 +179,10 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4">
<div>
<h2 className="text-base font-semibold">{mode === 'clone' ? '克隆数据源' : '编辑数据源'}</h2>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{instance.db_type.toUpperCase()} · {instance.host}:{instance.port}</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
<X className="w-4 h-4" />
</button>
@@ -186,9 +224,35 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
<div>
<label className={labelCls}>默认数据库</label>
<input className={inputCls} value={database} onChange={e => setDatabase(e.target.value)} placeholder="可选" />
<input className={inputCls} value={database} onChange={e => setDatabase(e.target.value)} placeholder={instance.db_type === 'redis' ? 'Redis 数据库编号,默认 0' : '可选'} />
</div>
{instance.db_type === 'redis' && (
<div>
<label className={labelCls}>Redis 部署模式</label>
<div className="grid grid-cols-2 gap-3">
{[
{ value: false, label: '单节点', desc: '普通 Redis 或哨兵入口' },
{ value: true, label: '集群', desc: 'Redis Cluster,按 slot 路由' },
].map(item => (
<button
key={item.label}
type="button"
onClick={() => { setClusterMode(item.value); setTestResult(null); }}
className={`text-left p-3 rounded-lg border transition-colors ${
clusterMode === item.value
? 'bg-blue-500/10 border-blue-500/40 text-blue-400'
: 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<div className="text-sm font-medium">{item.label}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{item.desc}</div>
</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>环境</label>
@@ -220,6 +284,13 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
{workflows.length === 0 && <p className="text-xs text-[var(--dg-text-muted)] mt-1">暂无可用审批流程</p>}
</div>
{testResult && (
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
<span>{testResult.success ? `连接成功${testResult.version ? ` · ${testResult.version}` : ''}${instance.db_type === 'redis' ? ` · ${testResult.cluster_mode ? '集群' : '单节点'}` : ''}` : testResult.message}</span>
</div>
)}
{/* 排除数据库 */}
<div>
<label className={labelCls}>排除数据库 <span className="font-normal text-[var(--dg-text-muted)]">(逗号分隔,如 sys, mysql)</span></label>
@@ -245,6 +316,14 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4">
<button
onClick={handleTest}
disabled={testing || !host || !port}
className="mr-auto flex items-center gap-2 px-4 py-2 text-sm bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg hover:bg-[var(--dg-bg-hover)] disabled:opacity-50 transition-colors"
>
{testing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />}
{testing ? '测试中...' : '测试连接'}
</button>
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">取消</button>
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors">
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
+2 -1
View File
@@ -9,6 +9,7 @@ import {
import { menuSections, menuItems } from '@/constants/menu';
import { useAuth } from '@/hooks/useAuth';
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
import request from '@/api/request';
const iconMap: Record<string, React.ElementType> = {
LayoutDashboard, Database, Server, Zap, Leaf,
@@ -25,7 +26,7 @@ export default function Layout() {
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
import('@/api/request').then(m => m.default.get('/dashboard/stats'))
request.get('/dashboard/stats')
.then((data: any) => setPendingCount(data?.ticket_pending ?? 0))
.catch(() => {});
}, [location.pathname]);
+18
View File
@@ -321,6 +321,24 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
if (isRedis && instanceId) fetchRedisKeys();
}, [isRedis, instanceId, fetchRedisKeys]);
useEffect(() => {
const keyword = redisPattern.trim();
if (!isRedis || redisKeys.length === 0 || !keyword || keyword === '*') return;
setRedisExpanded(prev => {
const next = new Set(prev);
redisKeys.forEach(item => {
const parts = item.key.split(':');
let prefix = '';
for (let i = 0; i < parts.length - 1; i++) {
prefix += (i === 0 ? '' : ':') + parts[i];
next.add(prefix);
}
});
return next;
});
}, [isRedis, redisKeys, redisPattern]);
/* ---------------------------------------------------------------- */
/* Fetch Redis key detail */
/* ---------------------------------------------------------------- */
+1 -1
View File
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-lg border py-6 shadow-sm",
className
)}
{...props}
+5 -8
View File
@@ -352,11 +352,11 @@
@layer utilities {
.dg-card {
@apply bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl;
@apply bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg;
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.dg-card-hover {
@apply transition-all duration-300 hover:-translate-y-0.5;
@apply transition-colors duration-200;
}
.dg-card-hover:hover {
border-color: var(--dg-card-hover-border);
@@ -374,16 +374,13 @@
color: var(--dg-text-secondary);
}
.dg-gradient-text {
background: linear-gradient(to right, var(--dg-gradient-text-start), var(--dg-gradient-text-end));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: var(--dg-text-primary);
}
.dg-gradient-bg {
background: linear-gradient(to right, var(--dg-gradient-start), var(--dg-gradient-end));
background: var(--dg-blue);
}
.dg-gradient-br {
background: linear-gradient(to bottom right, var(--dg-gradient-start), var(--dg-gradient-end));
background: var(--dg-blue);
}
.scrollbar-thin::-webkit-scrollbar {
width: 4px;
+1 -1
View File
@@ -125,7 +125,7 @@ export const queryTemplates: QueryTemplate[] = [
category: '性能分析',
sql: {
mysql: 'EXPLAIN SELECT ...',
postgresql: 'EXPLAIN ANALYZE SELECT ...',
postgresql: 'EXPLAIN SELECT ...',
mongodb: 'db.{collection}.find({...}).explain()',
},
supportedTypes: ['mysql', 'postgresql', 'mongodb'],
+26
View File
@@ -234,6 +234,13 @@ export default function AuditRulesPage() {
return groups;
}, [filteredRules]);
const ruleStats = useMemo(() => ({
total: rules.length,
enabled: rules.filter(rule => rule.is_enabled).length,
error: rules.filter(rule => rule.severity === 'error').length,
warning: rules.filter(rule => rule.severity === 'warning').length,
}), [rules]);
/* ---------------------------------------------------------------- */
/* Render helpers */
/* ---------------------------------------------------------------- */
@@ -441,6 +448,25 @@ export default function AuditRulesPage() {
</button>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="dg-card p-4">
<div className="text-xs text-[var(--dg-text-muted)]">规则总数</div>
<div className="text-2xl font-semibold mt-1">{ruleStats.total}</div>
</div>
<div className="dg-card p-4">
<div className="text-xs text-[var(--dg-text-muted)]">已启用</div>
<div className="text-2xl font-semibold mt-1 text-emerald-400">{ruleStats.enabled}</div>
</div>
<div className="dg-card p-4">
<div className="text-xs text-[var(--dg-text-muted)]">错误级别</div>
<div className="text-2xl font-semibold mt-1 text-red-400">{ruleStats.error}</div>
</div>
<div className="dg-card p-4">
<div className="text-xs text-[var(--dg-text-muted)]">警告级别</div>
<div className="text-2xl font-semibold mt-1 text-amber-400">{ruleStats.warning}</div>
</div>
</div>
{/* DB Type Tabs */}
<div className="dg-card p-1.5 flex gap-1 flex-wrap">
{DB_TABS.map((tab) => {
+8 -10
View File
@@ -11,6 +11,7 @@ import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants';
import { useTheme } from '@/hooks/useTheme';
import { useAuth } from '@/hooks/useAuth';
import { dashboardApi } from '@/api/dashboard';
import request from '@/api/request';
import type { DashboardStats } from '@/types';
/* ------------------------------------------------------------------ */
@@ -83,7 +84,7 @@ export default function Dashboard() {
const [queryStats, setQueryStats] = useState<any>(null);
useEffect(() => {
import('@/api/request').then(m => m.default.get('/announcements')).then(res => {
request.get('/announcements').then(res => {
const list = Array.isArray(res) ? res : [];
setAnnouncements(list.map((a: any) => ({
id: String(a.id),
@@ -114,8 +115,7 @@ export default function Dashboard() {
/* ---- Instances & Tickets ---- */
useEffect(() => {
import('@/api/request').then(m => {
m.default.get('/instances').then((res: any) => {
request.get('/instances').then((res: any) => {
const list = Array.isArray(res) ? res : (res?.items || res?.data || []);
setDbInstances(list.map((i: any) => ({
id: i.id, name: i.name, dbType: i.db_type || i.dbType,
@@ -125,11 +125,10 @@ export default function Dashboard() {
qps: i.qps ?? 0, connections: i.connections ?? 0,
})));
}).catch(() => setDbInstances([]));
m.default.get('/dashboard/my-tickets').then((res: any) => {
request.get('/dashboard/my-tickets').then((res: any) => {
const list = Array.isArray(res) ? res : [];
setTickets(list);
}).catch(() => setTickets([]));
});
}, []);
/* ---- Announcements ---- */
@@ -245,21 +244,20 @@ export default function Dashboard() {
)}
{/* ---- Stats Cards ---- */}
<div className="grid grid-cols-3 gap-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{loading ? (
Array.from({ length: 3 }).map((_, i) => <StatCardSkeleton key={i} />)
) : (
statsCards.map((s, i) => (
<Card key={i} className="dg-card dg-card-hover relative overflow-hidden">
<div className={`absolute top-0 right-0 w-20 h-20 ${s.bg} rounded-bl-[40px] opacity-30`} />
<CardContent className="p-5 relative z-10">
<Card key={i} className="dg-card dg-card-hover overflow-hidden">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-[var(--dg-text-secondary)] font-medium">{s.title}</span>
<div className={`w-9 h-9 rounded-lg ${s.bg} flex items-center justify-center`}>
<s.icon className={`w-4.5 h-4.5 ${s.color}`} />
</div>
</div>
<div className="text-4xl font-bold text-[var(--dg-text-primary)] tracking-tight">{s.value}</div>
<div className="text-3xl font-semibold text-[var(--dg-text-primary)]">{s.value}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-2">{s.subtitle}</div>
</CardContent>
</Card>
+199 -6
View File
@@ -5,6 +5,7 @@ import {
XCircle, Eye, EyeOff, ChevronLeft, Gauge, Wifi,
WifiOff, AlertTriangle, CheckCircle, Signal,
LayoutGrid, ListFilter, X, Loader2, BarChart3, Users, Copy,
Upload,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useActionPermission } from '@/hooks/useAuth';
@@ -141,6 +142,7 @@ export default function InstancePage() {
const [instances, setInstances] = useState<InstanceItem[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [showQuickImport, setShowQuickImport] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [editingInstance, setEditingInstance] = useState<InstanceItem | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
@@ -316,6 +318,18 @@ export default function InstancePage() {
action={canAdd ? { label: '添加实例', icon: Plus, onClick: () => setShowCreate(true), permission: 'instance.add' } : undefined}
/>
{canAdd && (
<div className="flex justify-end">
<button
onClick={() => setShowQuickImport(true)}
className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
>
<Upload className="w-4 h-4" />
快速导入
</button>
</div>
)}
{/* Type Tabs */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg w-fit">
@@ -717,10 +731,161 @@ export default function InstancePage() {
mode="clone"
/>
)}
{showQuickImport && (
<QuickImportDialog
onClose={() => setShowQuickImport(false)}
onImported={() => { setShowQuickImport(false); fetchInstances(); }}
/>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Quick Import Dialog */
/* ------------------------------------------------------------------ */
type ImportRow = {
name: string;
db_type: string;
host: string;
port: number;
username?: string;
password?: string;
database?: string;
env?: string;
access_mode?: string;
cluster_mode?: boolean;
};
const IMPORT_TEMPLATE = `name,db_type,host,port,username,password,database,env,access_mode,cluster_mode
prod-mysql,mysql,10.0.0.10,3306,root,secret,app,production,readwrite,false
prod-pg,postgres,10.0.0.11,5432,postgres,secret,app,production,read,false
prod-redis,redis,10.0.0.12,6379,,secret,,production,read,true
prod-mongo,mongo,10.0.0.13,27017,root,secret,admin,production,readwrite,false`;
function parseQuickImport(text: string): ImportRow[] {
const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
if (lines.length === 0) return [];
const hasHeader = lines[0].toLowerCase().startsWith('name,');
const dataLines = hasHeader ? lines.slice(1) : lines;
return dataLines.map(line => {
const parts = line.split(',').map(item => item.trim());
const [name, dbType, host, port, username, password, database, env, accessMode, clusterMode] = parts;
return {
name,
db_type: dbType,
host,
port: Number(port || DB_DEFAULT_PORTS[dbType] || 3306),
username: username || undefined,
password: password || undefined,
database: database || undefined,
env: env || 'development',
access_mode: accessMode || 'readwrite',
cluster_mode: clusterMode === 'true' || clusterMode === '1' || clusterMode === 'yes',
};
});
}
function QuickImportDialog({ onClose, onImported }: { onClose: () => void; onImported: () => void }) {
const [text, setText] = useState(IMPORT_TEMPLATE);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ success: number; failed: string[] } | null>(null);
const rows = useMemo(() => parseQuickImport(text), [text]);
const validRows = rows.filter(row => row.name && row.db_type && row.host && row.port);
const handleImport = useCallback(async () => {
setSubmitting(true);
const failed: string[] = [];
let success = 0;
for (const row of validRows) {
try {
await instanceApi.create({
...row,
username: row.db_type === 'redis' ? row.username : (row.username || 'default'),
password: row.password || '',
cluster_mode: row.db_type === 'redis' ? row.cluster_mode : undefined,
});
success += 1;
} catch (e: unknown) {
const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '创建失败';
failed.push(`${row.name || row.host}: ${detail}`);
}
}
setResult({ success, failed });
setSubmitting(false);
if (success > 0 && failed.length === 0) onImported();
}, [validRows, onImported]);
return (
<ModalOverlay open={true} onClose={onClose} width="max-w-4xl">
<div className="bg-[var(--dg-bg-secondary)] rounded-lg shadow-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--dg-border)]">
<div>
<h2 className="text-base font-semibold">快速导入数据源</h2>
<p className="text-xs text-[var(--dg-text-muted)] mt-1">支持 MySQL、PostgreSQL、Redis、MongoDB。每行一个实例,逗号分隔。</p>
</div>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]">
<X className="w-4 h-4" />
</button>
</div>
<div className="p-6 space-y-4">
<textarea
value={text}
onChange={e => setText(e.target.value)}
className="w-full min-h-[220px] px-4 py-3 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-xs font-mono focus:border-blue-400 focus:outline-none resize-y"
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
<div className="text-sm font-medium mb-2">字段说明</div>
<div className="text-xs text-[var(--dg-text-muted)] leading-6">
name, db_type, host, port, username, password, database, env, access_mode, cluster_mode
<br />
db_type 使用 mysql / postgres / redis / mongo;Redis 集群将 cluster_mode 写 true。
</div>
</div>
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
<div className="text-sm font-medium mb-2">预览</div>
<div className="text-xs text-[var(--dg-text-muted)]">
共解析 {rows.length} 行,可导入 {validRows.length} 行。
</div>
<div className="mt-2 max-h-24 overflow-y-auto space-y-1">
{validRows.slice(0, 5).map(row => (
<div key={`${row.name}-${row.host}`} className="text-xs">
{row.name} · {row.db_type} · {row.host}:{row.port}{row.db_type === 'redis' && row.cluster_mode ? ' · 集群' : ''}
</div>
))}
</div>
</div>
</div>
{result && (
<div className={`p-3 rounded-lg text-sm ${result.failed.length === 0 ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-400'}`}>
成功 {result.success} 个,失败 {result.failed.length} 个
{result.failed.length > 0 && (
<div className="mt-2 text-xs space-y-1">
{result.failed.map(item => <div key={item}>{item}</div>)}
</div>
)}
</div>
)}
<div className="flex justify-end gap-3">
<button onClick={onClose} className="px-4 py-2 text-sm bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg hover:bg-[var(--dg-bg-hover)]">
取消
</button>
<button
onClick={handleImport}
disabled={submitting || validRows.length === 0}
className="flex items-center gap-2 px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg"
>
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
导入 {validRows.length} 个
</button>
</div>
</div>
</div>
</ModalOverlay>
);
}
/* ------------------------------------------------------------------ */
/* InstanceDetail */
/* ------------------------------------------------------------------ */
@@ -1012,6 +1177,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
const [showPwd, setShowPwd] = useState(false);
const [database, setDatabase] = useState('');
const [redisDb, setRedisDb] = useState('0');
const [redisClusterMode, setRedisClusterMode] = useState(false);
const [env, setEnv] = useState('development');
const [customEnv, setCustomEnv] = useState('');
const [accessMode, setAccessMode] = useState<'readwrite' | 'read' | 'write'>('readwrite');
@@ -1020,7 +1186,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string; cluster_mode?: boolean } | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null);
const isRedis = dbType === 'redis';
@@ -1033,7 +1199,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
setDbType(t);
setPort(String(DB_DEFAULT_PORTS[t] || 3306));
setTestResult(null);
if (t === 'redis') { setDatabase(''); }
if (t === 'redis') { setDatabase(''); } else { setRedisClusterMode(false); }
}, []);
const handleSubmit = useCallback(async () => {
@@ -1047,6 +1213,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
username: isRedis ? (username || undefined) : (username || 'default'),
password: password || 'default',
database: database || undefined,
cluster_mode: isRedis ? redisClusterMode : undefined,
env: (finalEnv || 'development') as string,
access_mode: accessMode,
excluded_databases: excludedDbs ? excludedDbs.split(',').map(s => s.trim()).filter(Boolean) : undefined,
@@ -1062,7 +1229,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
} finally {
setSubmitting(false);
}
}, [name, host, port, dbType, username, password, database, finalEnv, accessMode, excludedDbs, maskFields, needUsername, onCreated]);
}, [name, host, port, dbType, username, password, database, redisClusterMode, finalEnv, accessMode, excludedDbs, maskFields, needUsername, isRedis, onCreated]);
const handleTestConnection = useCallback(async () => {
if (!host || !port) { setError('请填写地址和端口'); return; }
@@ -1074,16 +1241,17 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
username: isRedis ? (username || undefined) : (username || 'default'),
password: password || 'default',
database: database || undefined,
cluster_mode: isRedis ? redisClusterMode : undefined,
env: (finalEnv || 'development') as string, access_mode: accessMode,
});
setTestResult(res as { success: boolean; message: string; version?: string });
setTestResult(res as { success: boolean; message: string; version?: string; cluster_mode?: boolean });
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '连接失败';
setTestResult({ success: false, message: msg });
} finally {
setTesting(false);
}
}, [host, port, dbType, username, password, database, finalEnv, accessMode, needUsername, name]);
}, [host, port, dbType, username, password, database, redisClusterMode, finalEnv, accessMode, needUsername, name, isRedis]);
const validateStep = useCallback((step: number): boolean => {
setError('');
@@ -1314,6 +1482,31 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
</div>
)}
</div>
{isRedis && (
<div>
<label className={labelCls}>Redis 部署模式</label>
<div className="grid grid-cols-2 gap-3">
{[
{ value: false, label: '单节点', desc: '普通 Redis 或哨兵入口,查询只访问当前实例' },
{ value: true, label: '集群', desc: 'Redis Cluster,按 slot 路由并扫描所有 master' },
].map(mode => (
<button
key={mode.label}
type="button"
onClick={() => { setRedisClusterMode(mode.value); setTestResult(null); }}
className={`text-left p-3 rounded-lg border transition-colors ${
redisClusterMode === mode.value
? 'bg-blue-500/10 border-blue-500/40 text-blue-400'
: 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<div className="text-sm font-medium">{mode.label}</div>
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{mode.desc}</div>
</button>
))}
</div>
</div>
)}
{/* Connection Mode */}
<div>
<label className={labelCls}>连接模式</label>
@@ -1342,7 +1535,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
{testResult && (
<div className={`flex items-center gap-3 p-3 rounded-lg text-sm ${testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{testResult.success
? <><CheckCircle className="w-4 h-4 flex-shrink-0" /> 连接成功{testResult.version ? ` · ${testResult.version}` : ''}</>
? <><CheckCircle className="w-4 h-4 flex-shrink-0" /> 连接成功{testResult.version ? ` · ${testResult.version}` : ''}{isRedis ? ` · ${testResult.cluster_mode ? '集群' : '单节点'}` : ''}</>
: <><XCircle className="w-4 h-4 flex-shrink-0" /> {testResult.message}</>
}
</div>
+20 -1
View File
@@ -152,6 +152,10 @@ export default function PlatformConfigPage() {
// 按 GROUP_ORDER 排序,过滤隐藏分组
const visibleGroups = GROUP_ORDER.filter(g => settings[g]);
const hasChanges = Object.keys(changed).length > 0;
const totalItems = visibleGroups.reduce((sum, group) => sum + (settings[group]?.length || 0), 0);
const enabledBooleanItems = visibleGroups.reduce((sum, group) => {
return sum + (settings[group] || []).filter(item => item.value_type === 'boolean' && displayValue(item) === 'true').length;
}, 0);
return (
<div className="space-y-6">
@@ -166,13 +170,28 @@ export default function PlatformConfigPage() {
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="dg-card p-4">
<div className="text-xs text-[var(--dg-text-muted)]">配置项</div>
<div className="text-2xl font-semibold mt-1">{totalItems}</div>
</div>
<div className="dg-card p-4">
<div className="text-xs text-[var(--dg-text-muted)]">已启用策略</div>
<div className="text-2xl font-semibold mt-1 text-emerald-400">{enabledBooleanItems}</div>
</div>
<div className="dg-card p-4">
<div className="text-xs text-[var(--dg-text-muted)]">待保存修改</div>
<div className={`text-2xl font-semibold mt-1 ${hasChanges ? 'text-amber-400' : ''}`}>{Object.keys(changed).length}</div>
</div>
</div>
{/* 各分组 */}
{visibleGroups.map(group => {
const meta = GROUP_META[group] || { label: group, icon: '⚙️', desc: '' };
const items = (settings[group] || []).filter((it: SettingItem) => !HIDDEN_GROUPS.has(it.group));
return (
<div key={group} className="bg-[var(--dg-bg-secondary)] rounded-xl overflow-hidden">
<div key={group} className="dg-card overflow-hidden">
{/* 分组头 */}
<div className="px-5 py-3 flex items-center gap-3">
<span className="text-lg">{meta.icon}</span>
+22 -2
View File
@@ -57,13 +57,15 @@ type SortState = { column: string | null; direction: SortDirection };
/* Constants */
/* ------------------------------------------------------------------ */
const dbTypeLabel: Record<string, string> = {
mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB',
mysql: 'MySQL', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB', mongodb: 'MongoDB',
};
const dbTypeIcon: Record<string, string> = {
mysql: '/db-logos/mysql.svg',
postgres: '/db-logos/postgres.svg',
postgresql: '/db-logos/postgres.svg',
redis: '/db-logos/redis.svg',
mongo: '/db-logos/mongo.svg',
mongodb: '/db-logos/mongo.svg',
};
@@ -77,6 +79,20 @@ const placeholderMap: Record<DBType, string> = {
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
};
function normalizeDbType(dbType?: string): DBType {
if (dbType === 'postgres' || dbType === 'pg') return 'postgresql';
if (dbType === 'mongo') return 'mongodb';
if (dbType === 'redis') return 'redis';
return 'mysql';
}
const queryPolicyText: Record<DBType, string> = {
mysql: '查询入口仅允许 SELECT / SHOW / DESCRIBE / EXPLAIN;禁止 UPDATE/DELETE/DDL、多语句、SELECT FOR UPDATE。未写 LIMIT 时按平台配置自动限制。',
postgresql: '查询入口仅允许 SELECT / EXPLAIN;禁止 EXPLAIN ANALYZE、写入 CTE、多语句和锁定读。未写 LIMIT 时按平台配置自动限制。',
redis: 'Redis 查询入口仅允许只读命令;写命令请提交变更工单。集群实例会按 slot 路由并扫描所有 master。',
mongodb: 'MongoDB 查询入口用于 find / aggregate / count / distinct 等只读操作;写操作请提交变更工单。',
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
@@ -184,7 +200,7 @@ export default function QueriesPage() {
}, [instanceId, instances]);
const selectedInstance = useMemo(() => instances.find(inst => String(inst.id) === instanceId), [instances, instanceId]);
const dbType = (selectedInstance?.db_type || 'mysql') as DBType;
const dbType = normalizeDbType(selectedInstance?.db_type);
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
useEffect(() => {
@@ -505,6 +521,10 @@ export default function QueriesPage() {
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Shift+F 格式化</span>
</div>
</div>
<div className="px-3 py-2 border-t border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[11px] text-[var(--dg-text-muted)] flex items-start gap-2">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 mt-0.5 shrink-0" />
<span>{queryPolicyText[dbType]}</span>
</div>
</div>
</div>
+12
View File
@@ -5,6 +5,18 @@ import { defineConfig } from "vite"
export default defineConfig({
base: './',
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return;
if (id.includes('react') || id.includes('react-router')) return 'vendor-react';
if (id.includes('@codemirror')) return 'vendor-editor';
if (id.includes('@radix-ui')) return 'vendor-ui';
},
},
},
},
server: {
port: 5173,
proxy: {
-238
View File
@@ -1,238 +0,0 @@
# 优化包合并分析报告
## 📋 总览
| 文件 | 行数变化 | 冲突模型引用 | 安全合并? | 策略 |
|------|---------|------------|----------|------|
| models/user.py | 23→299 (+276) | ⚠️ **核心冲突** | ❌ | 手动合并 |
| models/workflow.py | 55→362 (+307) | ✅ 无 | ✅ | 直接覆盖 |
| services/workflow_engine.py | 359→1319 (+960) | ✅ 无 | ✅ | 直接覆盖 |
| services/query_service.py | 709→1253 (+544) | ✅ 无 | ✅ | 直接覆盖 |
| services/audit_engine.py | 234→790 (+556) | ✅ 无 | ✅ | 直接覆盖 |
| services/ticket_executor.py | 1384→1688 (+304) | ✅ 无 | ✅ | 直接覆盖 |
| api/v1/endpoints/ticket.py | 747→1029 (+282) | ⚠️ AuditLog引用 | ✅ | 直接覆盖 |
| api/v1/endpoints/query.py | 226→426 (+200) | ⚠️ AuditLog引用 | ✅ | 直接覆盖 |
| api/v1/endpoints/user.py | 116→116 (0) | ⚠️ AuditLogService | ✅ | 直接覆盖 |
---
## 🔴 冲突分析
### 冲突1:`user_groups` 表名冲突
| 维度 | 我们的版本 | 优化包版本 |
|------|-----------|-----------|
| **位置** | `models/instance_group.py` | `models/user.py` |
| **类型** | `Table()` 关联表 | `class UserGroup(Base)` ORM模型 |
| **表名** | `user_groups` | `user_groups` |
| **列** | user_id, group_id, permissions | id, name, description, group_permissions, ... |
| **用途** | User ↔ InstanceGroup 多对多关联 | 独立的用户组权限管理实体 |
**冲突本质**:两个完全不同的数据库表结构映射到同一个表名。优化包的 `UserGroup` 是一个完整的权限组系统,我们的 `user_groups` 只是一个简单的关联表。
### 冲突2:AuditLog 双重定义
| 维度 | 我们的版本 (`models/audit_log.py`) | 优化包版本 (`models/user.py`) |
|------|----------------------------------|-------------------------------|
| **表名** | `audit_logs` | `audit_logs` |
| **字段风格** | 运维审计(target_type, query_content, db_type) | 通用审计(resource_type, before_data, after_data) |
| **关系** | 无 relationship | `user = relationship("User", ...)` |
**关键发现**:优化包的 `ticket.py` 和 `query.py` 都 `from app.models.audit_log import AuditLog`(即使用的是我们的版本),而不是从 `models.user` 导入!
### 冲突3:新增模型类(无冲突但需数据库迁移)
优化包 `models/user.py` 新增了:
- `UserGroup` — 用户组模型(表名与我们的关联表冲突)
- `UserGroupMember` — 用户组成员关联表
- `AuditLog` — 审计日志(与 `models/audit_log.py` 重复定义)
---
## 📁 逐文件分析
### 1. models/user.py — ⚠️ 需手动合并
**差异概要**:
- User 模型新增了 4 个 JSON 权限字段(data_source_permissions, custom_permissions, column_permissions, permissions_updated_at)
- User 模型新增了 5 个 relationship(tickets, approvals, audit_logs, query_histories, user_groups)
- User 模型新增了 5 个方法(has_permission, has_data_source_permission, get_allowed_columns, to_dict, __repr__)
- 新增 3 个类:UserGroup, UserGroupMember, AuditLog
**冲突点**:
- `secondary="user_groups"` — 与 instance_group.py 的 Table 定义冲突
- `user_groups = relationship("UserGroup", ...)` — 引用同文件 UserGroup
- `audit_logs = relationship("AuditLog", ...)` — 引用同文件 AuditLog
- `to_dict()` 中 `self.user_groups` — 引用 UserGroup relationship
**合并策略**:
```
✅ 直接采纳:User 模型的 JSON 权限字段(4个新字段)
✅ 直接采纳:User 的便捷方法(has_permission, to_dict 等)
⚠️ 需修改:User.groups 的 secondary 保持 "user_groups"(不改,与 instance_group.py 兼容)
❌ 不采纳:User.user_groups relationship(引用冲突的 UserGroup)
❌ 不采纳:User.tickets/approvals/audit_logs/query_histories(跨模型耦合,按需添加)
❌ 不采纳:UserGroup 类(表名冲突)
❌ 不采纳:UserGroupMember 类(依赖 UserGroup)
❌ 不采纳:AuditLog 类(已在 audit_log.py 独立定义)
⚠️ to_dict() 中的 user_groups 引用需要改为 groups 或移除
```
### 2. models/workflow.py — ✅ 可安全覆盖
**差异概要**:
- WorkflowDefinition 新增 category, updated_at, created_by, flow_config, trigger_conditions 字段
- 新增 WorkflowVersion 类(版本控制)
- WorkflowStep 大幅增强(description, approver_assignment, entry_conditions, transition_conditions, field_permissions)
- TicketApproval 大幅增强(workflow_id, version_id, step_type, actual_approver_id, transfer 信息, 超时标记)
- 新增 WorkflowExecutionLog 类(执行日志)
**新增依赖**:`json`, `UniqueConstraint`, `JSON` — 标准库/SQLAlchemy
**安全评估**:无冲突模型引用,纯功能增强。✅
### 3. services/workflow_engine.py — ✅ 可安全覆盖
**差异概要**:
- 从简单引擎升级为支持条件审批、超时策略、中断恢复的完整引擎
- 新增 WorkflowStatus/ApprovalAction/TimeoutStrategy 枚举
- 新增 ConditionResult/ApproverAssignment 数据类
- 引擎核心方法大幅扩展
**import 检查**:
- `from app.models.workflow import (WorkflowDefinition, WorkflowStep, WorkflowVersion, TicketApproval, WorkflowExecutionLog)` — 全部来自 workflow.py
- `from app.models.user import User` — 只用 User 基础字段
- 无冲突模型引用
### 4. services/query_service.py — ✅ 可安全覆盖
**差异概要**:查询服务增强,支持更多数据库类型和查询优化。
**import 检查**:
- `from app.models.instance import Instance` — ✅
- `from app.schemas.query import UnifiedQueryResult` — ✅
- `from app.services.db_connector import DBConnector` — ✅
- 无冲突模型引用
### 5. services/audit_engine.py — ✅ 可安全覆盖
**差异概要**:审计引擎增强,新增枚举和条件判断。
**import 检查**:
- 无外部模型导入,仅标准库 + typing
- 无冲突模型引用
### 6. services/ticket_executor.py — ✅ 可安全覆盖
**差异概要**:工单执行器增强,支持更多 SQL 解析和执行策略。
**import 检查**:
- `from app.models.instance import Instance` — ✅
- `from app.models.ticket import Ticket` — ✅
- `from app.services.sql_analyzer import SQLAnalyzer, SQLType` — ✅
- 无冲突模型引用
### 7. api/v1/endpoints/ticket.py — ✅ 可安全覆盖
**AuditLog 使用情况**:
- `from app.models.audit_log import AuditLog` — ✅ 使用我们的独立 AuditLog
- `AuditLog.target_type`, `AuditLog.target_id`, `AuditLog.created_at` — ✅ 我们模型有这些字段
- `AuditLogService.log(target_type=..., detail=...)` — ✅ audit_log_service.py 两版完全相同
**注意**:ticket.py 使用的 AuditLog 字段(target_type, target_id, detail, db_type, status)全部存在于我们的 `models/audit_log.py`。
### 8. api/v1/endpoints/query.py — ✅ 可安全覆盖(需注意字段)
**AuditLog 使用情况**:
- `from app.models.audit_log import AuditLog` — ✅ 使用我们的独立 AuditLog
- 直接创建 `AuditLog(...)` 实例,使用字段:
- ✅ 我们模型有:user_id, username, action, target_type, target_id, instance_name, db_type, database_name, query_content, execution_time_ms, rows_affected, result_status, detail
- ⚠️ **`query_type`** — 我们模型没有这个字段!
- ⚠️ **`status`** — 我们模型没有 `status`,只有 `result_status`
**需要的处理**:在我们的 `models/audit_log.py` 中新增 `query_type` 字段:
```python
query_type = Column(String(20), nullable=True) # 新增:查询类型
```
### 9. api/v1/endpoints/user.py — ✅ 可安全覆盖
**差异**:两版完全相同(0 差异),优化包版本多了 AuditLogService 引用但代码一致。
---
## 🗂️ 推荐执行顺序
### 第一阶段:无冲突文件直接覆盖(7个文件)
```bash
# 1. models/workflow.py
# 2. services/workflow_engine.py
# 3. services/query_service.py
# 4. services/audit_engine.py
# 5. services/ticket_executor.py
# 6. api/v1/endpoints/ticket.py
# 7. api/v1/endpoints/user.py (两版相同,跳过)
```
### 第二阶段:小修复
在 `models/audit_log.py` 新增 `query_type` 字段:
```python
query_type = Column(String(20), nullable=True) # 查询类型(SELECT/INSERT/UPDATE/DELETE等)
```
### 第三阶段:手动合并 models/user.py
**保留我们现有结构**,只合并以下安全内容:
1. **新增 User 字段**(JSON 权限相关):
```python
data_source_permissions = Column(JSON, nullable=True, default=list)
custom_permissions = Column(JSON, nullable=True, default=dict)
column_permissions = Column(JSON, nullable=True, default=dict)
permissions_updated_at = Column(DateTime, nullable=True)
```
2. **新增 User 方法**:
```python
def has_permission(self, action: str) -> bool: ...
def has_data_source_permission(self, ...) -> bool: ...
def get_allowed_columns(self, ...) -> list: ...
def to_dict(self, include_sensitive=False) -> dict: ...
```
3. **不合并的内容**:
- `UserGroup` 类 → 如果需要用户组功能,必须改名或用不同表名
- `UserGroupMember` 类 → 同上
- `AuditLog` 类 → 已有独立文件,不重复定义
- `User.user_groups` relationship → 依赖冲突的 UserGroup
- `User.audit_logs` relationship → 需要评估是否添加(跨模型耦合)
- `User.tickets`/`approvals`/`query_histories` → 按需添加
4. **to_dict 方法调整**:
- 将 `self.user_groups` 改为 `self.groups`(匹配现有 InstanceGroup 关系)
### 第四阶段:数据库迁移
新增/修改的数据库表:
- `workflow_versions` — 新增表
- `workflow_execution_logs` — 新增表
- `workflow_definitions` — 新增 category, updated_at, created_by, flow_config, trigger_conditions 字段
- `workflow_steps` — 新增多个字段
- `ticket_approvals` — 新增多个字段
- `audit_logs` — 新增 query_type 字段
- `users` — 新增 4 个 JSON 权限字段
---
## ⚠️ 风险提示
1. **UserGroup/user_groups 冲突**:如果未来需要优化包的用户组功能,需要:
- 将优化包的 `UserGroup` 改表名为 `user_permission_groups`
- 或将我们的关联表 `user_groups` 改名为 `user_instance_groups`
- 同时更新所有外键和 relationship 引用
2. **AuditLog 双定义**:SQLAlchemy 的 metadata 中如果同时注册两个同名 `__tablename__` 的类会报错。只要不同时 import 就没问题。确保不 import `models/user.py` 中的 AuditLog 即可。
3. **workflow.py 的 relationship 指向 "User"**:通过字符串引用,SQLAlchemy 在运行时解析。只要所有模型在 metadata 中注册时 User 类存在即可。