feat: improve datasource query audit workflows
This commit is contained in:
1 parent
74748e4914
commit
644981eef4
32 files changed
+780
-3061
No files matched your search
@@ -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 独有) |
|
||||
| 风险评估 | 中等 — 文件结构一致但内容改动量大 |
|
||||
@@ -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 人天** | — |
|
||||
@@ -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?建议新建表,便于查询和展示
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
@@ -141,12 +141,12 @@ func testRedis(ctx context.Context, host string, port int, username, pass string
|
||||
redisUsername = ""
|
||||
}
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Username: redisUsername,
|
||||
Password: pass,
|
||||
DB: 0,
|
||||
DialTimeout: 10 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
Addr: addr,
|
||||
Username: redisUsername,
|
||||
Password: pass,
|
||||
DB: 0,
|
||||
DialTimeout: 10 * time.Second,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
})
|
||||
defer rdb.Close()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
// GET /api/v1/instances
|
||||
func ListInstances(c *gin.Context) {
|
||||
userID, _, role := middleware.GetCurrentUser(c)
|
||||
|
||||
|
||||
var instances []model.Instance
|
||||
query := model.DB.Order("id")
|
||||
|
||||
|
||||
// developer only sees instances in their groups
|
||||
if role == "developer" {
|
||||
var groupIDs []int
|
||||
@@ -33,12 +33,12 @@ func ListInstances(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if err := query.Find(&instances).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// mask passwords in response
|
||||
for i := range instances {
|
||||
instances[i].Password = ""
|
||||
@@ -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"
|
||||
@@ -108,13 +108,13 @@ func UpdateInstance(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var input map[string]interface{}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// if password is empty or masked placeholder, don't update it
|
||||
if pw, ok := input["password"].(string); ok {
|
||||
pw = strings.TrimSpace(pw)
|
||||
@@ -128,12 +128,12 @@ func UpdateInstance(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if err := model.DB.Model(&instance).Updates(input).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
model.DB.First(&instance, id)
|
||||
instance.Password = "******"
|
||||
c.JSON(http.StatusOK, instance)
|
||||
@@ -157,15 +157,15 @@ func TestConnection(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
result := testDBConnectionFull(&instance)
|
||||
// Update version & cluster_mode in DB if detected
|
||||
updates := map[string]interface{}{}
|
||||
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)
|
||||
@@ -181,16 +181,16 @@ func TestConnection(c *gin.Context) {
|
||||
func InstanceStats(c *gin.Context) {
|
||||
var total int64
|
||||
model.DB.Model(&model.Instance{}).Count(&total)
|
||||
|
||||
|
||||
type typeCount struct {
|
||||
DBType string `json:"db_type"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
var byType []typeCount
|
||||
model.DB.Model(&model.Instance{}).Select("db_type, count(*) as count").Group("db_type").Scan(&byType)
|
||||
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"total": total,
|
||||
"total": total,
|
||||
"by_type": byType,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
@@ -498,7 +636,7 @@ func RedisExecuteCommand(c *gin.Context) {
|
||||
|
||||
ctx := context.Background()
|
||||
cmd := strings.ToUpper(input.Command)
|
||||
|
||||
|
||||
// Safe read-only commands
|
||||
readOnly := map[string]bool{
|
||||
"GET": true, "MGET": true, "HGET": true, "HGETALL": true,
|
||||
@@ -508,7 +646,7 @@ func RedisExecuteCommand(c *gin.Context) {
|
||||
"STRLEN": true, "LLEN": true, "SCARD": true, "ZCARD": true,
|
||||
"HLEN": true, "GETRANGE": true,
|
||||
}
|
||||
|
||||
|
||||
if !readOnly[cmd] {
|
||||
c.JSON(http.StatusForbidden, gin.H{"detail": "Only read-only commands allowed via query interface"})
|
||||
return
|
||||
@@ -547,10 +685,10 @@ func RedisCreateKey(c *gin.Context) {
|
||||
}
|
||||
|
||||
var input struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Value interface{} `json:"value"`
|
||||
TTL int `json:"ttl"`
|
||||
Key string `json:"key" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
Value interface{} `json:"value"`
|
||||
TTL int `json:"ttl"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||||
@@ -571,7 +709,7 @@ func RedisCreateKey(c *gin.Context) {
|
||||
defer client.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
|
||||
switch input.Type {
|
||||
case "string":
|
||||
client.Set(ctx, input.Key, input.Value, 0)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
|
||||
+1
-1
@@ -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" />
|
||||
|
||||
Generated
+4
-1677
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
+59
-47
@@ -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,33 +61,35 @@ function LoginGuard() {
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginGuard />} />
|
||||
<Route element={<RouteGuard />}>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="instances" element={<InstancePage />} />
|
||||
<Route path="instance-groups" element={<InstanceGroupPage />} />
|
||||
<Route path="instances/:id" element={<InstanceDetailPage />} />
|
||||
<Route path="redis/:id" element={<RedisDetailPage />} />
|
||||
<Route path="mongo/:id" element={<MongoDBDetailPage />} />
|
||||
<Route path="data-dictionary" element={<DataDictionaryPage />} />
|
||||
<Route path="tickets" element={<TicketsPage />} />
|
||||
<Route path="tickets/create" element={<TicketCreatePage />} />
|
||||
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
||||
<Route path="queries" element={<QueriesPage />} />
|
||||
<Route path="roles" element={<RolesPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="audit-rules" element={<AuditRulesPage />} />
|
||||
<Route path="audit-logs" element={<AuditLogsPage />} />
|
||||
<Route path="workflow" element={<WorkflowPage />} />
|
||||
<Route path="auto-task" element={<AutoTaskPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginGuard />} />
|
||||
<Route element={<RouteGuard />}>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="instances" element={<InstancePage />} />
|
||||
<Route path="instance-groups" element={<InstanceGroupPage />} />
|
||||
<Route path="instances/:id" element={<InstanceDetailPage />} />
|
||||
<Route path="redis/:id" element={<RedisDetailPage />} />
|
||||
<Route path="mongo/:id" element={<MongoDBDetailPage />} />
|
||||
<Route path="data-dictionary" element={<DataDictionaryPage />} />
|
||||
<Route path="tickets" element={<TicketsPage />} />
|
||||
<Route path="tickets/create" element={<TicketCreatePage />} />
|
||||
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
||||
<Route path="queries" element={<QueriesPage />} />
|
||||
<Route path="roles" element={<RolesPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="audit-rules" element={<AuditRulesPage />} />
|
||||
<Route path="audit-logs" element={<AuditLogsPage />} />
|
||||
<Route path="workflow" element={<WorkflowPage />} />
|
||||
<Route path="auto-task" element={<AutoTaskPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,4 +106,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
@@ -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">
|
||||
<h2 className="text-base font-semibold">{mode === 'clone' ? '克隆数据源' : '编辑数据源'}</h2>
|
||||
<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" />}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,22 +115,20 @@ export default function Dashboard() {
|
||||
|
||||
/* ---- Instances & Tickets ---- */
|
||||
useEffect(() => {
|
||||
import('@/api/request').then(m => {
|
||||
m.default.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,
|
||||
status: i.status || 'online',
|
||||
healthScore: i.health_score ?? i.healthScore ?? 100,
|
||||
host: i.host || '', port: i.port || '',
|
||||
qps: i.qps ?? 0, connections: i.connections ?? 0,
|
||||
})));
|
||||
}).catch(() => setDbInstances([]));
|
||||
m.default.get('/dashboard/my-tickets').then((res: any) => {
|
||||
const list = Array.isArray(res) ? res : [];
|
||||
setTickets(list);
|
||||
}).catch(() => setTickets([]));
|
||||
});
|
||||
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,
|
||||
status: i.status || 'online',
|
||||
healthScore: i.health_score ?? i.healthScore ?? 100,
|
||||
host: i.host || '', port: i.port || '',
|
||||
qps: i.qps ?? 0, connections: i.connections ?? 0,
|
||||
})));
|
||||
}).catch(() => setDbInstances([]));
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 类存在即可。
|
||||
Reference in new issue
Block a user