diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index 53ab58c..2e9984c 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -1,4 +1,4 @@ -# Web-Lite 数据审核平台 - 项目评估 +# SQL-Platform 数据审核平台 - 项目评估 > 评估日期: 2025-05-22 > 评估版本: v0.5.0 (Phase 1-5 全部完成) @@ -9,7 +9,7 @@ | 项目 | 说明 | |------|------| -| 项目名 | Web-Lite 数据审核平台 (sql-platform) | +| 项目名 | SQL-Platform 数据审核平台 (sql-platform) | | 定位 | SQL 审核与工单管理平台(对标 Yearning/ArcSQL) | | 技术栈 | FastAPI + SQLite + React + TypeScript + Vite | | 代码仓库 | ~/devops/sql-platform | diff --git a/ROADMAP.md b/ROADMAP.md index 2773b28..db5f780 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,4 +1,4 @@ -# Web-Lite 数据审核平台 - 开发路线图 +# SQL-Platform 数据审核平台 - 开发路线图 > 项目路径: ~/devops/sql-platform > 创建时间: 2025-05-22 diff --git a/TDD_PLAN.md b/TDD_PLAN.md index eee1954..c33fa95 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -1,4 +1,4 @@ -# Web-Lite 数据审核平台 - TDD 开发计划 +# SQL-Platform 数据审核平台 - TDD 开发计划 > 创建时间: 2025-05-22 > 测试框架: pytest (后端) + vitest (前端) diff --git a/backend/app/api/v1/endpoints/instance.py b/backend/app/api/v1/endpoints/instance.py index d8fd07f..2e97050 100644 --- a/backend/app/api/v1/endpoints/instance.py +++ b/backend/app/api/v1/endpoints/instance.py @@ -1,3 +1,4 @@ +import json from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException @@ -16,6 +17,29 @@ from app.services.audit_log_service import AuditLogService router = APIRouter(prefix="/instances", tags=["数据源管理"]) +def _serialize_json_fields(instance: Instance) -> dict: + """将 Instance 的 JSON text 字段反序列化为 dict/list,返回 Response dict""" + d = { + "id": instance.id, + "name": instance.name, + "db_type": instance.db_type, + "host": instance.host, + "port": instance.port, + "username": instance.username, + "database": instance.database, + "env": instance.env, + "description": instance.description, + "status": instance.status, + "version": instance.version, + "access_mode": instance.access_mode or "readwrite", + "workflow": json.loads(instance.workflow) if instance.workflow else None, + "excluded_databases": json.loads(instance.excluded_databases) if instance.excluded_databases else None, + "mask_fields": json.loads(instance.mask_fields) if instance.mask_fields else None, + "created_at": instance.created_at, + } + return d + + @router.get("", response_model=List[InstanceResponse]) async def list_instances( db_type: Optional[str] = None, @@ -29,7 +53,8 @@ async def list_instances( if env: stmt = stmt.where(Instance.env == env) result = await db.execute(stmt) - return result.scalars().all() + instances = result.scalars().all() + return [_serialize_json_fields(i) for i in instances] @router.post("", response_model=InstanceResponse, status_code=201) @@ -48,6 +73,10 @@ async def create_instance( database=data.database, env=data.env, description=data.description, + access_mode=data.access_mode, + workflow=json.dumps([s.model_dump() for s in data.workflow], ensure_ascii=False) if data.workflow else None, + excluded_databases=json.dumps(data.excluded_databases, ensure_ascii=False) if data.excluded_databases else None, + mask_fields=json.dumps([m.model_dump() for m in data.mask_fields], ensure_ascii=False) if data.mask_fields else None, ) db.add(instance) await db.flush() @@ -58,7 +87,7 @@ async def create_instance( ) await db.commit() await db.refresh(instance) - return instance + return _serialize_json_fields(instance) @router.get("/{instance_id}", response_model=InstanceResponse) @@ -71,7 +100,7 @@ async def get_instance( instance = result.scalar_one_or_none() if not instance: raise HTTPException(status_code=404, detail="数据源不存在") - return instance + return _serialize_json_fields(instance) @router.put("/{instance_id}", response_model=InstanceResponse) @@ -89,11 +118,19 @@ async def update_instance( update_data = data.model_dump(exclude_unset=True) if "password" in update_data: update_data["password"] = encrypt_password(update_data["password"]) + # JSON fields + if "workflow" in update_data: + update_data["workflow"] = json.dumps([s if isinstance(s, dict) else s for s in update_data["workflow"]], ensure_ascii=False) if update_data["workflow"] else None + if "excluded_databases" in update_data: + update_data["excluded_databases"] = json.dumps(update_data["excluded_databases"], ensure_ascii=False) if update_data["excluded_databases"] else None + if "mask_fields" in update_data: + update_data["mask_fields"] = json.dumps([m if isinstance(m, dict) else m for m in update_data["mask_fields"]], ensure_ascii=False) if update_data["mask_fields"] else None + for key, value in update_data.items(): setattr(instance, key, value) await db.commit() await db.refresh(instance) - return instance + return _serialize_json_fields(instance) @router.delete("/{instance_id}") @@ -128,7 +165,6 @@ async def test_connection( if not instance: raise HTTPException(status_code=404, detail="数据源不存在") - # Decrypt password for connection test instance.password = decrypt_password(instance.password) return await InstanceService.test_connection(instance) diff --git a/backend/app/main.py b/backend/app/main.py index 092e2de..69bea5d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -56,7 +56,7 @@ async def lifespan(app: FastAPI): app = FastAPI( - title="Web-Lite API", + title="SQL-Platform API", version="1.0.0", description="数据审核平台 API", lifespan=lifespan, diff --git a/backend/app/models/instance.py b/backend/app/models/instance.py index f877473..3501883 100644 --- a/backend/app/models/instance.py +++ b/backend/app/models/instance.py @@ -18,5 +18,12 @@ class Instance(Base): description = Column(Text, nullable=True) status = Column(String(20), default="disconnected") # connected / disconnected / error version = Column(String(50), nullable=True) + + # 新增字段 + access_mode = Column(String(20), default="readwrite") # readwrite / read / write + workflow = Column(Text, nullable=True) # JSON: [{stage, role, assignee}] + excluded_databases = Column(Text, nullable=True) # JSON: ["sys", "information_schema", ...] + mask_fields = Column(Text, nullable=True) # JSON: [{table, field, rule}] + created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/schemas/instance.py b/backend/app/schemas/instance.py index 60934db..75023e8 100644 --- a/backend/app/schemas/instance.py +++ b/backend/app/schemas/instance.py @@ -1,6 +1,18 @@ from datetime import datetime from pydantic import BaseModel, Field -from typing import Optional, Literal +from typing import Optional, Literal, List, Any + + +class WorkflowStage(BaseModel): + stage: str = Field(..., description="阶段名称,如:提交阶段、审核阶段、执行阶段") + role: str = Field(..., description="角色,如:提交人、开发负责人、执行人") + assignee: Optional[str] = Field(None, description="负责人用户名") + + +class MaskFieldRule(BaseModel): + table: str = Field(..., description="表名") + field: str = Field(..., description="字段名") + rule: str = Field(default="mask", description="脱敏规则:mask/hide/hash") class InstanceCreate(BaseModel): @@ -13,6 +25,10 @@ class InstanceCreate(BaseModel): database: Optional[str] = None env: Literal["production", "staging", "development"] = "development" description: Optional[str] = None + access_mode: Literal["readwrite", "read", "write"] = "readwrite" + workflow: Optional[List[WorkflowStage]] = None + excluded_databases: Optional[List[str]] = None + mask_fields: Optional[List[MaskFieldRule]] = None class InstanceUpdate(BaseModel): @@ -24,6 +40,10 @@ class InstanceUpdate(BaseModel): database: Optional[str] = None env: Optional[Literal["production", "staging", "development"]] = None description: Optional[str] = None + access_mode: Optional[Literal["readwrite", "read", "write"]] = None + workflow: Optional[List[WorkflowStage]] = None + excluded_databases: Optional[List[str]] = None + mask_fields: Optional[List[MaskFieldRule]] = None class InstanceResponse(BaseModel): @@ -38,6 +58,10 @@ class InstanceResponse(BaseModel): description: Optional[str] = None status: str version: Optional[str] = None + access_mode: str = "readwrite" + workflow: Optional[List[dict]] = None + excluded_databases: Optional[List[str]] = None + mask_fields: Optional[List[dict]] = None created_at: datetime class Config: diff --git a/backend/tests/test_instance_fields.py b/backend/tests/test_instance_fields.py new file mode 100644 index 0000000..03f0949 --- /dev/null +++ b/backend/tests/test_instance_fields.py @@ -0,0 +1,124 @@ +"""测试 Instance 新增字段:access_mode, workflow, excluded_databases, mask_fields""" +import pytest +from tests.conftest import auth_header + + +@pytest.mark.asyncio +async def test_create_instance_with_full_fields(client, admin_token): + """创建数据源:完整字段""" + res = await client.post("/api/v1/instances", json={ + "name": "pro-cex-rds-只读库", + "db_type": "mysql", + "host": "pro-rds-ro.51aipay.com", + "port": 3306, + "username": "admin", + "password": "secret123", + "env": "production", + "access_mode": "read", + "workflow": [ + {"stage": "提交阶段", "role": "提交人", "assignee": ""}, + {"stage": "开发负责人", "role": "开发负责人", "assignee": "lairdlandonloose"}, + {"stage": "执行人", "role": "执行人", "assignee": "zachlogan"}, + ], + "excluded_databases": ["sys", "information_schema", "mysql"], + "mask_fields": [ + {"table": "users", "field": "phone", "rule": "mask"}, + {"table": "users", "field": "id_card", "rule": "hide"}, + ], + }, headers=auth_header(admin_token)) + assert res.status_code == 201 + data = res.json() + assert data["access_mode"] == "read" + assert data["workflow"] is not None + assert len(data["workflow"]) == 3 + assert data["workflow"][1]["assignee"] == "lairdlandonloose" + assert data["excluded_databases"] == ["sys", "information_schema", "mysql"] + assert len(data["mask_fields"]) == 2 + assert data["mask_fields"][0]["table"] == "users" + + +@pytest.mark.asyncio +async def test_create_instance_default_access_mode(client, admin_token): + """默认连接模式为 readwrite""" + res = await client.post("/api/v1/instances", json={ + "name": "test-default", + "db_type": "mysql", + "host": "127.0.0.1", + "port": 3306, + "username": "root", + "password": "test123", + }, headers=auth_header(admin_token)) + assert res.status_code == 201 + assert res.json()["access_mode"] == "readwrite" + + +@pytest.mark.asyncio +async def test_list_instance_includes_new_fields(client, admin_token): + """列表接口返回新字段""" + # 创建 + await client.post("/api/v1/instances", json={ + "name": "list-test", + "db_type": "postgres", + "host": "10.0.0.1", + "port": 5432, + "username": "postgres", + "password": "pwd", + "access_mode": "write", + "workflow": [{"stage": "审批", "role": "DBA", "assignee": "alice"}], + }, headers=auth_header(admin_token)) + + res = await client.get("/api/v1/instances", headers=auth_header(admin_token)) + assert res.status_code == 200 + items = res.json() + found = [i for i in items if i["name"] == "list-test"] + assert len(found) == 1 + assert found[0]["access_mode"] == "write" + assert found[0]["workflow"] is not None + assert found[0]["workflow"][0]["assignee"] == "alice" + + +@pytest.mark.asyncio +async def test_update_instance_workflow(client, admin_token): + """更新流程配置""" + create = await client.post("/api/v1/instances", json={ + "name": "update-wf", + "db_type": "mysql", + "host": "127.0.0.1", + "port": 3306, + "username": "root", + "password": "test", + }, headers=auth_header(admin_token)) + iid = create.json()["id"] + + res = await client.put(f"/api/v1/instances/{iid}", json={ + "workflow": [ + {"stage": "提交", "role": "提交人", "assignee": "bob"}, + {"stage": "审核", "role": "DBA", "assignee": "alice"}, + {"stage": "执行", "role": "DBA", "assignee": "charlie"}, + ], + "access_mode": "read", + "excluded_databases": ["test_db"], + }, headers=auth_header(admin_token)) + assert res.status_code == 200 + data = res.json() + assert len(data["workflow"]) == 3 + assert data["access_mode"] == "read" + assert data["excluded_databases"] == ["test_db"] + + +@pytest.mark.asyncio +async def test_instance_null_json_fields(client, admin_token): + """不传新字段时,返回 null""" + res = await client.post("/api/v1/instances", json={ + "name": "minimal", + "db_type": "mysql", + "host": "127.0.0.1", + "port": 3306, + "username": "root", + "password": "test", + }, headers=auth_header(admin_token)) + assert res.status_code == 201 + data = res.json() + assert data["workflow"] is None + assert data["excluded_databases"] is None + assert data["mask_fields"] is None diff --git a/frontend/index.html b/frontend/index.html index fc77e77..d66514a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - Web-Lite Pro + SQL-Platform
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index a382562..54ed6f8 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -57,7 +57,7 @@ export default function Layout() { - Web-Lite + SQL-Platform @@ -140,7 +140,7 @@ export default function Layout() { {/* 顶部栏 */}
- Web-Lite + SQL-Platform / {getPageTitle()}
diff --git a/frontend/src/lib/mock.ts b/frontend/src/lib/mock.ts index 19f2492..24535ee 100644 --- a/frontend/src/lib/mock.ts +++ b/frontend/src/lib/mock.ts @@ -300,7 +300,7 @@ export const announcements: Announcement[] = [ }, { id: 'ann-sql-1', title: '【规范】SQL 变更工单提交流程', - content: '1. 登录 Web-Lite 平台,进入「数据变更」页面\n2. 点击「新建工单」,填写标题和变更原因\n3. 选择目标数据库类型和实例\n4. 在 SQL 编辑器中输入变更语句\n5. 系统自动审核并评分,确认无误后提交\n6. 等待 DBA 审批通过后执行\n\n注意:生产环境变更必须提供回滚 SQL!', + content: '1. 登录 SQL-Platform 平台,进入「数据变更」页面\n2. 点击「新建工单」,填写标题和变更原因\n3. 选择目标数据库类型和实例\n4. 在 SQL 编辑器中输入变更语句\n5. 系统自动审核并评分,确认无误后提交\n6. 等待 DBA 审批通过后执行\n\n注意:生产环境变更必须提供回滚 SQL!', priority: 'high', pinned: true, enabled: true, author: 'admin', publishTime: '2024-01-10 08:00:00', expireTime: '2025-12-31 00:00:00', readCount: 512, targetRoles: ['admin', 'dba', 'developer'], diff --git a/frontend/src/pages/AnnouncementPage.tsx b/frontend/src/pages/AnnouncementPage.tsx index 8ea0c02..9e2b194 100644 --- a/frontend/src/pages/AnnouncementPage.tsx +++ b/frontend/src/pages/AnnouncementPage.tsx @@ -73,7 +73,7 @@ const initialAnnouncements: Announcement[] = [ readCount: 67, targetRoles: ['developer'], }, { - id: 'ann-5', title: '【版本更新】Web-Lite v2.5.0 更新日志', + id: 'ann-5', title: '【版本更新】SQL-Platform v2.5.0 更新日志', content: '本次更新内容:\n\n新增功能:\n- 支持多数据源统一管理\n- 查询审计新增敏感字段自动脱敏\n- 工单批量审批功能\n\n优化改进:\n- SQL 编辑器性能优化\n- 审批流程可视化升级\n- 通知渠道新增 Telegram 支持\n\nBug 修复:\n- 修复大结果集导出内存溢出问题\n- 修复 Redis 集群连接超时问题', priority: 'low', pinned: false, enabled: false, author: 'admin', publishTime: '2024-01-10 16:00:00', expireTime: '2024-02-10 00:00:00', diff --git a/frontend/src/pages/InstancePage.tsx b/frontend/src/pages/InstancePage.tsx index 96b032b..146eb40 100644 --- a/frontend/src/pages/InstancePage.tsx +++ b/frontend/src/pages/InstancePage.tsx @@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom'; import { Plus, RefreshCw, Search, Database, Server, Zap, Leaf, Activity, HardDrive, ChevronRight, Trash2, Edit2, - XCircle, + XCircle, Eye, EyeOff, X, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -293,6 +293,12 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete } /* ---------- CreateInstanceForm ---------- */ +interface WorkflowStep { + stage: string; + role: string; + assignee: string; +} + function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: string; onCancel: () => void; onCreated: () => void }) { const [name, setName] = useState(''); const [dbType, setDbType] = useState(defaultType); @@ -300,12 +306,32 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: const [port, setPort] = useState('3306'); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); + const [showPwd, setShowPwd] = useState(false); const [database, setDatabase] = useState(''); const [env, setEnv] = useState('development'); - const [description, setDescription] = useState(''); + const [accessMode, setAccessMode] = useState<'readwrite' | 'read' | 'write'>('readwrite'); + const [excludedDbs, setExcludedDbs] = useState(''); + const [maskFields, setMaskFields] = useState(''); + const [workflow, setWorkflow] = useState([ + { stage: '提交阶段', role: '提交人', assignee: '' }, + { stage: '开发负责人', role: '开发负责人', assignee: '' }, + { stage: '执行人', role: '执行人', assignee: '' }, + ]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); + const updateWorkflow = (idx: number, field: keyof WorkflowStep, value: string) => { + setWorkflow(prev => prev.map((w, i) => i === idx ? { ...w, [field]: value } : w)); + }; + + const addWorkflowStep = () => { + setWorkflow(prev => [...prev, { stage: '', role: '', assignee: '' }]); + }; + + const removeWorkflowStep = (idx: number) => { + setWorkflow(prev => prev.filter((_, i) => i !== idx)); + }; + const handleSubmit = async () => { if (!name || !host || !port || !username || !password) { setError('请填写所有必填项'); return; } setSubmitting(true); @@ -314,7 +340,13 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: await instanceApi.create({ name, db_type: dbType, host, port: Number(port), username, password, database: database || undefined, - env, description: description || undefined, + env, access_mode: accessMode, + workflow: workflow.filter(w => w.stage || w.assignee).length > 0 ? workflow.filter(w => w.stage || w.assignee) : undefined, + excluded_databases: excludedDbs ? excludedDbs.split(',').map(s => s.trim()).filter(Boolean) : undefined, + mask_fields: maskFields ? maskFields.split('\n').filter(Boolean).map(line => { + const [table = '', field = '', rule = 'mask'] = line.split(':'); + return { table: table.trim(), field: field.trim(), rule: rule.trim() || 'mask' }; + }) : undefined, }); onCreated(); } catch (e: any) { @@ -324,73 +356,143 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: } }; + const inputCls = 'w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none'; + const labelCls = 'block text-sm font-medium mb-1.5'; + return ( -
+
-

添加实例

- +

添加数据源

+
-
-
- - setName(e.target.value)} className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="例如:生产 MySQL 主库" /> -
+ {/* 数据源信息 */} + + +

数据源信息

+
+
+ + +
+
+ + setName(e.target.value)} className={inputCls} placeholder="pro-cex-rds-只读库" /> +
+
+
+
+ + setHost(e.target.value)} className={inputCls} placeholder="pro-rds-ro.51aipay.com" /> +
+
+ + setPort(e.target.value)} className={inputCls} placeholder="3306" /> +
+
+ + +
+
+
+
+ + setUsername(e.target.value)} className={inputCls} placeholder="admin" /> +
+
+ +
+ setPassword(e.target.value)} className={inputCls + ' pr-10'} placeholder="数据库密码" /> + +
+
+
+
+ + setDatabase(e.target.value)} className={inputCls} placeholder="可选,留空则连接默认库" /> +
+
+
-
-
- - + {/* 连接模式 */} + + +

