refactor: mock清理+安全配置+通用组件+菜单优化

P0安全: SECRET_KEY/ADMIN_PASSWORD/CORS环境变量化, seed仅admin

P1Mock: mock.ts 301->88行, 删8数据mock+5generateMock, net-1300行

P2组件: useToast/useFetchList+ConfirmDialog/FormDialog

P3菜单: menuSections/menuItems->constants/menu.ts

P4API: NotificationsPage/WorkflowPage重写+RolesPage演示

测试: 1050passed, tsc clean
This commit is contained in:
logan committed 2026-05-24 02:42:29 +08:00
1 parent ec3a03cf38
commit 8b921c6c83
33 files changed
+1734 -2006

No files matched your search

+17 -9
View File
@@ -1,12 +1,20 @@
# DataGuard Backend Configuration
ENV=development
DEBUG=true
SECRET_KEY=change-me-to-a-secure-random-string
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ── SQL-Platform 生产环境配置 ──
# 复制此文件为 .env 并修改为实际值
# Platform Database (SQLite)
# 环境: development / production
ENV=production
DEBUG=false
# JWT 密钥(生产环境必须更换为随机强密钥)
# 生成方式: python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY=dev-secret-key-change-in-production
# 管理员初始密码(首次启动创建 admin 用户时使用)
ADMIN_PASSWORD=123456
# 数据库
DATABASE_URL=sqlite+aiosqlite:///./sqlite3.db
# Default Admin
ADMIN_USERNAME=admin
ADMIN_PASSWORD=123456
# CORS 允许的前端域名(生产环境必填,逗号分隔)
# CORS_ORIGINS=https://sql.example.com,https://sql2.example.com
CORS_ORIGINS=
+24
View File
@@ -65,6 +65,30 @@ async def execute_query(
if current_user.role == "guest":
raise HTTPException(status_code=403, detail="访客无权执行查询,请联系管理员申请权限")
# ── 系统设置集成 ──
from app.api.v1.endpoints.settings import get_setting_value
require_approval = await get_setting_value(db, "query_require_approval", False)
max_limit = await get_setting_value(db, "query_max_limit", 10000)
query_timeout = await get_setting_value(db, "query_timeout", 30)
max_length = await get_setting_value(db, "query_max_length", 10000)
auto_limit = await get_setting_value(db, "query_auto_limit", True)
enable_masking = await get_setting_value(db, "query_enable_masking", True)
save_hist = await get_setting_value(db, "query_save_history", True)
# 查询审批检查
if require_approval and current_user.role not in ("admin", "dba"):
raise HTTPException(
status_code=403,
detail="当前系统已开启查询审批,请通过工单提交查询申请"
)
# 查询语句长度检查
if len(req.sql) > max_length:
raise HTTPException(
status_code=400,
detail=f"查询语句过长({len(req.sql)} 字符),上限 {max_length} 字符"
)
# 查询实例信息
result = await db.execute(select(Instance).where(Instance.id == req.instance_id))
instance = result.scalar_one_or_none()
+162
View File
@@ -0,0 +1,162 @@
"""系统设置 API — GET/PUT 分组设置,自动初始化种子数据。"""
from typing import List, Dict
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.system_setting import SystemSetting
from app.schemas.system_setting import SettingItem, SettingUpdateItem
from app.services.settings_seeds import SEED_SETTINGS, SETTING_GROUPS
router = APIRouter()
async def _ensure_settings(db: AsyncSession) -> None:
"""确保所有种子设置项存在(缺失的自动插入)。"""
result = await db.execute(select(SystemSetting.key))
existing = {row[0] for row in result.all()}
missing = [s for s in SEED_SETTINGS if s["key"] not in existing]
if missing:
for s in missing:
db.add(SystemSetting(**s))
await db.commit()
@router.get("", response_model=Dict[str, List[SettingItem]])
async def get_settings(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""获取所有系统设置,按分组返回。"""
await _ensure_settings(db)
result = await db.execute(
select(SystemSetting).order_by(SystemSetting.group, SystemSetting.sort_order)
)
settings = result.scalars().all()
grouped: Dict[str, List[SettingItem]] = {}
for s in settings:
group = s.group
if group not in grouped:
grouped[group] = []
grouped[group].append(SettingItem(
key=s.key,
value=s.value,
value_type=s.value_type,
group=s.group,
label=s.label,
description=s.description,
default_value=s.default_value,
sort_order=s.sort_order,
))
return grouped
@router.get("/groups")
async def get_setting_groups(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""获取设置分组信息。"""
await _ensure_settings(db)
return SETTING_GROUPS
@router.put("")
async def update_settings(
items: List[SettingUpdateItem],
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""批量更新系统设置。"""
await _ensure_settings(db)
updated = []
for item in items:
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == item.key)
)
setting = result.scalar_one_or_none()
if not setting:
continue
# 类型校验
if setting.value_type == "boolean" and item.value not in ("true", "false"):
raise HTTPException(400, f"设置 '{setting.label}' 需要布尔值 (true/false)")
if setting.value_type == "integer":
try:
int(item.value)
except ValueError:
raise HTTPException(400, f"设置 '{setting.label}' 需要数字")
# 范围校验
if setting.key == "query_max_limit":
v = int(item.value)
if v < 100 or v > 100000:
raise HTTPException(400, "查询 LIMIT 上限范围: 100~100000")
if setting.key == "query_timeout":
v = int(item.value)
if v < 5 or v > 300:
raise HTTPException(400, "查询超时范围: 5~300 秒")
if setting.key == "query_max_length":
v = int(item.value)
if v < 500 or v > 50000:
raise HTTPException(400, "查询语句长度范围: 500~50000")
if setting.key == "security_password_min_length":
v = int(item.value)
if v < 4 or v > 32:
raise HTTPException(400, "密码最小长度范围: 4~32")
if setting.key == "security_max_login_attempts":
v = int(item.value)
if v < 0 or v > 20:
raise HTTPException(400, "最大登录失败次数范围: 0~20")
setting.value = item.value
updated.append(setting.key)
await db.commit()
return {"updated": updated}
# ── 供其他模块调用的工具函数 ──
async def get_setting_value(db: AsyncSession, key: str, fallback=None):
"""获取设置值的实际类型(给其他模块用)。"""
result = await db.execute(
select(SystemSetting).where(SystemSetting.key == key)
)
setting = result.scalar_one_or_none()
if setting:
return setting.get_typed_value()
# fallback 到种子数据默认值
for s in SEED_SETTINGS:
if s["key"] == key:
if s["value_type"] == "boolean":
return s["default_value"].lower() in ("true", "1", "yes")
if s["value_type"] == "integer":
return int(s["default_value"])
return s["default_value"]
return fallback
@router.get("/public/query")
async def get_public_query_settings(
db: AsyncSession = Depends(get_db),
):
"""前端查询页面获取查询相关设置(无需鉴权,只读)。"""
await _ensure_settings(db)
keys = [
"query_require_approval", "query_max_limit", "query_timeout",
"query_max_length", "query_auto_limit", "query_enable_masking",
"query_save_history",
]
result = await db.execute(
select(SystemSetting).where(SystemSetting.key.in_(keys))
)
settings = result.scalars().all()
return {s.key: s.get_typed_value() for s in settings}
+2
View File
@@ -7,6 +7,7 @@ from app.api.v1.endpoints import (
instance_group, masking_rule, rollback,
workflow, impact, schema_drift, slow_log, pool,
mongo, query_history, sql_assistant, db_types,
settings,
)
api_router = APIRouter(prefix="/api/v1")
@@ -39,3 +40,4 @@ api_router.include_router(query_history.router)
api_router.include_router(sql_assistant.router)
api_router.include_router(db_types.router)
api_router.include_router(settings.router, prefix="/settings", tags=["系统设置"])
+3
View File
@@ -15,6 +15,9 @@ class Settings(BaseSettings):
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = "123456"
# CORS: production comma-separated origins (e.g. "https://sql.example.com,https://sql2.example.com")
CORS_ORIGINS: str = ""
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
+12 -15
View File
@@ -29,23 +29,16 @@ async def seed_workflows():
async def seed_data():
"""Create default admin user and seed data."""
"""Create default admin user from environment config."""
async with async_session() as db:
result = await db.execute(select(User).where(User.username == settings.ADMIN_USERNAME))
if not result.scalar_one_or_none():
users = [
User(username="admin", display_name="管理员", role="admin",
hashed_password=hash_password("123456")),
User(username="dba", display_name="DBA 张三", role="dba",
hashed_password=hash_password("123456")),
User(username="dev1", display_name="开发者 李四", role="developer",
hashed_password=hash_password("123456")),
User(username="dev2", display_name="开发者 王五", role="developer",
hashed_password=hash_password("123456")),
User(username="auditor", display_name="审计员 赵六", role="developer",
email="auditor@example.com", hashed_password=hash_password("123456")),
]
db.add_all(users)
db.add(User(
username=settings.ADMIN_USERNAME,
display_name="管理员",
role="admin",
hashed_password=hash_password(settings.ADMIN_PASSWORD),
))
await db.commit()
@@ -85,9 +78,13 @@ app = FastAPI(
docs_url="/docs" if settings.DEBUG else None,
)
# CORS: development allows *, production uses CORS_ORIGINS env var
_cors_origins = ["*"] if settings.DEBUG else [
o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=["*"] if settings.DEBUG else [],
allow_origins=_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
+1
View File
@@ -1 +1,2 @@
from app.models.instance_group import InstanceGroup, user_groups # noqa: F401
from app.models.system_setting import SystemSetting # noqa: F401
+36
View File
@@ -0,0 +1,36 @@
"""系统设置模型 — 键值对存储,支持分组。"""
from datetime import datetime, timezone
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean
from app.core.database import Base
class SystemSetting(Base):
"""系统设置表 — key-value 存储。
每个设置项包含: key, value, value_type, group, description, default_value。
value_type 用于前端控件渲染 (boolean → 开关, integer → 数字输入, string → 文本)。
"""
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(100), unique=True, nullable=False, index=True, comment="设置键名")
value = Column(Text, nullable=False, comment="设置值(字符串存储)")
value_type = Column(String(20), nullable=False, default="string", comment="值类型: boolean/integer/string")
group = Column(String(50), nullable=False, default="general", comment="分组: query/security/general")
label = Column(String(100), nullable=False, comment="显示名称")
description = Column(Text, nullable=True, comment="说明")
default_value = Column(Text, nullable=True, comment="默认值")
sort_order = Column(Integer, nullable=False, default=0, comment="排序权重")
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
def get_typed_value(self):
"""将字符串值转为实际类型。"""
if self.value_type == "boolean":
return self.value.lower() in ("true", "1", "yes")
if self.value_type == "integer":
try:
return int(self.value)
except (ValueError, TypeError):
return int(self.default_value or 0)
return self.value
+39
View File
@@ -0,0 +1,39 @@
"""系统设置 Schema。"""
from typing import Optional, List
from pydantic import BaseModel, Field
class SettingItem(BaseModel):
"""单个设置项。"""
key: str
value: str
value_type: str = "string" # boolean / integer / string
group: str = "general"
label: str
description: Optional[str] = None
default_value: Optional[str] = None
sort_order: int = 0
class Config:
from_attributes = True
class SettingUpdateItem(BaseModel):
"""单个更新项。"""
key: str
value: str
class SettingBulkUpdate(BaseModel):
"""批量更新设置项。"""
settings: List[SettingUpdateItem] = Field(..., min_length=1)
class SettingGroupResponse(BaseModel):
"""分组设置响应。"""
group: str
label: str
icon: str = ""
description: str = ""
settings: List[SettingItem]
+136
View File
@@ -0,0 +1,136 @@
"""系统设置种子数据 — 首次启动自动初始化。"""
SEED_SETTINGS = [
# ── 查询控制 ──
{
"key": "query_require_approval",
"value": "false",
"value_type": "boolean",
"group": "query",
"label": "查询需要审批",
"description": "开启后,所有查询操作需要经过管理员或 DBA 审批才能执行",
"default_value": "false",
"sort_order": 1,
},
{
"key": "query_max_limit",
"value": "10000",
"value_type": "integer",
"group": "query",
"label": "查询 LIMIT 上限",
"description": "SELECT 查询返回的最大行数,超出的行会被截断。范围: 100~100000",
"default_value": "10000",
"sort_order": 2,
},
{
"key": "query_timeout",
"value": "30",
"value_type": "integer",
"group": "query",
"label": "查询超时(秒)",
"description": "单次查询最大执行时间,超时自动终止。范围: 5~300",
"default_value": "30",
"sort_order": 3,
},
{
"key": "query_max_length",
"value": "10000",
"value_type": "integer",
"group": "query",
"label": "查询语句最大长度",
"description": "查询语句允许的最大字符数。范围: 500~50000",
"default_value": "10000",
"sort_order": 4,
},
{
"key": "query_auto_limit",
"value": "true",
"value_type": "boolean",
"group": "query",
"label": "自动添加 LIMIT",
"description": "当 SELECT 语句没有 LIMIT 时自动追加,防止返回海量数据",
"default_value": "true",
"sort_order": 5,
},
{
"key": "query_enable_masking",
"value": "true",
"value_type": "boolean",
"group": "query",
"label": "启用数据脱敏",
"description": "自动对手机号、身份证、邮箱等敏感字段进行脱敏处理",
"default_value": "true",
"sort_order": 6,
},
{
"key": "query_save_history",
"value": "true",
"value_type": "boolean",
"group": "query",
"label": "保存查询历史",
"description": "自动记录每次查询内容,方便后续复用和审计",
"default_value": "true",
"sort_order": 7,
},
# ── 安全控制 ──
{
"key": "security_max_login_attempts",
"value": "5",
"value_type": "integer",
"group": "security",
"label": "最大登录失败次数",
"description": "连续登录失败达到次数后锁定账号(0 表示不限制)",
"default_value": "5",
"sort_order": 10,
},
{
"key": "security_password_min_length",
"value": "6",
"value_type": "integer",
"group": "security",
"label": "密码最小长度",
"description": "用户密码的最小字符数",
"default_value": "6",
"sort_order": 11,
},
{
"key": "security_enable_audit_log",
"value": "true",
"value_type": "boolean",
"group": "security",
"label": "启用审计日志",
"description": "记录所有关键操作(查询、工单、权限变更等)",
"default_value": "true",
"sort_order": 12,
},
# ── 工单控制 ──
{
"key": "ticket_default_workflow",
"value": "standard",
"value_type": "string",
"group": "ticket",
"label": "默认审批流程",
"description": "新建工单使用的默认审批流程: standard(标准三级) / simple(简单一级) / urgent(紧急快速)",
"default_value": "standard",
"sort_order": 20,
},
{
"key": "ticket_allow_rollback",
"value": "true",
"value_type": "boolean",
"group": "ticket",
"label": "允许工单回滚",
"description": "执行失败的工单是否允许重新执行(回到 approved 状态)",
"default_value": "true",
"sort_order": 21,
},
]
# 分组元信息
SETTING_GROUPS = {
"query": {"label": "查询控制", "icon": "🔍", "description": "控制查询行为、LIMIT、超时和脱敏策略"},
"security": {"label": "安全设置", "icon": "🔒", "description": "登录安全、密码策略、审计日志"},
"ticket": {"label": "工单设置", "icon": "📋", "description": "审批流程、回滚策略"},
}
+243
View File
@@ -0,0 +1,243 @@
"""系统设置 API 测试 — GET/PUT 设置 + 公共查询设置 + 查询集成。"""
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
async def _admin_token(client: AsyncClient) -> str:
resp = await client.post("/api/v1/auth/login", json={
"username": "admin", "password": "123456"
})
assert resp.status_code == 200
return resp.json()["access_token"]
def _auth_header(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
async def _ensure_user(client: AsyncClient, admin_token: str, username: str, role: str = "developer") -> str:
"""确保用户存在并返回 token。"""
resp = await client.post("/api/v1/auth/login", json={"username": username, "password": "123456"})
if resp.status_code == 200:
return resp.json()["access_token"]
# 创建
await client.post("/api/v1/users", headers=_auth_header(admin_token), json={
"username": username, "password": "123456",
"display_name": username, "role": role
})
resp = await client.post("/api/v1/auth/login", json={"username": username, "password": "123456"})
assert resp.status_code == 200
return resp.json()["access_token"]
# ── 1. GET /settings ──
@pytest.mark.asyncio
async def test_get_settings(client):
"""管理员可以获取所有系统设置。"""
token = await _admin_token(client)
resp = await client.get("/api/v1/settings", headers=_auth_header(token))
assert resp.status_code == 200
data = resp.json()
assert "query" in data
assert "security" in data
assert "ticket" in data
# 查询控制至少 7 项
assert len(data["query"]) >= 7
@pytest.mark.asyncio
async def test_get_settings_non_admin(client):
"""非管理员不能获取设置。"""
admin_token = await _admin_token(client)
user_token = await _ensure_user(client, admin_token, "testuser_settings")
resp = await client.get("/api/v1/settings", headers=_auth_header(user_token))
# require_role("admin") 应该返回 403
assert resp.status_code == 403
# ── 2. PUT /settings ──
@pytest.mark.asyncio
async def test_update_settings_boolean(client):
"""更新布尔设置项。"""
token = await _admin_token(client)
resp = await client.put("/api/v1/settings", headers=_auth_header(token), json=[
{"key": "query_require_approval", "value": "true"}
])
assert resp.status_code == 200
assert "query_require_approval" in resp.json()["updated"]
# 验证
resp = await client.get("/api/v1/settings", headers=_auth_header(token))
items = resp.json()["query"]
approval = next(i for i in items if i["key"] == "query_require_approval")
assert approval["value"] == "true"
# 恢复
await client.put("/api/v1/settings", headers=_auth_header(token), json=[
{"key": "query_require_approval", "value": "false"}
])
@pytest.mark.asyncio
async def test_update_settings_integer(client):
"""更新整数设置项。"""
token = await _admin_token(client)
resp = await client.put("/api/v1/settings", headers=_auth_header(token), json=[
{"key": "query_max_limit", "value": "5000"}
])
assert resp.status_code == 200
# 恢复
await client.put("/api/v1/settings", headers=_auth_header(token), json=[
{"key": "query_max_limit", "value": "10000"}
])
@pytest.mark.asyncio
async def test_update_settings_validation(client):
"""设置值校验。"""
token = await _admin_token(client)
# 布尔值错误
resp = await client.put("/api/v1/settings", headers=_auth_header(token), json=[
{"key": "query_require_approval", "value": "yes"}
])
assert resp.status_code == 400
# 整数范围错误
resp = await client.put("/api/v1/settings", headers=_auth_header(token), json=[
{"key": "query_max_limit", "value": "10"}
])
assert resp.status_code == 400
# 超时范围错误
resp = await client.put("/api/v1/settings", headers=_auth_header(token), json=[
{"key": "query_timeout", "value": "1"}
])
assert resp.status_code == 400
# ── 3. GET /settings/public/query ──
@pytest.mark.asyncio
async def test_public_query_settings(client):
"""公共查询设置无需鉴权。"""
resp = await client.get("/api/v1/settings/public/query")
assert resp.status_code == 200
data = resp.json()
assert "query_require_approval" in data
assert "query_max_limit" in data
assert "query_timeout" in data
assert isinstance(data["query_require_approval"], bool)
assert isinstance(data["query_max_limit"], int)
# ── 4. 查询集成 — 审批开关 ──
@pytest.mark.asyncio
async def test_query_require_approval(client):
"""开启查询审批后普通用户无法直接查询。"""
admin_token = await _admin_token(client)
user_token = await _ensure_user(client, admin_token, "testuser_approval")
# 开启查询审批
await client.put("/api/v1/settings", headers=_auth_header(admin_token), json=[
{"key": "query_require_approval", "value": "true"}
])
# 普通用户查询应被拦截
resp = await client.post("/api/v1/query", headers=_auth_header(user_token), json={
"instance_id": 1, "database": "test", "sql": "SELECT 1"
})
assert resp.status_code == 403
assert "审批" in resp.json()["detail"]
# 管理员仍然可以查询(不被审批限制拦截,可能因连接报其他错)
resp = await client.post("/api/v1/query", headers=_auth_header(admin_token), json={
"instance_id": 1, "database": "test", "sql": "SELECT 1"
})
assert resp.status_code != 403, "管理员不应被审批限制拦截"
# 恢复
await client.put("/api/v1/settings", headers=_auth_header(admin_token), json=[
{"key": "query_require_approval", "value": "false"}
])
# ── 5. 查询集成 — 语句长度检查 ──
@pytest.mark.asyncio
async def test_query_max_length(client):
"""超长查询语句被拦截。"""
admin_token = await _admin_token(client)
# 设置较短限制(在合法范围 500~50000 内)
await client.put("/api/v1/settings", headers=_auth_header(admin_token), json=[
{"key": "query_max_length", "value": "500"}
])
# 超长 SQL(> 500 字符)
long_sql = "SELECT * FROM users WHERE id = " + "1" * 600
resp = await client.post("/api/v1/query", headers=_auth_header(admin_token), json={
"instance_id": 1, "database": "test", "sql": long_sql
})
assert resp.status_code == 400, f"Expected 400, got {resp.status_code}: {resp.text}"
assert "过长" in resp.json()["detail"]
# 正常长度 SQL 不被拦截(可能因连接失败但不应是400长度错误)
short_sql = "SELECT 1"
resp = await client.post("/api/v1/query", headers=_auth_header(admin_token), json={
"instance_id": 1, "database": "test", "sql": short_sql
})
assert resp.status_code != 400 or "过长" not in resp.text
# 恢复
await client.put("/api/v1/settings", headers=_auth_header(admin_token), json=[
{"key": "query_max_length", "value": "50000"}
])
# ── 6. 批量更新 ──
@pytest.mark.asyncio
async def test_bulk_update(client):
"""批量更新多个设置。"""
token = await _admin_token(client)
resp = await client.put("/api/v1/settings", headers=_auth_header(token), json=[
{"key": "query_timeout", "value": "60"},
{"key": "query_max_limit", "value": "2000"},
])
assert resp.status_code == 200
assert len(resp.json()["updated"]) == 2
# 恢复
await client.put("/api/v1/settings", headers=_auth_header(token), json=[
{"key": "query_timeout", "value": "30"},
{"key": "query_max_limit", "value": "10000"},
])
# ── 7. 设置种子数据自动初始化 ──
@pytest.mark.asyncio
async def test_settings_auto_seed(client):
"""设置接口自动初始化种子数据。"""
token = await _admin_token(client)
resp = await client.get("/api/v1/settings", headers=_auth_header(token))
assert resp.status_code == 200
data = resp.json()
# 确认至少 12 个设置项
total = sum(len(v) for v in data.values())
assert total >= 12