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 @@
-diff --git a/run.bat b/run.bat index fe4db5f..c89ee52 100644 --- a/run.bat +++ b/run.bat @@ -1,6 +1,6 @@ @echo off chcp 65001 >nul -echo === Web-Lite 数据审核平台启动脚本 === +echo === SQL-Platform 数据审核平台启动脚本 === echo [1/4] 安装后端依赖... cd backend diff --git a/run.sh b/run.sh index 7b64b63..243ab49 100755 --- a/run.sh +++ b/run.sh @@ -138,7 +138,7 @@ start_frontend() { show_status() { echo "" - echo "=== Web-Lite 数据审核平台 状态 ===" + echo "=== SQL-Platform 数据审核平台 状态 ===" echo "" if is_running "$BACKEND_PID_FILE" "后端"; then