连接模式

+
+ {[ + { value: 'readwrite' as const, label: '读写' }, + { value: 'write' as const, label: '写' }, + { value: 'read' as const, label: '读' }, + ].map(m => ( + + ))}
-
- - -
-
+ + -
-
- - setHost(e.target.value)} className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="192.168.1.100" /> + {/* 流程配置 */} + + +
+

流程

+
-
- - setPort(e.target.value)} className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="3306" /> +
+ {workflow.map((step, idx) => ( +
+ {idx + 1} + updateWorkflow(idx, 'stage', e.target.value)} className={inputCls + ' flex-1'} placeholder="提交阶段" /> + updateWorkflow(idx, 'role', e.target.value)} className={inputCls + ' w-32'} placeholder="角色" /> + updateWorkflow(idx, 'assignee', e.target.value)} className={inputCls + ' flex-1'} placeholder="负责人" /> + {workflow.length > 1 && ( + + )} +
+ ))}
-
- - setDatabase(e.target.value)} className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="可选" /> -
-
+
+
-
+ {/* 排除的数据库 & 脱敏字段 */} + + +

高级配置

- - setUsername(e.target.value)} className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="root" /> + + setExcludedDbs(e.target.value)} className={inputCls} placeholder="以逗号分隔,如:sys, information_schema, mysql" />
- - setPassword(e.target.value)} className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="数据库密码" /> + +