feat: v0.3.1 — Go/Gin 后端重写 + gh-ost 集成

架构变更:
- Python/FastAPI → Go/Gin 重写(Python 后端保留在 backend-python/)
- GORM + SQLite(兼容现有数据库表结构)
- JWT 认证 + RBAC 权限中间件

Go 后端结构:
- cmd/server/main.go — 入口,端口 9000
- config/config.go — 环境变量配置
- internal/model/ — GORM 模型(30张表,完全兼容 SQLite)
- internal/handler/ — auth/instance/ticket/dashboard/ghost handler
- internal/middleware/ — JWT 认证 + CORS
- internal/ghost/ — gh-ost 集成模块(同步/异步执行,任务管理)
- router/router.go — API 路由注册(/api/v1/...)

gh-ost 集成:
- RunGhOst() 同步执行
- RunGhOstAsync() 异步执行 + 进度追踪
- TaskManager 管理多个并发任务
- API: POST /ghost/dry-run, /ghost/execute, GET /ghost/status/:taskId

核心 API 已对齐:
- POST /auth/login (JWT)
- GET /dashboard/stats, /dashboard/my-tickets
- GET /tickets/statistics, CRUD + approve/execute
- GET/POST/PUT/DELETE /instances
- GET /users, /audit-rules, /announcements, /settings

已验证:
- 登录 admin/admin123 ✅
- Dashboard 99工单/110查询/4实例 ✅
- 工单统计按角色过滤 ✅
This commit is contained in:
logan committed 2026-05-25 21:40:54 +08:00
1 parent 43d1f3a234
commit ca308e277d
202 files changed
+2265 -51181

No files matched your search

+11 -1
View File
@@ -1,10 +1,20 @@
# Backend # Backend Python
__pycache__/ __pycache__/
*.pyc *.pyc
venv/ venv/
*.db *.db
.env .env
*.sqlite3 *.sqlite3
*.db-journal
.pytest_cache/
apscheduler.db
# Backend Python (preserved but not tracked)
backend-python/
# Backend Go
backend/sql-platform
backend/go.sum
# Frontend # Frontend
node_modules/ node_modules/
-6
View File
@@ -1,6 +0,0 @@
venv/
__pycache__/
*.pyc
.pytest_cache/
*.db
alembic/versions/__pycache__/
-20
View File
@@ -1,20 +0,0 @@
# ── SQL-Platform 生产环境配置 ──
# 复制此文件为 .env 并修改为实际值
# 环境: 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
# CORS 允许的前端域名(生产环境必填,逗号分隔)
# CORS_ORIGINS=https://sql.example.com,https://sql2.example.com
CORS_ORIGINS=
-31
View File
@@ -1,31 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
# Install system deps
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libffi-dev && \
rm -rf /var/lib/apt/lists/*
# Install Python deps
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy app
COPY . .
# Default env
ENV DATABASE_URL="sqlite+aiosqlite:///./data/sqlite3.db"
ENV SECRET_KEY="change-me-in-production"
ENV ADMIN_USERNAME="admin"
ENV ADMIN_PASSWORD="changeme"
ENV ENV="production"
ENV DEBUG="false"
# Create data dir
RUN mkdir -p /app/data
EXPOSE 9000
# Run alembic + uvicorn
CMD ["sh", "-c", "alembic upgrade head && python -m uvicorn app.main:app --host 0.0.0.0 --port 9000"]
-147
View File
@@ -1,147 +0,0 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
-1
View File
@@ -1 +0,0 @@
Generic single-database configuration.
-61
View File
@@ -1,61 +0,0 @@
from logging.config import fileConfig
import os
import sys
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.core.database import Base
from app.core.config import get_settings
# Import all models so Alembic can detect them
from app.models.user import User # noqa
from app.models.instance import Instance # noqa
from app.models.ticket import Ticket # noqa
from app.models.audit_log import AuditLog # noqa
from app.models.audit_rule import AuditRule # noqa
config = context.config
# Override sqlalchemy.url from settings
config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL.replace("+aiosqlite", ""))
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
-28
View File
@@ -1,28 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -1,32 +0,0 @@
"""initial_schema
Revision ID: 5c8b75f38c7e
Revises:
Create Date: 2026-05-22 13:32:02.584732
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5c8b75f38c7e'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
View File
Whitespace-only changes.
View File
Whitespace-only changes.
View File
Whitespace-only changes.
Whitespace-only changes.
@@ -1,93 +0,0 @@
from datetime import datetime, timezone
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.models.user import User
from app.models.announcement import Announcement
from app.schemas.announcement import (
AnnouncementCreate,
AnnouncementUpdate,
AnnouncementResponse,
)
router = APIRouter(prefix="/announcements", tags=["公告管理"])
@router.get("", response_model=List[AnnouncementResponse])
async def list_announcements(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""所有登录用户可查看公告列表"""
stmt = select(Announcement).order_by(Announcement.id.desc())
result = await db.execute(stmt)
return result.scalars().all()
@router.post("", response_model=AnnouncementResponse, status_code=201)
async def create_announcement(
data: AnnouncementCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""管理员创建公告"""
announcement = Announcement(
title=data.title,
content=data.content,
is_published=False,
)
db.add(announcement)
await db.commit()
await db.refresh(announcement)
return announcement
@router.put("/{announcement_id}", response_model=AnnouncementResponse)
async def update_announcement(
announcement_id: int,
data: AnnouncementUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""管理员更新公告"""
result = await db.execute(
select(Announcement).where(Announcement.id == announcement_id)
)
announcement = result.scalar_one_or_none()
if not announcement:
raise HTTPException(status_code=404, detail="公告不存在")
update_data = data.model_dump(exclude_unset=True)
# If publishing for the first time, set published_at
if update_data.get("is_published") and not announcement.published_at:
announcement.published_at = datetime.now(timezone.utc)
for key, value in update_data.items():
setattr(announcement, key, value)
await db.commit()
await db.refresh(announcement)
return announcement
@router.delete("/{announcement_id}")
async def delete_announcement(
announcement_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""管理员删除公告"""
result = await db.execute(
select(Announcement).where(Announcement.id == announcement_id)
)
announcement = result.scalar_one_or_none()
if not announcement:
raise HTTPException(status_code=404, detail="公告不存在")
await db.delete(announcement)
await db.commit()
return {"message": "公告已删除"}
-27
View File
@@ -1,27 +0,0 @@
"""Audit log endpoints."""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.services.audit_log_service import AuditLogService
router = APIRouter(prefix="/audit", tags=["审计日志"])
@router.get("/logs")
async def list_audit_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: Optional[int] = None,
action: Optional[str] = None,
target_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
return await AuditLogService.list_logs(
db, page=page, page_size=page_size,
user_id=user_id, action=action, target_type=target_type,
)
-94
View File
@@ -1,94 +0,0 @@
"""操作审计日志 API。"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.operation_log import OperationLog
router = APIRouter(prefix="/audit-logs", tags=["audit-logs"])
@router.get("")
async def list_audit_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
action: Optional[str] = Query(None),
resource_type: Optional[str] = Query(None),
username: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""查询操作审计日志(仅管理员)。"""
query = select(OperationLog)
count_query = select(func.count(OperationLog.id))
if action:
query = query.where(OperationLog.action == action)
count_query = count_query.where(OperationLog.action == action)
if resource_type:
query = query.where(OperationLog.resource_type == resource_type)
count_query = count_query.where(OperationLog.resource_type == resource_type)
if username:
query = query.where(OperationLog.username.contains(username))
count_query = count_query.where(OperationLog.username.contains(username))
total = (await db.execute(count_query)).scalar() or 0
query = query.order_by(desc(OperationLog.created_at))
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
logs = result.scalars().all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": l.id,
"user_id": l.user_id,
"username": l.username,
"action": l.action,
"resource_type": l.resource_type,
"resource_id": l.resource_id,
"detail": l.detail,
"ip_address": l.ip_address,
"created_at": l.created_at.isoformat() if l.created_at else None,
}
for l in logs
],
}
@router.get("/stats")
async def audit_log_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""审计日志统计概览。"""
# 最近7天操作分布
from datetime import datetime, timedelta, timezone
since = datetime.now(timezone.utc) - timedelta(days=7)
result = await db.execute(
select(OperationLog.action, func.count(OperationLog.id))
.where(OperationLog.created_at >= since)
.group_by(OperationLog.action)
)
action_stats = {row[0]: row[1] for row in result.all()}
# 操作最多的用户
result = await db.execute(
select(OperationLog.username, func.count(OperationLog.id))
.where(OperationLog.created_at >= since)
.group_by(OperationLog.username)
.order_by(desc(func.count(OperationLog.id)))
.limit(5)
)
top_users = [{"username": row[0], "count": row[1]} for row in result.all()]
return {"action_stats": action_stats, "top_users": top_users}
-206
View File
@@ -1,206 +0,0 @@
"""Audit rule management endpoints."""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.audit_rule import AuditRule
from app.schemas.audit_rule import AuditRuleCreate, AuditRuleUpdate, AuditRuleResponse
from app.services.audit_rule_seeds import SEED_RULES_MAP
from app.services.audit_rule_seeds_v2 import seed_all_rules
router = APIRouter(prefix="/audit-rules", tags=["审核规则"])
@router.get("", response_model=List[AuditRuleResponse])
async def list_audit_rules(
rule_type: Optional[str] = None,
severity: Optional[str] = None,
db_type: Optional[str] = None,
is_enabled: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "auditor")),
):
stmt = select(AuditRule).order_by(AuditRule.id)
if rule_type:
stmt = stmt.where(AuditRule.rule_type == rule_type)
if severity:
stmt = stmt.where(AuditRule.severity == severity)
if db_type:
# 支持类型别名: postgresql -> postgres, mongo -> mongodb
db_type_aliases = {"postgresql": "postgres", "pg": "postgres", "mongo": "mongodb"}
resolved = db_type_aliases.get(db_type.lower(), db_type.lower())
stmt = stmt.where((AuditRule.db_type == resolved) | (AuditRule.db_type == db_type) | (AuditRule.db_type == "all"))
if is_enabled is not None:
stmt = stmt.where(AuditRule.is_enabled == is_enabled)
result = await db.execute(stmt)
return result.scalars().all()
@router.post("", response_model=AuditRuleResponse, status_code=201)
async def create_audit_rule(
data: AuditRuleCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
exists = await db.execute(select(AuditRule).where(AuditRule.name == data.name))
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail="规则名称已存在")
rule = AuditRule(**data.model_dump())
db.add(rule)
await db.commit()
await db.refresh(rule)
return rule
@router.get("/stats/summary")
async def rule_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "auditor")),
):
total_stmt = select(func.count(AuditRule.id))
total = (await db.execute(total_stmt)).scalar()
enabled_stmt = select(func.count(AuditRule.id)).where(AuditRule.is_enabled == True)
enabled = (await db.execute(enabled_stmt)).scalar()
type_stmt = select(AuditRule.rule_type, func.count(AuditRule.id)).group_by(AuditRule.rule_type)
type_result = (await db.execute(type_stmt)).all()
by_type = {row[0]: row[1] for row in type_result}
return {"total": total, "enabled": enabled, "disabled": total - enabled, "by_type": by_type}
@router.post("/seed/{db_type}", response_model=List[AuditRuleResponse])
async def seed_audit_rules(
db_type: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""批量初始化预设审核规则种子数据。
支持4种数据库类型:
- mysql: MySQL 专用规则 (60+ 条)
- postgres: PostgreSQL 专用规则 (45+ 条)
- redis: Redis 专用规则 (43+ 条)
- mongodb: MongoDB 专用规则 (42+ 条)
"""
db_type_lower = db_type.lower()
# 别名映射
db_type_aliases = {
"postgresql": "postgres",
"pg": "postgres",
"mongo": "mongodb",
}
db_type_lower = db_type_aliases.get(db_type_lower, db_type_lower)
if db_type_lower not in SEED_RULES_MAP:
raise HTTPException(
status_code=400,
detail=f"不支持的数据库类型: {db_type},支持: {', '.join(SEED_RULES_MAP.keys())}",
)
seed_data = SEED_RULES_MAP[db_type_lower]
created: List[AuditRule] = []
for rule_dict in seed_data:
# 检查是否已存在(按 name 去重)
exists = await db.execute(select(AuditRule).where(AuditRule.name == rule_dict["name"]))
if exists.scalar_one_or_none():
continue # 跳过已存在的规则
rule = AuditRule(**rule_dict)
db.add(rule)
created.append(rule)
await db.commit()
for rule in created:
await db.refresh(rule)
return created
@router.get("/{rule_id}", response_model=AuditRuleResponse)
async def get_audit_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "auditor")),
):
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="规则不存在")
return rule
@router.put("/{rule_id}", response_model=AuditRuleResponse)
async def update_audit_rule(
rule_id: int,
data: AuditRuleUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="规则不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(rule, key, value)
await db.commit()
await db.refresh(rule)
return rule
@router.delete("/{rule_id}")
async def delete_audit_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="规则不存在")
await db.delete(rule)
await db.commit()
return {"message": "规则已删除"}
@router.post("/{rule_id}/toggle", response_model=AuditRuleResponse)
async def toggle_audit_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="规则不存在")
rule.is_enabled = not rule.is_enabled
await db.commit()
await db.refresh(rule)
return rule
@router.post("/seed-v2", response_model=List[AuditRuleResponse])
async def seed_v2_audit_rules(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""批量初始化 SQL 审核引擎 V2 的所有规则种子数据(40+ 条规则)。
涵盖 DDL/DML/DQL/命名规范/性能/安全等多个维度,
跳过数据库中已存在的规则(按名称去重)。
"""
created = await seed_all_rules(db)
return created
-157
View File
@@ -1,157 +0,0 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import verify_password, hash_password, create_access_token, get_current_user
from app.models.user import User
from app.schemas.auth import LoginRequest, TokenResponse, UserBrief
router = APIRouter(prefix="/auth", tags=["认证"])
async def _try_ldap_auth(username: str, password: str):
"""尝试 LDAP 认证,成功返回用户信息 dict,失败返回 None"""
from app.core.database import async_session
from app.models.ldap_config import LdapConfig
from app.core.crypto import decrypt_password
import json
async with async_session() as db:
result = await db.execute(select(LdapConfig).limit(1))
ldap = result.scalar_one_or_none()
if not ldap or not ldap.ldap_host:
return None
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
# Admin bind to search user
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
# Search with uid filter
base_filter = ldap.search_filter or "(objectClass=inetOrgPerson)"
if "%s" in base_filter:
user_filter = base_filter.replace("%s", username)
else:
user_filter = f"(&{base_filter}(uid={username}))"
results = conn.search_s(
ldap.base_dn, _ldap.SCOPE_SUBTREE, user_filter,
["uid", "cn", "mail", "telephoneNumber"],
)
if not results or not results[0][0]:
conn.unbind_s()
return None
user_dn, user_attrs = results[0]
# Verify password by binding as user
try:
conn.simple_bind_s(user_dn, password)
except Exception:
conn.unbind_s()
return None
# Extract info
def _attr(key):
vals = user_attrs.get(key, [])
return vals[0].decode("utf-8", errors="ignore") if vals else ""
user_mapping = json.loads(ldap.user_mapping) if ldap.user_mapping else {}
info = {
"username": _attr(user_mapping.get("username", "uid")) or username,
"display_name": _attr(user_mapping.get("display_name", "cn")) or username,
"email": _attr(user_mapping.get("email", "mail")) or None,
}
conn.unbind_s()
return info
except Exception:
return None
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == req.username))
user = result.scalar_one_or_none()
auth_source = "local"
if user:
# 本地用户存在,用本地密码验证
if not verify_password(req.password, user.hashed_password):
# 本地密码不对,记录失败
from app.services.audit_logger import log_operation
await log_operation(db, action="login_failed", username=req.username,
detail="登录失败: 用户名或密码错误", request=request)
await db.commit()
raise HTTPException(status_code=401, detail="用户名或密码错误")
else:
# 本地没有该用户,尝试 LDAP 认证
ldap_info = await _try_ldap_auth(req.username, req.password)
if not ldap_info:
from app.services.audit_logger import log_operation
await log_operation(db, action="login_failed", username=req.username,
detail="登录失败: 本地和LDAP均未通过", request=request)
await db.commit()
raise HTTPException(status_code=401, detail="用户名或密码错误")
# LDAP 认证成功,自动创建本地用户
user = User(
username=ldap_info["username"],
display_name=ldap_info["display_name"],
email=ldap_info.get("email"),
hashed_password=hash_password(req.password),
role="developer",
is_active=True,
)
db.add(user)
await db.flush()
auth_source = "ldap"
if not user.is_active:
raise HTTPException(status_code=403, detail="用户已禁用")
user.last_login = datetime.now(timezone.utc)
from app.services.audit_logger import log_operation
await log_operation(db, action="login", user_id=user.id, username=user.username,
detail=f"登录成功 ({auth_source})", request=request)
await db.commit()
token = create_access_token(data={"sub": str(user.id), "role": user.role})
return TokenResponse(
access_token=token,
user=UserBrief(
id=user.id,
username=user.username,
display_name=user.display_name,
role=user.role,
email=user.email,
),
)
@router.get("/me", response_model=UserBrief)
async def get_me(current_user: User = Depends(get_current_user)):
return UserBrief(
id=current_user.id,
username=current_user.username,
display_name=current_user.display_name,
role=current_user.role,
email=current_user.email,
)
@router.post("/logout")
async def logout():
return {"message": "已登出"}
-62
View File
@@ -1,62 +0,0 @@
"""Backup management API."""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.backup import Backup
from app.schemas.backup import BackupCreate, BackupResponse
router = APIRouter(prefix="/backups", tags=["备份管理"])
@router.get("", response_model=List[BackupResponse])
async def list_backups(
instance_id: Optional[int] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
stmt = select(Backup).order_by(Backup.id.desc())
if instance_id:
stmt = stmt.where(Backup.instance_id == instance_id)
result = await db.execute(stmt)
backups = result.scalars().all()
return backups
@router.post("", response_model=BackupResponse, status_code=201)
async def create_backup(
data: BackupCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
backup = Backup(
title=data.title,
instance_id=data.instance_id,
database=data.database,
filename=data.database + "_" + str(data.instance_id) + ".sql",
status="pending",
created_by=current_user.id,
remark=data.remark,
)
db.add(backup)
await db.commit()
await db.refresh(backup)
return backup
@router.get("/{backup_id}", response_model=BackupResponse)
async def get_backup(
backup_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
result = await db.execute(select(Backup).where(Backup.id == backup_id))
backup = result.scalar_one_or_none()
if not backup:
raise HTTPException(status_code=404, detail="备份记录不存在")
return backup
-224
View File
@@ -1,224 +0,0 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, func, case, extract
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.ticket import Ticket
from app.models.instance import Instance
from app.models.audit_log import AuditLog
from app.models.query_history import QueryHistory
from app.schemas.dashboard import DashboardStats, TrendData, TrendPoint
router = APIRouter(prefix="/dashboard", tags=["仪表盘"])
@router.get("/stats", response_model=DashboardStats)
async def get_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
now = datetime.now(timezone.utc)
month_ago = now - timedelta(days=30)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
ticket_total = (await db.execute(select(func.count(Ticket.id)))).scalar() or 0
ticket_pending = (await db.execute(
select(func.count(Ticket.id)).where(Ticket.status.in_(["pending", "reviewing"]))
)).scalar() or 0
ticket_month = (await db.execute(
select(func.count(Ticket.id)).where(Ticket.create_time >= month_ago)
)).scalar() or 0
ticket_executing = (await db.execute(
select(func.count(Ticket.id)).where(Ticket.status == "executing")
)).scalar() or 0
instance_total = (await db.execute(select(func.count(Instance.id)))).scalar() or 0
instance_online = (await db.execute(
select(func.count(Instance.id)).where(Instance.status == "connected")
)).scalar() or 0
user_total = (await db.execute(
select(func.count(User.id)).where(User.is_active == True)
)).scalar() or 0
queries_today = (await db.execute(
select(func.count(QueryHistory.id)).where(
QueryHistory.created_at >= today_start,
)
)).scalar() or 0
# --- 查询统计 ---
qh_today = select(QueryHistory).where(QueryHistory.created_at >= today_start)
total_q = (await db.execute(
select(func.count(QueryHistory.id))
)).scalar() or 0
avg_time = (await db.execute(
select(func.avg(QueryHistory.execution_time_ms))
)).scalar() or 0
slow_count = (await db.execute(
select(func.count(QueryHistory.id)).where(QueryHistory.execution_time_ms > 1000)
)).scalar() or 0
# 按数据库类型分布
type_rows = (await db.execute(
select(QueryHistory.db_type, func.count(QueryHistory.id).label('cnt'))
.group_by(QueryHistory.db_type)
)).all()
type_distribution = [
{"label": row[0] or "unknown", "count": row[1]}
for row in type_rows
]
from app.schemas.dashboard import QueryStatsData
query_stats = QueryStatsData(
total=total_q,
avg_time_ms=int(avg_time),
slow_count=slow_count,
type_distribution=type_distribution,
)
return DashboardStats(
ticket_total=ticket_total,
ticket_pending=ticket_pending,
ticket_month=ticket_month,
ticket_executing=ticket_executing,
instance_total=instance_total,
instance_online=instance_online,
user_total=user_total,
queries_today=queries_today,
query_stats=query_stats,
)
@router.get("/trend", response_model=TrendData)
async def get_trend(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""工单趋势数据 — 按天聚合最近 N 天的提交/成功/失败数"""
now = datetime.now(timezone.utc)
start = now - timedelta(days=days)
# 按天聚合工单数
rows = (await db.execute(
select(
func.date(Ticket.create_time).label('d'),
func.count(Ticket.id).label('total'),
func.sum(case((Ticket.status.in_(['completed', 'success']), 1), else_=0)).label('success'),
func.sum(case((Ticket.status.in_(['failed', 'rejected']), 1), else_=0)).label('failed'),
)
.where(Ticket.create_time >= start)
.group_by(func.date(Ticket.create_time))
.order_by(func.date(Ticket.create_time))
)).all()
# 构建日期映射
data_map = {}
for row in rows:
d = str(row[0])
data_map[d] = {"submitted": row[1], "success": int(row[2] or 0), "failed": int(row[3] or 0)}
# 填充缺失日期
items = []
for i in range(days):
dt = (start + timedelta(days=i)).strftime('%Y-%m-%d')
d = data_map.get(dt, {"submitted": 0, "success": 0, "failed": 0})
label = dt[5:] # MM-DD
items.append(TrendPoint(date=label, **d))
return TrendData(items=items)
@router.get("/my-tickets")
async def my_pending_tickets(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""返回当前用户关联的待处理工单:
- admin: 所有 pending/reviewing/executing
- dba: 自己需要审批的 + 自己提交的
- developer/auditor: 自己提交的
"""
from app.models.workflow import TicketApproval, WorkflowStep
from sqlalchemy import or_
import json as _json
user_id = current_user.id
role = current_user.role
if role == "admin":
# admin 看所有待处理
rows = (await db.execute(
select(Ticket).where(
Ticket.status.in_(["pending", "reviewing", "executing"])
).order_by(Ticket.create_time.desc()).limit(10)
)).scalars().all()
else:
# 非admin: 找"和我有关"的工单
# 1) 我提交的
my_submitted = set((await db.execute(
select(Ticket.id).where(
Ticket.status.in_(["pending", "reviewing", "executing"]),
Ticket.applicant_id == user_id,
)
)).scalars().all())
# 2) ticket_approvals 中 action=pending 且 approver_id = me
my_approvals = set((await db.execute(
select(TicketApproval.ticket_id).where(
TicketApproval.action == "pending",
TicketApproval.approver_id == user_id,
)
)).scalars().all())
# 3) 通过角色匹配: approver_role == my role
my_role_approvals = set((await db.execute(
select(TicketApproval.ticket_id).where(
TicketApproval.action == "pending",
TicketApproval.approver_role == role,
)
)).scalars().all())
all_ids = my_submitted | my_approvals | my_role_approvals
if not all_ids:
return []
rows = (await db.execute(
select(Ticket).where(Ticket.id.in_(all_ids)).order_by(Ticket.create_time.desc()).limit(10)
)).scalars().all()
# 用户名映射
all_users = (await db.execute(select(User))).scalars().all()
user_map = {u.id: u.username for u in all_users}
result = []
for t in rows:
pending_approvals = (await db.execute(
select(TicketApproval).where(
TicketApproval.ticket_id == t.id,
TicketApproval.action == "pending",
)
)).scalars().all()
pending_info = []
for pa in pending_approvals:
approver_name = user_map.get(pa.approver_id, "")
label = pa.step_name or ""
if approver_name:
pending_info.append(f"{label}: {approver_name}" if label else approver_name)
is_mine = t.applicant_id == user_id
result.append({
"id": t.id,
"title": t.title or "",
"status": t.status,
"db_type": t.db_type or "",
"applicant": user_map.get(t.applicant_id, ""),
"create_time": t.create_time.strftime("%m-%d %H:%M") if t.create_time else "",
"pending_info": ", ".join(filter(None, pending_info)),
"relation": "我提交的" if is_mine else "待我审批",
})
return result
-266
View File
@@ -1,266 +0,0 @@
"""数据库类型说明 API — 人性化数据库能力查询。
提供每种数据库类型的详细信息,包括:
- 支持的操作和限制
- 查询语法示例
- 安全规则说明
- 推荐使用场景
"""
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, List, Any
from app.core.security import get_current_user
from app.models.user import User
router = APIRouter(prefix="/db-types", tags=["数据库类型说明"])
# ── 数据库类型元数据 ──
DB_TYPE_INFO: Dict[str, Dict[str, Any]] = {
"mysql": {
"name": "MySQL",
"icon": "/db-logos/mysql.svg",
"description": "最流行的开源关系型数据库,适合 Web 应用、OLTP 场景",
"version": "5.7+ / 8.0+",
"category": "关系型",
"supported_query_types": [
{"type": "SELECT", "description": "数据查询", "example": "SELECT id, name FROM users WHERE status = 'active' LIMIT 100"},
{"type": "SHOW", "description": "元数据查询", "example": "SHOW TABLES; SHOW PROCESSLIST; SHOW VARIABLES LIKE 'slow%'"},
{"type": "DESCRIBE", "description": "表结构查看", "example": "DESCRIBE users;"},
{"type": "EXPLAIN", "description": "执行计划分析", "example": "EXPLAIN SELECT * FROM users WHERE id = 1"},
],
"security_rules": [
"仅支持 SELECT / SHOW / DESCRIBE / EXPLAIN 只读查询",
"禁止 DML (INSERT/UPDATE/DELETE) 和 DDL (CREATE/ALTER/DROP)",
"查询结果自动脱敏敏感字段",
"最大返回行数限制 (默认 1000 行)",
],
"tips": [
"使用 EXPLAIN 分析慢查询",
"避免 SELECT *,明确指定需要的列",
"大数据量查询使用 LIMIT 分页",
"利用索引提示 FORCE INDEX 优化查询",
],
"common_commands": {
"查看所有表": "SHOW TABLES",
"查看表结构": "DESCRIBE table_name",
"查看建表语句": "SHOW CREATE TABLE table_name",
"查看索引": "SHOW INDEX FROM table_name",
"查看连接": "SHOW PROCESSLIST",
"查看状态": "SHOW STATUS LIKE 'Threads%'",
},
"limitations": [
"不支持存储过程调用",
"不支持临时表创建",
"不支持 LOAD DATA INFILE",
],
},
"postgres": {
"name": "PostgreSQL",
"icon": "/db-logos/postgres.svg",
"description": "功能最强大的开源关系型数据库,适合复杂查询、GIS、JSON 场景",
"version": "12+ / 13+ / 14+ / 15+",
"category": "关系型",
"supported_query_types": [
{"type": "SELECT", "description": "数据查询", "example": "SELECT id, name FROM users WHERE status = 'active' LIMIT 100"},
{"type": "EXPLAIN", "description": "执行计划分析", "example": "EXPLAIN ANALYZE SELECT * FROM users WHERE id = 1"},
],
"security_rules": [
"仅支持 SELECT 只读查询",
"支持 EXPLAIN / EXPLAIN ANALYZE (只读)",
"禁止 DML (INSERT/UPDATE/DELETE) 和 DDL 操作",
"查询结果自动脱敏",
],
"tips": [
"使用 EXPLAIN ANALYZE 获取真实执行统计",
"利用 CTE (WITH 语句) 优化复杂查询",
"pg_stat_user_tables 查看表统计信息",
"使用 JSONB 类型和操作符处理 JSON 数据",
],
"common_commands": {
"查看所有表": "SELECT tablename FROM pg_tables WHERE schemaname = 'public'",
"查看表大小": "SELECT pg_size_pretty(pg_total_relation_size('table_name'))",
"查看活动查询": "SELECT * FROM pg_stat_activity WHERE state = 'active'",
"查看索引使用": "SELECT * FROM pg_stat_user_indexes ORDER BY idx_scan DESC",
"查看锁等待": "SELECT * FROM pg_locks WHERE NOT granted",
"查看表统计": "SELECT * FROM pg_stat_user_tables ORDER BY n_live_tup DESC",
},
"limitations": [
"不支持存储过程调用",
"不支持 COPY 命令",
"不支持事务控制 (BEGIN/COMMIT)",
],
},
"redis": {
"name": "Redis",
"icon": "/db-logos/redis.svg",
"description": "高性能内存键值数据库,适合缓存、会话管理、排行榜场景",
"version": "6.0+ / 7.0+",
"category": "键值型",
"supported_query_types": [
{"type": "GET", "description": "获取字符串值", "example": "GET user:1001"},
{"type": "HGETALL", "description": "获取哈希所有字段", "example": "HGETALL user:1001"},
{"type": "LRANGE", "description": "获取列表范围", "example": "LRANGE mylist 0 -1"},
{"type": "SMEMBERS", "description": "获取集合所有成员", "example": "SMEMBERS myset"},
{"type": "ZRANGE", "description": "获取有序集合范围", "example": "ZRANGE leaderboard 0 10 WITHSCORES"},
{"type": "INFO", "description": "服务器信息", "example": "INFO server; INFO memory"},
{"type": "SCAN", "description": "迭代键", "example": "SCAN 0 MATCH user:* COUNT 50"},
],
"security_rules": [
"仅支持只读命令白名单 (40+ 命令)",
"禁止写入命令 (SET/DEL/FLUSHDB 等)",
"禁止管理命令 (SHUTDOWN/DEBUG 等)",
"KEYS 命令建议用 SCAN 替代 (生产环境安全)",
],
"tips": [
"使用 SCAN 代替 KEYS 避免阻塞",
"HGETALL 获取完整哈希,HGET 获取单个字段",
"ZRANGE WITHSCORES 获取分数",
"TYPE key 查看键的数据类型",
"TTL key 查看剩余过期时间",
],
"common_commands": {
"查看键数量": "DBSIZE",
"查看服务器信息": "INFO server",
"查看内存使用": "INFO memory",
"查看键类型": "TYPE key_name",
"扫描键": "SCAN 0 MATCH pattern COUNT 50",
"获取字符串": "GET key_name",
"获取哈希": "HGETALL key_name",
"查看客户端": "CLIENT LIST",
},
"limitations": [
"不支持 Lua 脚本执行 (EVAL)",
"不支持事务 (MULTI/EXEC)",
"SCAN 命令不支持跨数据库",
],
},
"mongo": {
"name": "MongoDB",
"icon": "/db-logos/mongo.svg",
"description": "文档型 NoSQL 数据库,适合灵活 Schema、JSON 原生存储场景",
"version": "4.4+ / 5.0+ / 6.0+",
"category": "文档型",
"supported_query_types": [
{"type": "find", "description": "查询文档", "example": "db.users.find({status: 'active'}).limit(100)"},
{"type": "aggregate", "description": "聚合管道", "example": "db.orders.aggregate([{$match: {status: 'completed'}}, {$group: {_id: '$product', total: {$sum: '$amount'}}}])"},
{"type": "countDocuments", "description": "计数", "example": "db.users.countDocuments({status: 'active'})"},
{"type": "distinct", "description": "去重值", "example": "db.users.distinct('department')"},
],
"security_rules": [
"仅支持 find/aggregate/countDocuments/distinct 只读操作",
"聚合管道禁止 $out 和 $merge (防止写入)",
"禁止 insert/update/delete/drop 等写操作",
"查询结果限制最大返回数量",
],
"tips": [
"使用投影 (projection) 只返回需要的字段",
"aggregate 支持多阶段管道处理",
"$lookup 实现类似 SQL JOIN 的关联查询",
"createIndex / getIndexes 查看索引信息",
],
"common_commands": {
"查看集合": "show collections",
"查找文档": "db.collection.find({}).limit(20)",
"计数文档": "db.collection.countDocuments({})",
"聚合统计": "db.collection.aggregate([{$group: {_id: '$field', count: {$sum: 1}}}])",
"查看索引": "db.collection.getIndexes()",
"查看统计": "db.collection.stats()",
},
"limitations": [
"聚合管道不支持 $out/$merge",
"不支持 eval 命令",
"不支持创建/删除集合",
],
},
}
@router.get("")
async def list_db_types(
current_user: User = Depends(get_current_user),
):
"""获取所有支持的数据库类型概览。
返回每种数据库类型的基本信息,包括名称、图标、描述和类别。
用于前端展示数据库类型选择器或帮助信息。
"""
result = []
for db_type, info in DB_TYPE_INFO.items():
result.append({
"type": db_type,
"name": info["name"],
"icon": info["icon"],
"description": info["description"],
"category": info["category"],
"version": info["version"],
"supported_operations": len(info["supported_query_types"]),
"security_rules_count": len(info["security_rules"]),
})
return {"items": result, "total": len(result)}
@router.get("/{db_type}")
async def get_db_type_detail(
db_type: str,
current_user: User = Depends(get_current_user),
):
"""获取指定数据库类型的详细信息。
包含: 支持的操作、查询语法示例、安全规则、使用技巧、常用命令。
用于查询页面的上下文帮助和语法提示。
"""
if db_type not in DB_TYPE_INFO:
raise HTTPException(
status_code=404,
detail="不支持的数据库类型: " + db_type + "。支持: mysql, postgres, redis, mongo",
)
info = DB_TYPE_INFO[db_type]
return {
"type": db_type,
**info,
}
@router.get("/{db_type}/commands")
async def get_db_type_commands(
db_type: str,
current_user: User = Depends(get_current_user),
):
"""获取指定数据库类型的常用命令列表。
返回常用命令名称和示例,用于快捷查询模板。
"""
if db_type not in DB_TYPE_INFO:
raise HTTPException(
status_code=404,
detail="不支持的数据库类型: " + db_type,
)
commands = DB_TYPE_INFO[db_type]["common_commands"]
result = [
{"name": name, "command": cmd}
for name, cmd in commands.items()
]
return {"items": result, "total": len(result)}
@router.get("/{db_type}/tips")
async def get_db_type_tips(
db_type: str,
current_user: User = Depends(get_current_user),
):
"""获取指定数据库类型的使用技巧。
用于前端展示小贴士,帮助用户更好地使用查询功能。
"""
if db_type not in DB_TYPE_INFO:
raise HTTPException(
status_code=404,
detail="不支持的数据库类型: " + db_type,
)
tips = DB_TYPE_INFO[db_type]["tips"]
return {"items": tips, "total": len(tips)}
@@ -1,70 +0,0 @@
"""数据字典 API 端点。"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.services import dictionary_service
router = APIRouter(tags=["数据字典"])
@router.get("/instances/{instance_id}/databases")
async def list_databases(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "developer")),
):
"""获取实例的数据库列表。"""
databases = await dictionary_service.list_databases(db, instance_id)
if databases is None:
raise HTTPException(status_code=404, detail="数据源不存在")
return {"databases": databases}
@router.get("/instances/{instance_id}/tables")
async def list_tables(
instance_id: int,
database: str = Query(..., description="数据库名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "developer")),
):
"""获取指定数据库的表列表。"""
tables = await dictionary_service.list_tables(db, instance_id, database)
if tables is None:
raise HTTPException(status_code=404, detail="数据源不存在")
return {"tables": tables}
@router.get("/instances/{instance_id}/tables/{table_name}")
async def get_table_detail(
instance_id: int,
table_name: str,
database: str = Query(..., description="数据库名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "developer")),
):
"""获取表详情: 字段/索引/DDL/行数。"""
detail = await dictionary_service.get_table_detail(
db, instance_id, database, table_name
)
if not detail:
raise HTTPException(status_code=404, detail="数据源不存在")
return detail
@router.get("/instances/{instance_id}/export")
async def export_dictionary(
instance_id: int,
database: str = Query(..., description="数据库名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "developer")),
):
"""导出整个数据库的表结构信息。"""
result = await dictionary_service.export_dictionary(
db, instance_id, database
)
return {"tables": result}
-33
View File
@@ -1,33 +0,0 @@
"""影响分析 API。"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.instance import Instance
from app.schemas.impact import ImpactAnalysisRequest, ImpactAnalysisResponse
from app.services.impact_analyzer import ImpactAnalyzer
router = APIRouter(prefix="/impact", tags=["影响分析"])
@router.post("/analyze", response_model=ImpactAnalysisResponse)
async def analyze_impact(
data: ImpactAnalysisRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""分析 SQL 执行影响。
预估影响行数、执行时间、风险等级。
"""
result = await db.execute(select(Instance).where(Instance.id == data.instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
analyzer = ImpactAnalyzer()
analysis = await analyzer.analyze(instance, data.database, data.sql_content)
return analysis.to_dict()
-295
View File
@@ -1,295 +0,0 @@
import json
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.core.permissions import check_instance_access, get_user_accessible_group_ids
from app.models.user import User
from app.models.instance import Instance
from app.models.instance_group import user_groups
from app.schemas.instance import InstanceCreate, InstanceUpdate, InstanceResponse, ConnectionTestResult
from app.services.instance_service import InstanceService
from app.core.crypto import encrypt_password, decrypt_password
from app.services.audit_log_service import AuditLogService
router = APIRouter(prefix="/instances", tags=["数据源管理"])
def _serialize_json_fields(instance: Instance, workflow_name: str = None) -> 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_id": instance.workflow_id,
"workflow_name": workflow_name,
"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
async def _get_workflow_names(db: AsyncSession, instances: list) -> dict:
"""批量获取 workflow_id -> name 映射"""
from app.models.workflow import WorkflowDefinition
wids = list({i.workflow_id for i in instances if i.workflow_id})
if not wids:
return {}
result = await db.execute(select(WorkflowDefinition).where(WorkflowDefinition.id.in_(wids)))
return {w.id: w.name for w in result.scalars().all()}
@router.get("/accessible", response_model=List[InstanceResponse])
async def list_accessible_instances(
access_type: str = "readonly",
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""返回当前用户有权访问的实例列表(按权限组的 access_type 过滤)。
所有用户(包括 admin)统一按权限组关联过滤:
user_groups → instance_group_members(access_type) → 返回对应实例
access_type: readonly=查询授权, write=变更授权
"""
from app.models.instance_group import instance_group_members, user_groups as ug_table
# 查用户关联的所有权限组
grp_result = await db.execute(
select(ug_table.c.group_id).where(ug_table.c.user_id == current_user.id)
)
group_ids = [row[0] for row in grp_result.all()]
if not group_ids:
return []
# 从 instance_group_members 中获取指定 access_type 的 instance_id
mem_result = await db.execute(
select(instance_group_members.c.instance_id)
.where(
instance_group_members.c.group_id.in_(group_ids),
instance_group_members.c.access_type == access_type,
)
.distinct()
)
instance_ids = [row[0] for row in mem_result.all()]
if not instance_ids:
return []
# 查实例详情
stmt = select(Instance).where(Instance.id.in_(instance_ids)).order_by(Instance.id)
result = await db.execute(stmt)
instances = result.scalars().all()
wnames = await _get_workflow_names(db, instances)
return [_serialize_json_fields(i, wnames.get(i.workflow_id)) for i in instances]
@router.get("", response_model=List[InstanceResponse])
async def list_instances(
db_type: Optional[str] = None,
env: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
stmt = select(Instance).order_by(Instance.id)
if db_type:
stmt = stmt.where(Instance.db_type == db_type)
if env:
stmt = stmt.where(Instance.env == env)
# P0: 权限组过滤 — 非 admin 只能看到自己权限组内的 + 公开实例
if current_user.role != "admin":
result = await db.execute(
select(user_groups.c.group_id).where(user_groups.c.user_id == current_user.id)
)
user_group_ids = [row[0] for row in result.all()]
if user_group_ids:
stmt = stmt.where(
(Instance.group_id.in_(user_group_ids)) | (Instance.group_id.is_(None))
)
else:
# 没有任何权限组,只能看公开实例
stmt = stmt.where(Instance.group_id.is_(None))
result = await db.execute(stmt)
instances = result.scalars().all()
wnames = await _get_workflow_names(db, instances)
return [_serialize_json_fields(i, wnames.get(i.workflow_id)) for i in instances]
@router.post("", response_model=InstanceResponse, status_code=201)
async def create_instance(
data: InstanceCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = Instance(
name=data.name,
db_type=data.db_type,
host=data.host,
port=data.port,
username=data.username,
password=encrypt_password(data.password),
database=data.database,
env=data.env,
description=data.description,
access_mode=data.access_mode,
workflow_id=data.workflow_id,
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()
await AuditLogService.log(
db, user_id=current_user.id, action="CREATE_INSTANCE",
target_type="instance", target_id=instance.id,
detail=f"创建数据源: {data.name} ({data.db_type}@{data.host}:{data.port})",
)
await db.commit()
await db.refresh(instance)
wnames = await _get_workflow_names(db, [instance])
return _serialize_json_fields(instance, wnames.get(instance.workflow_id))
@router.post("/test-connection", response_model=ConnectionTestResult)
async def test_new_connection(
data: InstanceCreate,
current_user: User = Depends(require_role("admin", "dba")),
):
"""测试连接参数(不保存实例)"""
fake = Instance(
name=data.name, db_type=data.db_type, host=data.host, port=data.port,
username=data.username, password=data.password, database=data.database,
env=data.env, access_mode=data.access_mode,
)
return await InstanceService.test_connection(fake)
@router.get("/{instance_id}", response_model=InstanceResponse)
async def get_instance(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
# P0: 检查访问权限
await check_instance_access(current_user, instance, db)
wnames = await _get_workflow_names(db, [instance])
return _serialize_json_fields(instance, wnames.get(instance.workflow_id))
@router.put("/{instance_id}", response_model=InstanceResponse)
async def update_instance(
instance_id: int,
data: InstanceUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
update_data = data.model_dump(exclude_unset=True)
if "password" in update_data:
update_data["password"] = encrypt_password(update_data["password"])
# workflow_id: 0 means clear
if "workflow_id" in update_data and update_data["workflow_id"] == 0:
update_data["workflow_id"] = 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)
wnames = await _get_workflow_names(db, [instance])
return _serialize_json_fields(instance, wnames.get(instance.workflow_id))
@router.delete("/{instance_id}")
async def delete_instance(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
await AuditLogService.log(
db, user_id=current_user.id, action="DELETE_INSTANCE",
target_type="instance", target_id=instance.id,
detail=f"删除数据源: {instance.name}",
)
await db.delete(instance)
await db.commit()
return {"message": "数据源已删除"}
@router.post("/{instance_id}/test", response_model=ConnectionTestResult)
async def test_connection(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
instance.password = decrypt_password(instance.password)
return await InstanceService.test_connection(instance)
@router.get("/{instance_id}/databases")
async def list_databases(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
# P0: 检查访问权限
await check_instance_access(current_user, instance, db)
return await InstanceService.list_databases(instance)
@router.get("/{instance_id}/databases/{database}/tables")
async def list_tables(
instance_id: int,
database: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
# P0: 检查访问权限
await check_instance_access(current_user, instance, db)
return await InstanceService.list_tables(instance, database)
@@ -1,348 +0,0 @@
"""MySQL instance detail management API — databases, tables, users, slow queries, processlist, status, variables."""
import asyncio
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_role
from app.core.permissions import check_instance_access
from app.models.user import User
from app.models.instance import Instance
from app.core.crypto import decrypt_password
router = APIRouter(prefix="/instances", tags=["实例详情"])
def _get_instance_or_404(db: AsyncSession, instance_id: int):
"""Helper to get instance or raise 404. Returns coroutine."""
return db.execute(select(Instance).where(Instance.id == instance_id))
async def _get_decrypted_instance(db: AsyncSession, instance_id: int) -> Instance:
result = await _get_instance_or_404(db, instance_id)
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
instance.password = decrypt_password(instance.password)
return instance
async def _mysql_query(instance: Instance, sql: str, database: Optional[str] = None, params: Optional[list] = None) -> list:
"""Execute a MySQL query and return rows as list of dicts."""
import aiomysql
conn = await aiomysql.connect(
host=instance.host, port=instance.port,
user=instance.username, password=instance.password,
db=database or instance.database,
)
try:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(sql, params)
rows = await cur.fetchall()
return rows
finally:
conn.close()
async def _mysql_execute(instance: Instance, sql: str, database: Optional[str] = None):
"""Execute a MySQL statement (no result set needed)."""
import aiomysql
conn = await aiomysql.connect(
host=instance.host, port=instance.port,
user=instance.username, password=instance.password,
db=database or instance.database,
)
try:
async with conn.cursor() as cur:
await cur.execute(sql)
finally:
conn.close()
# ---- Databases ----
@router.get("/{instance_id}/detail/databases")
async def list_databases(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "developer", "auditor")),
):
instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(instance, "SHOW DATABASES")
databases = [
row["Database"] for row in rows
if row["Database"] not in ("information_schema", "performance_schema", "sys", "mysql")
]
return databases
except Exception as e:
raise HTTPException(status_code=500, detail="查询数据库列表失败: " + str(e))
# ---- Tables ----
@router.get("/{instance_id}/detail/tables")
async def list_tables(
instance_id: int,
database: str = Query(..., description="数据库名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "developer", "auditor")),
):
instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(instance, "SHOW TABLE STATUS", database=database)
tables = []
for row in rows:
tables.append({
"name": row.get("Name", ""),
"engine": row.get("Engine", ""),
"rows": row.get("Rows", 0),
"data_length": row.get("Data_length", 0),
"index_length": row.get("Index_length", 0),
"comment": row.get("Comment", ""),
"collation": row.get("Collation", ""),
"create_time": str(row.get("Create_time", "")),
"update_time": str(row.get("Update_time", "")),
})
return tables
except Exception as e:
raise HTTPException(status_code=500, detail="查询表列表失败: " + str(e))
# ---- DB Schema (for autocomplete) ----
@router.get("/{instance_id}/detail/db-schema")
async def get_db_schema(
instance_id: int,
database: str = Query(..., description="数据库名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "developer")),
):
"""一次性返回整个库的 {table: [columns]} 结构,用于前端 SQL 自动补全"""
instance = await _get_decrypted_instance(db, instance_id)
await check_instance_access(current_user, instance, db)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(
instance,
"SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = %s ORDER BY TABLE_NAME, ORDINAL_POSITION",
database=database,
params=[database],
)
schema: dict[str, list[str]] = {}
for row in rows:
tbl = row["TABLE_NAME"]
col = row["COLUMN_NAME"]
schema.setdefault(tbl, []).append(col)
return schema
except Exception as e:
raise HTTPException(status_code=500, detail="获取库结构失败: " + str(e))
# ---- Table Detail ----
@router.get("/{instance_id}/detail/table-detail")
async def table_detail(
instance_id: int,
database: str = Query(..., description="数据库名"),
table: str = Query(..., description="表名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba", "developer", "auditor")),
):
instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
# SHOW CREATE TABLE
ddl_rows = await _mysql_query(
instance,
"SHOW CREATE TABLE " + table,
database=database,
)
ddl = ddl_rows[0].get("Create Table", "") if ddl_rows else ""
# SHOW COLUMNS
columns = await _mysql_query(
instance,
"SHOW COLUMNS FROM " + table,
database=database,
)
# SHOW INDEX
indexes = await _mysql_query(
instance,
"SHOW INDEX FROM " + table,
database=database,
)
return {
"ddl": ddl,
"columns": columns,
"indexes": indexes,
}
except Exception as e:
raise HTTPException(status_code=500, detail="查询表详情失败: " + str(e))
# ---- Users ----
@router.get("/{instance_id}/detail/users")
async def list_users(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(instance, "SELECT user, host FROM mysql.user")
users = [{"user": row.get("user", ""), "host": row.get("host", "")} for row in rows]
return users
except Exception as e:
raise HTTPException(status_code=500, detail="查询用户列表失败: " + str(e))
# ---- Grants ----
@router.get("/{instance_id}/detail/grants")
async def show_grants(
instance_id: int,
user: str = Query(..., description="用户名"),
host: str = Query(..., description="主机"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
quoted_user = user.replace("'", "\\'")
quoted_host = host.replace("'", "\\'")
sql = "SHOW GRANTS FOR '" + quoted_user + "'@'" + quoted_host + "'"
rows = await _mysql_query(instance, sql)
grants = []
for row in rows:
# The key varies by MySQL version, grab first value
grants.append(list(row.values())[0])
return {"user": user, "host": host, "grants": grants}
except Exception as e:
raise HTTPException(status_code=500, detail="查询权限失败: " + str(e))
# ---- Processlist ----
@router.get("/{instance_id}/detail/processlist")
async def show_processlist(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(instance, "SHOW FULL PROCESSLIST")
return rows
except Exception as e:
raise HTTPException(status_code=500, detail="查询进程列表失败: " + str(e))
# ---- Kill Process ----
@router.post("/{instance_id}/detail/processlist/{pid}/kill")
async def kill_process(
instance_id: int,
pid: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
await _mysql_execute(instance, "KILL CONNECTION " + str(pid))
return {"message": "进程已终止", "pid": pid}
except Exception as e:
raise HTTPException(status_code=500, detail="终止进程失败: " + str(e))
# ---- Status ----
@router.get("/{instance_id}/detail/status")
async def show_status(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(instance, "SHOW GLOBAL STATUS")
# Return key metrics
key_metrics = {}
important_keys = {
"Threads_connected", "Threads_running", "Threads_cached",
"Queries", "Questions", "Slow_queries",
"Connections", "Aborted_connects",
"Bytes_received", "Bytes_sent",
"Table_open_cache_hits", "Table_open_cache_misses",
"Innodb_row_lock_waits", "Innodb_row_lock_time",
"Uptime",
}
for row in rows:
var_name = row.get("Variable_name", "")
if var_name in important_keys:
key_metrics[var_name] = row.get("Value", "")
return {
"key_metrics": key_metrics,
"all_status": rows,
}
except Exception as e:
raise HTTPException(status_code=500, detail="查询状态失败: " + str(e))
# ---- Variables ----
@router.get("/{instance_id}/detail/variables")
async def show_variables(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
rows = await _mysql_query(instance, "SHOW VARIABLES")
return rows
except Exception as e:
raise HTTPException(status_code=500, detail="查询变量失败: " + str(e))
@@ -1,437 +0,0 @@
import json
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, delete as sa_delete, func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.models.user import User
from app.models.instance import Instance
from app.models.instance_group import InstanceGroup, user_groups, instance_group_members
from app.schemas.instance_group import (
InstanceGroupCreate, InstanceGroupUpdate,
InstanceGroupResponse, InstanceGroupDetailResponse,
InstanceAssignRequest, InstanceRemoveRequest,
UserAssignRequest, UserGroupUpdateRequest,
)
router = APIRouter(prefix="/instance-groups", tags=["权限组管理"])
INSTANCE_FIELDS = ["id", "name", "db_type", "host", "port", "env", "status"]
def _serialize_instance(inst: Instance) -> dict:
return {k: getattr(inst, k, None) for k in INSTANCE_FIELDS}
async def _count_members(db: AsyncSession, group_id: int, access_type: str) -> int:
result = await db.execute(
select(sa_func.count()).select_from(instance_group_members).where(
instance_group_members.c.group_id == group_id,
instance_group_members.c.access_type == access_type,
)
)
return result.scalar() or 0
async def _get_member_instances(db: AsyncSession, group_id: int, access_type: str) -> List[dict]:
"""查询权限组中指定 access_type 的实例列表。"""
# Join instance_group_members with instances
result = await db.execute(
select(Instance, instance_group_members.c.access_type)
.join(instance_group_members, Instance.id == instance_group_members.c.instance_id)
.where(
instance_group_members.c.group_id == group_id,
instance_group_members.c.access_type == access_type,
)
)
rows = result.all()
return [_serialize_instance(row[0]) for row in rows]
async def _serialize_group(group: InstanceGroup, db: AsyncSession) -> dict:
ro_count = await _count_members(db, group.id, "readonly")
wr_count = await _count_members(db, group.id, "write")
user_count = len(group.users) if group.users else 0
return {
"id": group.id,
"name": group.name,
"description": group.description,
"db_type": group.db_type,
"permissions": group.get_permissions(),
"allowed_databases": group.get_allowed_databases(),
"is_active": group.is_active,
"created_at": group.created_at,
"updated_at": group.updated_at,
"readonly_instance_count": ro_count,
"write_instance_count": wr_count,
"user_count": user_count,
}
async def _serialize_group_detail(group: InstanceGroup, db: AsyncSession) -> dict:
d = await _serialize_group(group, db)
d["readonly_instances"] = await _get_member_instances(db, group.id, "readonly")
d["write_instances"] = await _get_member_instances(db, group.id, "write")
d["users"] = [
{
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"role": u.role,
}
for u in (group.users or [])
]
return d
async def _sync_members(
db: AsyncSession,
group_id: int,
readonly_instance_ids: List[int],
write_instance_ids: List[int],
):
"""Clear and rewrite all instance_group_members for a group."""
await db.execute(
sa_delete(instance_group_members).where(
instance_group_members.c.group_id == group_id
)
)
for inst_id in readonly_instance_ids:
await db.execute(
instance_group_members.insert().values(
group_id=group_id,
instance_id=inst_id,
access_type="readonly",
)
)
for inst_id in write_instance_ids:
await db.execute(
instance_group_members.insert().values(
group_id=group_id,
instance_id=inst_id,
access_type="write",
)
)
@router.get("", response_model=List[InstanceGroupDetailResponse])
async def list_instance_groups(
db_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
stmt = select(InstanceGroup).order_by(InstanceGroup.id)
if db_type:
stmt = stmt.where(InstanceGroup.db_type == db_type)
result = await db.execute(stmt)
groups = result.scalars().all()
return [await _serialize_group_detail(g, db) for g in groups]
@router.post("", response_model=InstanceGroupDetailResponse, status_code=201)
async def create_instance_group(
data: InstanceGroupCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
# Check name uniqueness
existing = await db.execute(
select(InstanceGroup).where(InstanceGroup.name == data.name)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="权限组名称已存在")
group = InstanceGroup(
name=data.name,
description=data.description,
db_type=data.db_type,
allowed_databases=(
json.dumps(data.allowed_databases, ensure_ascii=False)
if data.allowed_databases
else None
),
permissions=json.dumps(data.permissions, ensure_ascii=False) if data.permissions else None,
is_active=data.is_active,
)
db.add(group)
await db.commit()
await db.refresh(group)
# Write members
await _sync_members(db, group.id, data.readonly_instance_ids, data.write_instance_ids)
await db.commit()
return await _serialize_group_detail(group, db)
@router.get("/{group_id}", response_model=InstanceGroupDetailResponse)
async def get_instance_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
return await _serialize_group_detail(group, db)
@router.put("/{group_id}", response_model=InstanceGroupDetailResponse)
async def update_instance_group(
group_id: int,
data: InstanceGroupUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
update_data = data.model_dump(exclude_unset=True)
if "name" in update_data:
# Check uniqueness
existing = await db.execute(
select(InstanceGroup).where(
InstanceGroup.name == update_data["name"],
InstanceGroup.id != group_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="权限组名称已存在")
# Handle legacy permissions field
if "permissions" in update_data:
update_data["permissions"] = (
json.dumps(update_data["permissions"], ensure_ascii=False)
if update_data["permissions"]
else None
)
# Handle new allowed_databases field
if "allowed_databases" in update_data:
update_data["allowed_databases"] = (
json.dumps(update_data["allowed_databases"], ensure_ascii=False)
if update_data["allowed_databases"]
else None
)
# Extract member lists before setting attributes
ro_ids = update_data.pop("readonly_instance_ids", None)
wr_ids = update_data.pop("write_instance_ids", None)
for key, value in update_data.items():
setattr(group, key, value)
await db.commit()
await db.refresh(group)
# Update members if provided
if ro_ids is not None or wr_ids is not None:
await _sync_members(
db, group_id,
ro_ids if ro_ids is not None else [],
wr_ids if wr_ids is not None else [],
)
await db.commit()
return await _serialize_group_detail(group, db)
@router.delete("/{group_id}")
async def delete_instance_group(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
# Delete members first
await db.execute(
sa_delete(instance_group_members).where(
instance_group_members.c.group_id == group_id
)
)
await db.delete(group)
await db.commit()
return {"message": "权限组已删除"}
@router.post("/{group_id}/instances")
async def assign_instances(
group_id: int,
data: InstanceAssignRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
for inst_id in data.instance_ids:
inst_result = await db.execute(
select(Instance).where(Instance.id == inst_id)
)
inst = inst_result.scalar_one_or_none()
if inst:
# Check for duplicate
existing = await db.execute(
select(instance_group_members).where(
instance_group_members.c.group_id == group_id,
instance_group_members.c.instance_id == inst_id,
instance_group_members.c.access_type == data.access_type,
)
)
if not existing.first():
await db.execute(
instance_group_members.insert().values(
group_id=group_id,
instance_id=inst_id,
access_type=data.access_type,
)
)
await db.commit()
return {"message": "实例已关联"}
@router.delete("/{group_id}/instances/{instance_id}")
async def remove_instance(
group_id: int,
instance_id: int,
access_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
conditions = [
instance_group_members.c.group_id == group_id,
instance_group_members.c.instance_id == instance_id,
]
if access_type:
conditions.append(instance_group_members.c.access_type == access_type)
result = await db.execute(
sa_delete(instance_group_members).where(*conditions)
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="实例未关联该权限组")
await db.commit()
return {"message": "实例已取消关联"}
@router.get("/{group_id}/users")
async def list_group_users(
group_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
# Query user_groups for permissions override
ug_result = await db.execute(
select(user_groups).where(user_groups.c.group_id == group_id)
)
ug_rows = ug_result.all()
perm_map = {}
for row in ug_rows:
perms = json.loads(row.permissions) if row.permissions else None
perm_map[row.user_id] = perms
users_data = []
for u in (group.users or []):
users_data.append({
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"role": u.role,
"permissions": perm_map.get(u.id),
})
return users_data
@router.post("/{group_id}/users")
async def assign_users(
group_id: int,
data: UserAssignRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == group_id)
)
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail="权限组不存在")
perm_json = json.dumps(data.permissions, ensure_ascii=False) if data.permissions else None
for user_id in data.user_ids:
# Check if already assigned
check = await db.execute(
select(user_groups).where(
user_groups.c.user_id == user_id,
user_groups.c.group_id == group_id,
)
)
if not check.first():
await db.execute(
user_groups.insert().values(
user_id=user_id,
group_id=group_id,
permissions=perm_json,
)
)
await db.commit()
return {"message": "用户已分配"}
@router.put("/users/{user_id}/groups")
async def update_user_groups(
user_id: int,
data: UserGroupUpdateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
user_result = await db.execute(
select(User).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# Remove existing group associations
await db.execute(
user_groups.delete().where(user_groups.c.user_id == user_id)
)
# Add new ones
for gid in data.group_ids:
group_result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == gid)
)
if group_result.scalar_one_or_none():
await db.execute(
user_groups.insert().values(user_id=user_id, group_id=gid)
)
await db.commit()
return {"message": "用户权限组已更新"}
-545
View File
@@ -1,545 +0,0 @@
import json
import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.crypto import encrypt_password, decrypt_password
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.ldap_config import LdapConfig
from app.schemas.ldap_config import (
LdapConfigCreate,
LdapConfigUpdate,
LdapConfigResponse,
LdapTestRequest,
)
router = APIRouter(prefix="/ldap-config", tags=["LDAP配置"])
def _serialize_ldap(ldap: LdapConfig) -> dict:
"""Serialize LdapConfig to response dict with masked password."""
user_mapping = json.loads(ldap.user_mapping) if ldap.user_mapping else None
return {
"id": ldap.id,
"ldap_host": ldap.ldap_host,
"ldap_port": ldap.ldap_port,
"use_ldaps": ldap.use_ldaps,
"admin_dn": ldap.admin_dn,
"admin_password": "******",
"search_filter": ldap.search_filter,
"base_dn": ldap.base_dn,
"user_mapping": user_mapping,
"created_at": ldap.created_at,
"updated_at": ldap.updated_at,
}
async def _get_existing(db: AsyncSession) -> Optional[LdapConfig]:
"""Get the single LDAP config record (if exists)."""
result = await db.execute(select(LdapConfig).limit(1))
return result.scalar_one_or_none()
@router.get("", response_model=LdapConfigResponse)
async def get_ldap_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""管理员获取 LDAP 配置"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建")
return _serialize_ldap(ldap)
@router.put("", response_model=LdapConfigResponse)
async def upsert_ldap_config(
data: LdapConfigCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""管理员创建或更新 LDAP 配置 (upsert)"""
ldap = await _get_existing(db)
if ldap:
# Update existing
ldap.ldap_host = data.ldap_host
ldap.ldap_port = data.ldap_port
ldap.use_ldaps = data.use_ldaps
ldap.admin_dn = data.admin_dn
if data.admin_password:
ldap.admin_password = encrypt_password(data.admin_password)
ldap.search_filter = data.search_filter
ldap.base_dn = data.base_dn
ldap.user_mapping = json.dumps(data.user_mapping, ensure_ascii=False) if data.user_mapping else None
else:
# Create new — 首次必须提供密码
if not data.admin_password:
raise HTTPException(status_code=400, detail="首次配置 LDAP 必须提供管理员密码")
ldap = LdapConfig(
ldap_host=data.ldap_host,
ldap_port=data.ldap_port,
use_ldaps=data.use_ldaps,
admin_dn=data.admin_dn,
admin_password=encrypt_password(data.admin_password),
search_filter=data.search_filter,
base_dn=data.base_dn,
user_mapping=json.dumps(data.user_mapping, ensure_ascii=False) if data.user_mapping else None,
)
db.add(ldap)
await db.commit()
await db.refresh(ldap)
return _serialize_ldap(ldap)
@router.post("/test")
async def test_ldap_connection(
data: LdapTestRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""测试 LDAP 连接 + 用户 Lookup 详细信息"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建")
try:
import ldap as _ldap # noqa
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
# Search user — always filter by uid to avoid matching wrong user
base_filter = ldap.search_filter or "(objectClass=inetOrgPerson)"
if "%s" in base_filter:
user_filter = base_filter.replace("%s", data.test_user)
else:
# search_filter has no placeholder, append uid condition
user_filter = f"(&{base_filter}(uid={data.test_user}))"
results = conn.search_s(
ldap.base_dn,
_ldap.SCOPE_SUBTREE,
user_filter,
["uid", "cn", "mail", "telephoneNumber", "title", "department", "memberOf", "distinguishedName"],
)
if not results:
return {"success": False, "message": f"未找到用户: {data.test_user}"}
user_dn, user_attrs = results[0]
if not user_dn:
return {"success": False, "message": f"未找到用户: {data.test_user}"}
# Verify password by binding as user
conn.simple_bind_s(user_dn, data.test_password)
# Build user_info
user_mapping = json.loads(ldap.user_mapping) if ldap.user_mapping else {}
username_attr = user_mapping.get("username", "uid")
email_attr = user_mapping.get("email", "mail")
display_attr = user_mapping.get("display_name", "cn")
def _attr(key: str) -> str:
vals = user_attrs.get(key, [])
return vals[0].decode("utf-8", errors="ignore") if vals else ""
uid = _attr(username_attr) or data.test_user
display_name = _attr(display_attr)
email = _attr(email_attr)
# Lookup groups via memberOf
groups = []
group_vals = user_attrs.get("memberOf", [])
for g in group_vals:
g_str = g.decode("utf-8", errors="ignore")
# Extract CN from DN like "cn=ops,ou=groups,dc=..."
cn = g_str.split(",")[0]
if cn.lower().startswith("cn="):
cn = cn[3:]
groups.append(cn)
conn.unbind_s()
return {
"success": True,
"message": f"用户 {uid} 验证通过",
"user_info": {
"User lookup": "successful",
"User ID": uid,
"User DN": user_dn,
"Display Name": display_name or uid,
"Email": email or "(未设置)",
"Groups": groups,
"Group lookup": f"successful ({len(groups)} groups)" if groups else "no groups found",
},
}
except ImportError:
return {
"success": False,
"message": "python-ldap 未安装,无法测试 LDAP 连接。请执行: pip install python-ldap",
}
except Exception as e:
return {"success": False, "message": f"LDAP 连接失败: {str(e)}"}
@router.post("/ou-list")
async def list_ldap_ous(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""获取 LDAP OU(组织单元)列表"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
results = conn.search_s(
ldap.base_dn,
_ldap.SCOPE_SUBTREE,
"(objectClass=organizationalUnit)",
["ou", "description", "distinguishedName"],
)
conn.unbind_s()
ous = []
for dn, attrs in results:
if dn:
ou_name = attrs.get("ou", [b""])[0].decode("utf-8", errors="ignore")
desc = attrs.get("description", [b""])[0].decode("utf-8", errors="ignore")
ous.append({"dn": dn, "name": ou_name or dn, "description": desc})
return {"ous": ous}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"LDAP 查询失败: {str(e)}")
@router.post("/user-list")
async def list_ldap_users(
data: dict = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""获取 LDAP 用户列表"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
search_base = (data or {}).get("ou_dn", "") or ldap.base_dn
results = conn.search_s(
search_base,
_ldap.SCOPE_SUBTREE,
"(objectClass=inetOrgPerson)",
["uid", "cn", "mail", "dn"],
)
conn.unbind_s()
users = []
for dn, attrs in results:
if dn:
users.append({
"dn": dn,
"uid": attrs.get("uid", [b""])[0].decode("utf-8", errors="ignore"),
"cn": attrs.get("cn", [b""])[0].decode("utf-8", errors="ignore"),
"mail": attrs.get("mail", [b""])[0].decode("utf-8", errors="ignore"),
})
return {"users": users, "total": len(users)}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"LDAP 查询用户失败: {str(e)}")
@router.post("/create-ou")
async def create_ldap_ou(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""在 LDAP 中创建组织单元 (OU)"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
ou_name = data.get("ou_name", "").strip()
parent_dn = data.get("parent_dn", "").strip()
description = data.get("description", "").strip()
if not ou_name:
raise HTTPException(status_code=400, detail="OU 名称不能为空")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
new_ou_dn = f"ou={ou_name},{parent_dn}" if parent_dn else f"ou={ou_name},{ldap.base_dn}"
# Check if OU already exists
try:
conn.search_s(new_ou_dn, _ldap.SCOPE_BASE)
conn.unbind_s()
raise HTTPException(status_code=409, detail=f"OU 已存在: {new_ou_dn}")
except _ldap.NO_SUCH_OBJECT:
pass
attrs = {
"objectClass": [b"organizationalUnit", b"top"],
"ou": ou_name.encode("utf-8"),
}
if description:
attrs["description"] = description.encode("utf-8")
conn.add_s(new_ou_dn, list(attrs.items()))
conn.unbind_s()
return {"success": True, "dn": new_ou_dn, "message": f"OU \"{ou_name}\" 创建成功"}
except HTTPException:
raise
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建 OU 失败: {str(e)}")
@router.post("/create-user")
async def create_ldap_user(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""在 LDAP 中创建单个用户"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
username = data.get("username", "").strip()
display_name = data.get("display_name", "").strip()
email = data.get("email", "").strip()
password = data.get("password", "").strip()
ou_dn = data.get("ou_dn", "").strip() or ldap.base_dn
if not username or not password:
raise HTTPException(status_code=400, detail="用户名和密码不能为空")
try:
import ldap as _ldap
import hashlib, base64
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
user_dn = f"uid={username},{ou_dn}"
try:
conn.search_s(user_dn, _ldap.SCOPE_BASE)
conn.unbind_s()
raise HTTPException(status_code=409, detail=f"用户已存在: {username}")
except _ldap.NO_SUCH_OBJECT:
pass
# SSHA password
salt = os.urandom(4)
digest = hashlib.sha1(password.encode() + salt).digest()
user_password = "{SSHA}" + base64.b64encode(digest + salt).decode()
attrs = {
"objectClass": [b"inetOrgPerson", b"organizationalPerson", b"person", b"top"],
"uid": username.encode("utf-8"),
"cn": (display_name or username).encode("utf-8"),
"sn": (display_name or username).encode("utf-8"),
"userPassword": user_password.encode("utf-8"),
}
if email:
attrs["mail"] = email.encode("utf-8")
conn.add_s(user_dn, list(attrs.items()))
conn.unbind_s()
return {"success": True, "dn": user_dn, "message": f"用户 \"{username}\" 创建成功"}
except HTTPException:
raise
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建用户失败: {str(e)}")
@router.post("/delete-user")
async def delete_ldap_user(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""从 LDAP 中删除用户"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
dn = data.get("dn", "").strip()
if not dn:
raise HTTPException(status_code=400, detail="用户 DN 不能为空")
try:
import ldap as _ldap
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
conn.delete_s(dn)
conn.unbind_s()
return {"success": True, "message": f"用户已删除: {dn}"}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除用户失败: {str(e)}")
@router.post("/batch-import-ldap")
async def batch_import_to_ldap(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""批量导入用户到 LDAP 服务器"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在")
users = data.get("users", [])
ou_dn = data.get("ou_dn", "").strip() or ldap.base_dn
if not users:
raise HTTPException(status_code=400, detail="用户列表不能为空")
try:
import ldap as _ldap
import hashlib, base64
protocol = "ldaps" if ldap.use_ldaps else "ldap"
uri = f"{protocol}://{ldap.ldap_host}:{ldap.ldap_port}"
conn = _ldap.initialize(uri)
conn.protocol_version = 3
if ldap.use_ldaps:
conn.set_option(_ldap.OPT_X_TLS_REQUIRE_CERT, _ldap.OPT_X_TLS_NEVER)
conn.set_option(_ldap.OPT_NETWORK_TIMEOUT, 10)
conn.simple_bind_s(ldap.admin_dn, decrypt_password(ldap.admin_password))
success_count = 0
failed_count = 0
failed_users = []
for u in users:
username = u.get("username", "").strip()
display_name = u.get("display_name", u.get("nickname", "")).strip()
email = u.get("email", "").strip()
password = u.get("password", username)
if not username:
failed_count += 1
failed_users.append({"username": "", "error": "用户名为空"})
continue
user_dn = f"uid={username},{ou_dn}"
try:
try:
conn.search_s(user_dn, _ldap.SCOPE_BASE)
failed_count += 1
failed_users.append({"username": username, "error": "用户已存在"})
continue
except _ldap.NO_SUCH_OBJECT:
pass
salt = os.urandom(4)
digest = hashlib.sha1(password.encode() + salt).digest()
user_password = "{SSHA}" + base64.b64encode(digest + salt).decode()
attrs = {
"objectClass": [b"inetOrgPerson", b"organizationalPerson", b"person", b"top"],
"uid": username.encode("utf-8"),
"cn": (display_name or username).encode("utf-8"),
"sn": (display_name or username).encode("utf-8"),
"userPassword": user_password.encode("utf-8"),
}
if email:
attrs["mail"] = email.encode("utf-8")
conn.add_s(user_dn, list(attrs.items()))
success_count += 1
except Exception as e:
failed_count += 1
failed_users.append({"username": username, "error": str(e)})
conn.unbind_s()
return {
"success": True,
"success_count": success_count,
"failed_count": failed_count,
"failed_users": failed_users,
"message": f"导入完成: 成功 {success_count}, 失败 {failed_count}",
}
except ImportError:
raise HTTPException(status_code=500, detail="python-ldap 未安装")
except Exception as e:
raise HTTPException(status_code=500, detail=f"批量导入失败: {str(e)}")
@@ -1,337 +0,0 @@
"""脱敏规则 API 路由 — 提供脱敏规则的 CRUD 和批量操作。"""
import json
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.models.user import User
from app.models.instance import Instance
from app.models.masking_rule import MaskingRule
from app.schemas.masking import (
MaskingRuleCreate,
MaskingRuleUpdate,
MaskingRuleResponse,
MaskingRuleBatchCreate,
MaskingRuleListResponse,
)
router = APIRouter(prefix="/instances", tags=["数据脱敏规则"])
def _serialize_rule(rule: MaskingRule) -> dict:
"""Serialize a MaskingRule model, parsing mask_config JSON string."""
config = json.loads(rule.mask_config) if rule.mask_config else None
return {
"id": rule.id,
"instance_id": rule.instance_id,
"database": rule.database,
"table_name": rule.table_name,
"column_name": rule.column_name,
"mask_type": rule.mask_type,
"mask_config": config,
"is_enabled": rule.is_enabled,
"created_at": rule.created_at,
"updated_at": rule.updated_at,
}
async def _verify_instance(
db: AsyncSession, instance_id: int, current_user: User
) -> Instance:
"""验证实例存在且用户有权限访问。
Args:
db: 数据库会话
instance_id: 实例ID
current_user: 当前用户
Returns:
实例对象
Raises:
HTTPException: 实例不存在或无权访问
"""
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
return instance
async def _check_duplicate_rule(
db: AsyncSession,
instance_id: int,
database: str,
table_name: str,
column_name: str,
exclude_id: Optional[int] = None,
) -> bool:
"""检查是否存在重复规则。
Args:
db: 数据库会话
instance_id: 实例ID
database: 数据库名
table_name: 表名
column_name: 列名
exclude_id: 排除的规则ID(用于更新时)
Returns:
如果存在重复规则返回 True
"""
stmt = select(MaskingRule).where(
MaskingRule.instance_id == instance_id,
MaskingRule.database == database,
MaskingRule.table_name == table_name,
MaskingRule.column_name == column_name,
)
if exclude_id:
stmt = stmt.where(MaskingRule.id != exclude_id)
result = await db.execute(stmt)
return result.scalar_one_or_none() is not None
@router.get("/{instance_id}/masking-rules", response_model=MaskingRuleListResponse)
async def list_masking_rules(
instance_id: int,
database: Optional[str] = Query(None, description="数据库筛选"),
table_name: Optional[str] = Query(None, description="表名筛选"),
keyword: Optional[str] = Query(None, description="关键词搜索"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""列出指定实例的脱敏规则。
Args:
instance_id: 数据源实例ID
database: 按数据库名筛选
table_name: 按表名筛选
keyword: 关键词搜索(匹配列名)
page: 页码
page_size: 每页数量
db: 数据库会话
current_user: 当前用户
Returns:
脱敏规则列表
"""
await _verify_instance(db, instance_id, current_user)
stmt = select(MaskingRule).where(MaskingRule.instance_id == instance_id)
if database:
stmt = stmt.where(MaskingRule.database == database)
if table_name:
stmt = stmt.where(MaskingRule.table_name == table_name)
if keyword:
stmt = stmt.where(
MaskingRule.column_name.contains(keyword)
)
# 总数量
total_stmt = select(func.count()).select_from(stmt.subquery())
total = (await db.execute(total_stmt)).scalar() or 0
# 分页
stmt = (
stmt.order_by(MaskingRule.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(stmt)
rules = result.scalars().all()
return {
"total": total,
"items": [_serialize_rule(r) for r in rules],
}
@router.post(
"/{instance_id}/masking-rules",
response_model=MaskingRuleResponse,
status_code=201,
)
async def create_masking_rule(
instance_id: int,
data: MaskingRuleCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""创建脱敏规则。"""
await _verify_instance(db, instance_id, current_user)
is_dup = await _check_duplicate_rule(
db, instance_id, data.database, data.table_name, data.column_name
)
if is_dup:
raise HTTPException(
status_code=409,
detail="该实例+数据库+表+列的脱敏规则已存在",
)
rule = MaskingRule(
instance_id=instance_id,
database=data.database,
table_name=data.table_name,
column_name=data.column_name,
mask_type=data.mask_type,
mask_config=json.dumps(data.mask_config) if data.mask_config else None,
is_enabled=data.is_enabled,
)
db.add(rule)
await db.commit()
await db.refresh(rule)
return _serialize_rule(rule)
@router.put("/masking-rules/{rule_id}", response_model=MaskingRuleResponse)
async def update_masking_rule(
rule_id: int,
data: MaskingRuleUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""更新脱敏规则。
Args:
rule_id: 规则ID
data: 脱敏规则更新数据
db: 数据库会话
current_user: 当前用户
Returns:
更新后的脱敏规则
Raises:
HTTPException: 规则不存在或重复
"""
result = await db.execute(select(MaskingRule).where(MaskingRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="脱敏规则不存在")
# 如果更新了唯一键字段,检查重复
db_val = data.database or rule.database
tb_val = data.table_name or rule.table_name
col_val = data.column_name or rule.column_name
is_dup = await _check_duplicate_rule(
db, rule.instance_id, db_val, tb_val, col_val, exclude_id=rule_id
)
if is_dup:
raise HTTPException(
status_code=409,
detail="更新后与现有规则冲突",
)
if data.database is not None:
rule.database = data.database
if data.table_name is not None:
rule.table_name = data.table_name
if data.column_name is not None:
rule.column_name = data.column_name
if data.mask_type is not None:
rule.mask_type = data.mask_type
if data.mask_config is not None:
rule.mask_config = json.dumps(data.mask_config)
if data.is_enabled is not None:
rule.is_enabled = data.is_enabled
await db.commit()
await db.refresh(rule)
return _serialize_rule(rule)
@router.delete("/masking-rules/{rule_id}", status_code=204)
async def delete_masking_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""删除脱敏规则。
Args:
rule_id: 规则ID
db: 数据库会话
current_user: 当前用户
Raises:
HTTPException: 规则不存在
"""
result = await db.execute(select(MaskingRule).where(MaskingRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="脱敏规则不存在")
await db.delete(rule)
await db.commit()
@router.post("/masking-rules/batch", response_model=MaskingRuleListResponse, status_code=201)
async def batch_create_masking_rules(
data: MaskingRuleBatchCreate,
instance_id: int = Query(..., description="数据源实例ID"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""批量创建脱敏规则。
Args:
data: 批量规则数据
instance_id: 数据源实例ID
db: 数据库会话
current_user: 当前用户
Returns:
成功创建的规则列表
Raises:
HTTPException: 实例不存在或无权限
"""
await _verify_instance(db, instance_id, current_user)
created_rules: list[MaskingRule] = []
errors: list[str] = []
for i, rule_data in enumerate(data.rules):
is_dup = await _check_duplicate_rule(
db, instance_id,
rule_data.database, rule_data.table_name, rule_data.column_name
)
if is_dup:
errors.append(
f"第 {i + 1} 条规则重复: {rule_data.database}.{rule_data.table_name}."
f"{rule_data.column_name}"
)
continue
rule = MaskingRule(
instance_id=instance_id,
database=rule_data.database,
table_name=rule_data.table_name,
column_name=rule_data.column_name,
mask_type=rule_data.mask_type,
mask_config=json.dumps(rule_data.mask_config) if rule_data.mask_config else None,
is_enabled=rule_data.is_enabled,
)
db.add(rule)
created_rules.append(rule)
if created_rules:
await db.commit()
for rule in created_rules:
await db.refresh(rule)
if errors and not created_rules:
raise HTTPException(status_code=409, detail="; ".join(errors))
return {
"total": len(created_rules),
"items": [_serialize_rule(r) for r in created_rules],
}
-256
View File
@@ -1,256 +0,0 @@
"""MongoDB 管理 API endpoints.
提供数据库/集合列表、集合统计、只读查询等功能。
所有端点要求进行身份认证,部分端点限制为 admin/dba 角色。
"""
import json
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.core.permissions import check_instance_access
from app.models.instance import Instance
from app.models.user import User
from app.services.mongo_connector import MongoConnector, validate_operation
router = APIRouter(tags=["MongoDB管理"])
# ── 辅助函数 ─────────────────────────────────────────────────────
async def _get_mongo_instance(instance_id: int, db: AsyncSession) -> Instance:
"""获取并验证 MongoDB 实例。
Args:
instance_id: 实例 ID。
db: 数据库会话。
Returns:
Instance 模型对象(db_type 为 "mongo" 或 "mongodb")。
Raises:
HTTPException: 实例不存在或不是 MongoDB 类型时。
"""
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
if instance.db_type not in ("mongo", "mongodb"):
raise HTTPException(status_code=400, detail="该实例不是 MongoDB 类型")
return instance
# ── 请求/响应模型 ────────────────────────────────────────────────
class MongoQueryRequest(BaseModel):
"""MongoDB 只读查询请求体。"""
database: Optional[str] = Field(None, description="目标数据库名称,为空时使用实例默认库")
collection: str = Field(..., description="目标集合名称")
operation: str = Field("find", description="操作类型: find/findOne/countDocuments/distinct/aggregate")
filter: Dict[str, Any] = Field(default_factory=dict, description="查询过滤器")
projection: Optional[Dict[str, Any]] = Field(None, description="字段投影")
sort: Optional[Dict[str, int]] = Field(None, description="排序规则,如 {\"age\": -1}")
limit: int = Field(1000, ge=1, le=10000, description="返回上限")
skip: int = Field(0, ge=0, description="跳过数量")
pipeline: Optional[List[Dict[str, Any]]] = Field(None, description="aggregate 管道")
field: Optional[str] = Field(None, description="distinct 字段名")
class MongoChangeRequest(BaseModel):
"""MongoDB 变更操作请求体(工单执行用)。"""
database: Optional[str] = Field(None, description="目标数据库名称")
collection: str = Field(..., description="目标集合名称")
operation: str = Field(..., description="操作类型: insertOne/insertMany/updateOne/updateMany/deleteOne/deleteMany")
document: Optional[Dict[str, Any]] = Field(None, description="insertOne 文档")
documents: Optional[List[Dict[str, Any]]] = Field(None, description="insertMany 文档列表")
update: Optional[Dict[str, Any]] = Field(None, description="update 操作文档")
replacement: Optional[Dict[str, Any]] = Field(None, description="replaceOne 替换文档")
filter: Dict[str, Any] = Field(default_factory=dict, description="查询过滤器")
# ── Endpoints ────────────────────────────────────────────────────
@router.get("/mongo/{instance_id}/databases")
async def list_mongo_databases(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""列出 MongoDB 实例中的所有数据库。"""
instance = await _get_mongo_instance(instance_id, db)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
try:
databases = await MongoConnector.list_databases(instance)
return {"databases": databases}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"获取数据库列表失败: {exc}")
@router.get("/mongo/{instance_id}/databases/{database}/collections")
async def list_mongo_collections(
instance_id: int,
database: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""列出指定数据库中的所有集合。"""
instance = await _get_mongo_instance(instance_id, db)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
try:
collections = await MongoConnector.list_collections(instance, database)
return {"database": database, "collections": collections}
except Exception as exc:
raise HTTPException(status_code=500, detail=f"获取集合列表失败: {exc}")
@router.get("/mongo/{instance_id}/collections/{collection}/stats")
async def get_mongo_collection_stats(
instance_id: int,
collection: str,
database: Optional[str] = Query(None, description="数据库名称"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取指定集合的统计信息。"""
instance = await _get_mongo_instance(instance_id, db)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
db_name = database or getattr(instance, "database", None)
if not db_name:
raise HTTPException(status_code=400, detail="未指定数据库名称")
try:
stats = await MongoConnector.get_collection_stats(instance, db_name, collection)
return stats
except Exception as exc:
raise HTTPException(status_code=500, detail=f"获取集合统计失败: {exc}")
@router.post("/mongo/{instance_id}/query")
async def execute_mongo_query(
instance_id: int,
request: MongoQueryRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""执行 MongoDB 只读查询。
支持的只读操作:
- find: 需 filter, projection, sort, limit, skip
- findOne: 需 filter, projection
- countDocuments: 需 filter
- estimatedDocumentCount: 无需额外参数
- distinct: 需 field, filter
- aggregate: 需 pipeline
请求示例:
```json
{
"database": "test",
"collection": "users",
"operation": "find",
"filter": {"age": {"$gt": 18}},
"projection": {"name": 1, "age": 1},
"sort": {"age": -1},
"limit": 100
}
```
"""
instance = await _get_mongo_instance(instance_id, db)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
db_name = request.database or getattr(instance, "database", None)
if not db_name:
raise HTTPException(status_code=400, detail="未指定数据库名称")
# 验证只读操作
is_readonly, error_msg = validate_operation(request.operation)
if not is_readonly:
raise HTTPException(status_code=403, detail=error_msg)
try:
query_json = request.model_dump(exclude_none=True, exclude={"database"})
# 移除未设置的 pipeline/field 避免错误分发
if request.operation != "aggregate":
query_json.pop("pipeline", None)
if request.operation != "distinct":
query_json.pop("field", None)
result = await MongoConnector.execute_query_json(
instance, db_name, query_json
)
return {
"success": True,
"operation": request.operation,
"collection": request.collection,
"result": result,
}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
raise HTTPException(status_code=500, detail=f"查询执行失败: {exc}")
@router.post("/mongo/{instance_id}/execute")
async def execute_mongo_change(
instance_id: int,
request: MongoChangeRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""执行 MongoDB 变更操作(仅 admin/dba)。
用于工单变更执行,支持 insert/update/delete 等写操作。
"""
instance = await _get_mongo_instance(instance_id, db)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
db_name = request.database or getattr(instance, "database", None)
if not db_name:
raise HTTPException(status_code=400, detail="未指定数据库名称")
try:
change_json = request.model_dump(exclude_none=True, exclude={"database"})
result = await MongoConnector.execute_change_json(
instance, db_name, change_json
)
return {
"success": True,
"operation": request.operation,
"collection": request.collection,
"result": result,
}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
raise HTTPException(status_code=500, detail=f"变更执行失败: {exc}")
@router.post("/mongo/{instance_id}/test-connection")
async def test_mongo_connection(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""测试 MongoDB 实例连接。"""
instance = await _get_mongo_instance(instance_id, db)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
try:
result = await MongoConnector.test_connection(instance)
return result.to_dict()
except Exception as exc:
raise HTTPException(status_code=500, detail=f"连接测试失败: {exc}")
@@ -1,153 +0,0 @@
import json
import copy
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.notification import NotificationChannel
from app.schemas.notification import (
NotificationChannelCreate,
NotificationChannelUpdate,
NotificationChannelResponse,
)
router = APIRouter(prefix="/notifications", tags=["消息推送配置"])
def _mask_config(config: dict) -> dict:
"""Mask sensitive fields in config for response."""
masked = copy.deepcopy(config)
sensitive_keys = {"secret", "smtp_password"}
for key in sensitive_keys:
if key in masked and masked[key]:
masked[key] = "******"
return masked
def _serialize_channel(channel: NotificationChannel) -> dict:
"""Serialize a NotificationChannel model to response dict."""
config = json.loads(channel.config) if channel.config else {}
return {
"id": channel.id,
"name": channel.name,
"channel_type": channel.channel_type,
"config": _mask_config(config),
"is_enabled": channel.is_enabled,
"created_at": channel.created_at,
"updated_at": channel.updated_at,
}
@router.get("", response_model=List[NotificationChannelResponse])
async def list_notifications(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""管理员获取通知渠道列表"""
result = await db.execute(select(NotificationChannel).order_by(NotificationChannel.id))
channels = result.scalars().all()
return [_serialize_channel(c) for c in channels]
@router.post("", response_model=NotificationChannelResponse, status_code=201)
async def create_notification(
data: NotificationChannelCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""管理员创建通知渠道"""
channel = NotificationChannel(
name=data.name,
channel_type=data.channel_type,
config=json.dumps(data.config, ensure_ascii=False),
is_enabled=data.is_enabled,
)
db.add(channel)
await db.commit()
await db.refresh(channel)
return _serialize_channel(channel)
@router.put("/{channel_id}", response_model=NotificationChannelResponse)
async def update_notification(
channel_id: int,
data: NotificationChannelUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""管理员更新通知渠道"""
result = await db.execute(
select(NotificationChannel).where(NotificationChannel.id == channel_id)
)
channel = result.scalar_one_or_none()
if not channel:
raise HTTPException(status_code=404, detail="通知渠道不存在")
update_data = data.model_dump(exclude_unset=True)
if "config" in update_data:
update_data["config"] = json.dumps(update_data["config"], ensure_ascii=False)
for key, value in update_data.items():
setattr(channel, key, value)
await db.commit()
await db.refresh(channel)
return _serialize_channel(channel)
@router.delete("/{channel_id}")
async def delete_notification(
channel_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""管理员删除通知渠道"""
result = await db.execute(
select(NotificationChannel).where(NotificationChannel.id == channel_id)
)
channel = result.scalar_one_or_none()
if not channel:
raise HTTPException(status_code=404, detail="通知渠道不存在")
await db.delete(channel)
await db.commit()
return {"message": "通知渠道已删除"}
@router.post("/{channel_id}/test")
async def test_notification(
channel_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""测试发送通知 — 实际发送测试消息到配置的渠道。"""
from app.services.notification_sender import NotificationSender
result = await db.execute(
select(NotificationChannel).where(NotificationChannel.id == channel_id)
)
channel = result.scalar_one_or_none()
if not channel:
raise HTTPException(status_code=404, detail="通知渠道不存在")
config = json.loads(channel.config) if channel.config else {}
sender = NotificationSender()
test_data = {
"title": "测试通知",
"applicant": current_user.display_name or current_user.username,
"db_type": "mysql",
"target_database": "test_db",
"sql_preview": "SELECT 1",
"priority": "normal",
"approver": "系统",
"ticket_id": "TEST",
"result": "成功",
"duration": "10",
"affected_rows": "0",
}
result = await sender.send(channel.channel_type, config, "ticket_created", test_data)
return result
-64
View File
@@ -1,64 +0,0 @@
"""连接池管理 API。"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.instance import Instance
from app.services.connection_pool import get_pool_manager
router = APIRouter(prefix="/pools", tags=["连接池管理"])
@router.get("/instances/{instance_id}/stats")
async def pool_stats(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""获取连接池状态。"""
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
manager = get_pool_manager()
stats = manager.get_pool_stats(instance_id)
if not stats:
return {
"instance_id": instance_id,
"status": "not_initialized",
"message": "连接池尚未初始化,将在首次查询时自动创建",
}
return stats.to_dict()
@router.post("/instances/{instance_id}/reset")
async def reset_pool(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""重置连接池。"""
manager = get_pool_manager()
await manager.reset_pool(instance_id)
return {"message": "连接池已重置", "instance_id": instance_id}
@router.get("/overview")
async def pools_overview(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""所有连接池概览。"""
manager = get_pool_manager()
all_stats = manager.get_all_stats()
return {
"total_pools": len(all_stats),
"pools": [stats.to_dict() for stats in all_stats.values()],
}
-251
View File
@@ -1,251 +0,0 @@
"""统一查询 API — 支持 MySQL、PostgreSQL、Redis、MongoDB 的只读查询。
核心设计:
- 一个入口查询所有数据库,减少学习成本
- 自动审计记录所有查询(谁在什么时间查了什么)
- 自动记录查询历史(方便复用)
- 数据脱敏保护敏感信息
- 完整错误处理和人性化提示
"""
import time
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.instance import Instance
from app.models.audit_log import AuditLog
from app.schemas.query import QueryRequest, UnifiedQueryResult
from app.services.query_service import QueryService
from app.services.data_masker import DataMasker, load_masking_rules
from app.services.audit_log_service import AuditLogService
from app.services.query_history_service import QueryHistoryService
from app.core.crypto import decrypt_password
from app.core.permissions import check_instance_access, check_instance_permission
router = APIRouter(prefix="/query", tags=["统一查询"])
@router.post("", response_model=UnifiedQueryResult)
async def execute_query(
req: QueryRequest,
mask: bool = QueryParam(True, description="是否启用数据脱敏"),
save_history: bool = QueryParam(True, description="是否保存到查询历史"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""执行统一查询(只读)— 支持 MySQL、PostgreSQL、Redis、MongoDB。
**一个入口,查询所有数据库。** 系统会根据数据源类型自动路由到对应的查询引擎,
并进行只读安全验证。
**自动审计**: 每次查询都会被记录在案,包含查询内容、耗时、返回行数等。
**查询历史**: 自动保存查询记录,方便后续复用。
**不同数据库的查询格式:**
- **MySQL/PostgreSQL**: 标准 SQL SELECT 语句,如 `SELECT * FROM users LIMIT 10`
- **Redis**: 命令字符串,如 `GET mykey`、`HGETALL myhash`、`LRANGE mylist 0 -1`
- **MongoDB**: JSON 格式查询,如:
```json
{"collection": "users", "operation": "find",
"filter": {"age": {"$gt": 18}}, "limit": 10}
```
Args:
req: 查询请求(instance_id + database + sql)
mask: 是否启用数据脱敏(默认 True)
save_history: 是否保存到查询历史(默认 True)
Returns:
UnifiedQueryResult: 统一格式的查询结果
"""
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()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在,请确认实例ID正确")
# 权限组检查:用户是否有权访问该数据源
await check_instance_access(current_user, instance, db)
# P1: 操作级权限检查
await check_instance_permission(current_user, instance, "query:select", db)
# 解密密码
instance.password = decrypt_password(instance.password)
# 记录开始时间
start_time = time.time()
is_success = True
error_msg = None
try:
query_result = await QueryService.execute_query(
instance, req.database, req.sql, current_user.id, req.options
)
except ValueError as e:
is_success = False
error_msg = str(e)
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
is_success = False
error_msg = f"{type(e).__name__}: {e}"
raise HTTPException(status_code=500, detail=f"查询执行失败: {e}")
finally:
# 无论成功失败都记录审计日志
elapsed_ms = int((time.time() - start_time) * 1000)
# 1. 记录审计日志(完整上下文)
try:
username = current_user.display_name or current_user.username
await AuditLogService.log(
db=db,
user_id=current_user.id,
action="QUERY",
target_type="query",
target_id=instance.id,
detail=f"查询{'成功' if is_success else '失败'}: {req.sql[:200]}",
)
# 直接写入增强字段(AuditLogService.log只写基础字段)
log_entry = AuditLog(
user_id=current_user.id,
username=username,
action="QUERY",
target_type="query",
target_id=instance.id,
instance_name=instance.name,
db_type=instance.db_type,
database_name=req.database or instance.database,
query_content=req.sql[:2000],
query_type=getattr(query_result, 'query_type', 'UNKNOWN') if is_success else None,
execution_time_ms=elapsed_ms,
rows_affected=getattr(query_result, 'total', 0) if is_success else 0,
result_status="success" if is_success else "failed",
detail=f"查询{'成功' if is_success else '失败'}: {req.sql[:200]}{f', 错误: {error_msg}' if error_msg else ''}",
)
db.add(log_entry)
await db.commit()
except Exception:
pass # 审计记录失败不影响查询结果
# 数据脱敏处理
if is_success and mask and query_result.rows and instance.db_type in ("mysql", "postgres", "postgresql"):
try:
if req.database:
masking_rules = await load_masking_rules(db, req.instance_id, req.database)
if masking_rules:
masker = DataMasker(masking_rules)
from app.services.sql_analyzer import SQLAnalyzer
analyzer = SQLAnalyzer()
analysis = analyzer.analyze(req.sql)
tables = analysis.tables if analysis.tables else ["*"]
table_name = tables[0] if tables else "*"
if masker.has_rules_for_table(table_name):
query_result.rows = masker.mask_rows(table_name, query_result.rows)
except Exception:
pass # 脱敏失败不影响查询结果
# 记录查询历史(人性化:方便用户复用)
if is_success and save_history:
try:
username = current_user.display_name or current_user.username
await QueryHistoryService.record_query(
db=db,
user_id=current_user.id,
username=username,
instance_id=instance.id,
instance_name=instance.name,
db_type=instance.db_type,
database_name=req.database or instance.database,
query_content=req.sql,
query_type=getattr(query_result, 'query_type', 'QUERY'),
execution_time_ms=elapsed_ms,
result_rows=getattr(query_result, 'total', 0),
is_success=is_success,
error_message=error_msg,
)
except Exception:
pass # 历史记录失败不影响查询结果
return query_result
@router.get("/supported-types")
async def supported_db_types(
current_user: User = Depends(get_current_user),
):
"""获取支持的数据库类型和查询格式说明。
人性化设计:帮助用户了解如何查询不同类型的数据库。
"""
return {
"types": [
{
"type": "mysql",
"name": "MySQL",
"query_format": "标准 SQL SELECT 语句",
"example": "SELECT id, name FROM users WHERE status = 'active' LIMIT 10",
"security": "AST语法分析验证只读,禁止DDL/DML",
"features": ["语法高亮", "EXPLAIN分析", "查询建议", "数据脱敏"],
},
{
"type": "postgres",
"name": "PostgreSQL",
"query_format": "标准 SQL SELECT 语句",
"example": "SELECT id, name FROM users WHERE status = 'active' LIMIT 10",
"security": "AST语法分析验证只读,禁止DDL/DML",
"features": ["语法高亮", "EXPLAIN分析", "查询建议", "数据脱敏"],
},
{
"type": "redis",
"name": "Redis",
"query_format": "Redis 命令字符串",
"example": "HGETALL user:1001",
"security": "130+只读命令白名单验证",
"features": ["命令自动补全", "结果格式化", "键值浏览"],
"readonly_commands": [
"GET", "MGET", "HGET", "HGETALL", "LRANGE", "SMEMBERS",
"ZRANGE", "SCAN", "KEYS", "INFO", "TTL", "TYPE", "EXISTS",
],
},
{
"type": "mongo",
"name": "MongoDB",
"query_format": "JSON 格式查询",
"example": '{"collection": "users", "operation": "find", "filter": {}, "limit": 10}',
"security": "6个只读操作白名单:find/findOne/countDocuments/distinct/aggregate",
"features": ["JSON格式化", "结果表格化", "集合浏览"],
"readonly_operations": ["find", "findOne", "countDocuments", "estimatedDocumentCount", "distinct", "aggregate"],
},
]
}
@@ -1,111 +0,0 @@
"""查询历史和快捷查询 API — 人性化查询管理。"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.services.query_history_service import QueryHistoryService
router = APIRouter(prefix="/query-history", tags=["查询历史"])
@router.get("/my")
async def my_query_history(
db_type: Optional[str] = None,
instance_id: Optional[int] = None,
keyword: Optional[str] = None,
page: int = 1,
page_size: int = 20,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取我的查询历史。
自动记录每次查询,支持按数据库类型、实例、关键词筛选。
"""
return await QueryHistoryService.get_history(
db=db,
user_id=current_user.id,
db_type=db_type,
instance_id=instance_id,
keyword=keyword,
page=page,
page_size=page_size,
)
@router.post("/{history_id}/favorite")
async def toggle_favorite(
history_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""切换查询历史收藏状态。"""
is_fav = await QueryHistoryService.toggle_favorite(db, current_user.id, history_id)
return {"is_favorite": is_fav, "message": "已收藏" if is_fav else "已取消收藏"}
@router.get("/favorites")
async def my_favorites(
page: int = 1,
page_size: int = 20,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取我收藏的查询。"""
return await QueryHistoryService.get_favorites(
db=db, user_id=current_user.id, page=page, page_size=page_size
)
@router.get("/statistics")
async def my_query_statistics(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取我的查询统计 — 了解自己的数据使用习惯。"""
return await QueryHistoryService.get_query_statistics(db=db, user_id=current_user.id)
# ── 快捷查询 ──
@router.get("/quick-queries")
async def list_quick_queries(
db_type: Optional[str] = None,
keyword: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取快捷查询列表(个人+团队共享)。"""
items = await QueryHistoryService.get_quick_queries(
db=db, user_id=current_user.id, db_type=db_type, keyword=keyword
)
return {"items": items, "total": len(items)}
@router.post("/quick-queries")
async def create_quick_query(
name: str,
query_content: str,
db_type: str,
description: str = "",
database_pattern: str = "*",
is_shared: bool = False,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""创建快捷查询。"""
qq = await QueryHistoryService.create_quick_query(
db=db,
name=name,
description=description,
query_content=query_content,
db_type=db_type,
database_pattern=database_pattern,
is_shared=is_shared,
created_by=current_user.id,
created_by_name=current_user.display_name or current_user.username,
)
return {"id": qq.id, "name": qq.name, "message": "快捷查询已创建"}
-275
View File
@@ -1,275 +0,0 @@
"""Redis management API endpoints.
集成 RedisCommandValidator 进行只读命令安全验证:
- developer 角色仅允许执行只读命令
- admin/dba 角色可执行所有命令
- FLUSHDB 等高危操作仅限 admin
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.models.user import User
from app.models.instance import Instance
from app.services import redis_connector
from app.services.redis_validator import RedisCommandValidator
router = APIRouter(tags=["Redis管理"])
async def _get_redis_instance(instance_id: int, db: AsyncSession) -> Instance:
"""Fetch and validate a Redis instance."""
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
if instance.db_type != "redis":
raise HTTPException(status_code=400, detail="该实例不是 Redis 类型")
return instance
# ── Request schemas ──────────────────────────────────────────────
class RedisKeyCreate(BaseModel):
db: int = 0
key: str
value: object
type: str = "string"
ttl: Optional[int] = None
class RedisCommandRequest(BaseModel):
db: int = 0
command: str
class RedisExpireRequest(BaseModel):
db: int = 0
seconds: int
# ── Endpoints ────────────────────────────────────────────────────
@router.get("/redis/{instance_id}/info")
async def get_redis_info(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Redis 信息概览."""
instance = await _get_redis_instance(instance_id, db)
try:
info = await redis_connector.RedisConnector.get_info(instance)
return info
except Exception as e:
raise HTTPException(status_code=500, detail="获取 Redis 信息失败: " + str(e))
@router.get("/redis/{instance_id}/dbsize")
async def get_redis_dbsize(
instance_id: int,
db_num: int = Query(0, alias="db"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""DBSIZE."""
instance = await _get_redis_instance(instance_id, db)
try:
size = await redis_connector.RedisConnector.get_dbsize(instance, db_num)
return {"db": db_num, "size": size}
except Exception as e:
raise HTTPException(status_code=500, detail="获取 DBSIZE 失败: " + str(e))
@router.get("/redis/{instance_id}/keys")
async def get_redis_keys(
instance_id: int,
db_num: int = Query(0, alias="db"),
pattern: str = Query("*"),
type: Optional[str] = Query(None, alias="type"),
cursor: int = Query(0),
count: int = Query(50),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""键列表."""
instance = await _get_redis_instance(instance_id, db)
try:
next_cursor, keys = await redis_connector.RedisConnector.scan_keys(
instance, db=db_num, pattern=pattern,
type_filter=type, cursor=cursor, count=count,
)
return {"cursor": next_cursor, "keys": keys}
except Exception as e:
raise HTTPException(status_code=500, detail="获取键列表失败: " + str(e))
@router.get("/redis/{instance_id}/keys/{key:path}")
async def get_redis_key_detail(
instance_id: int,
key: str,
db_num: int = Query(0, alias="db"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""键详情."""
instance = await _get_redis_instance(instance_id, db)
try:
detail = await redis_connector.RedisConnector.get_key_value(
instance, db_num, key
)
return detail
except Exception as e:
raise HTTPException(status_code=500, detail="获取键详情失败: " + str(e))
@router.post("/redis/{instance_id}/keys")
async def create_redis_key(
instance_id: int,
data: RedisKeyCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""创建/更新键."""
instance = await _get_redis_instance(instance_id, db)
try:
result = await redis_connector.RedisConnector.set_key_value(
instance, data.db, data.key, data.value, data.type, data.ttl
)
return {"message": "操作成功", "result": result}
except Exception as e:
raise HTTPException(status_code=500, detail="创建键失败: " + str(e))
@router.delete("/redis/{instance_id}/keys/{key:path}")
async def delete_redis_key(
instance_id: int,
key: str,
db_num: int = Query(0, alias="db"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""删除键."""
instance = await _get_redis_instance(instance_id, db)
try:
deleted = await redis_connector.RedisConnector.del_key(instance, db_num, key)
return {"message": "删除成功", "deleted": deleted}
except Exception as e:
raise HTTPException(status_code=500, detail="删除键失败: " + str(e))
@router.put("/redis/{instance_id}/keys/{key:path}/expire")
async def set_redis_key_expire(
instance_id: int,
key: str,
data: RedisExpireRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""设置 TTL."""
instance = await _get_redis_instance(instance_id, db)
try:
ok = await redis_connector.RedisConnector.set_ttl(
instance, data.db, key, data.seconds
)
return {"message": "设置成功" if ok else "设置失败"}
except Exception as e:
raise HTTPException(status_code=500, detail="设置 TTL 失败: " + str(e))
@router.delete("/redis/{instance_id}/flushdb")
async def flush_redis_db(
instance_id: int,
db_num: int = Query(0, alias="db"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""清空DB (admin only!)."""
instance = await _get_redis_instance(instance_id, db)
try:
result = await redis_connector.RedisConnector.flush_db(instance, db_num)
return {"message": "清空成功", "result": result}
except Exception as e:
raise HTTPException(status_code=500, detail="清空 DB 失败: " + str(e))
@router.post("/redis/{instance_id}/command")
async def execute_redis_command(
instance_id: int,
data: RedisCommandRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""执行 Redis 命令(带安全验证)。
权限控制:
- developer 角色只能执行只读命令(白名单内的 GET, HGETALL 等)
- admin/dba 角色可以执行所有命令(但 FLUSHDB/FLUSHALL 仍仅限 admin)
"""
instance = await _get_redis_instance(instance_id, db)
# 提取命令关键字用于权限判断
parts = data.command.strip().split()
if not parts:
raise HTTPException(status_code=400, detail="命令不能为空")
cmd = parts[0].upper()
# ── 权限校验 ────────────────────────────────────────────────
# FLUSHDB / FLUSHALL 仅 admin 可用
if cmd in ("FLUSHDB", "FLUSHALL"):
if current_user.role != "admin":
raise HTTPException(
status_code=403, detail=f"Redis 命令 '{cmd}' 仅限 admin 执行"
)
else:
# developer 只允许只读命令
if current_user.role == "developer":
is_safe, error_msg = RedisCommandValidator.validate(data.command)
if not is_safe:
raise HTTPException(status_code=403, detail=error_msg)
# admin/dba 允许所有命令(上面已拦截 FLUSHDB/FLUSHALL)
try:
output = await redis_connector.RedisConnector.execute_command(
instance, data.db, data.command
)
return {"output": output}
except Exception as e:
raise HTTPException(status_code=500, detail="命令执行失败: " + str(e))
@router.get("/redis/{instance_id}/slowlog")
async def get_redis_slowlog(
instance_id: int,
count: int = Query(10),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""慢日志."""
instance = await _get_redis_instance(instance_id, db)
try:
logs = await redis_connector.RedisConnector.get_slowlog(instance, count)
return logs
except Exception as e:
raise HTTPException(status_code=500, detail="获取慢日志失败: " + str(e))
@router.get("/redis/{instance_id}/clients")
async def get_redis_clients(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""客户端列表."""
instance = await _get_redis_instance(instance_id, db)
try:
clients = await redis_connector.RedisConnector.get_clients(instance)
return clients
except Exception as e:
raise HTTPException(status_code=500, detail="获取客户端列表失败: " + str(e))
-176
View File
@@ -1,176 +0,0 @@
"""回滚 SQL API 路由 — 提供回滚 SQL 生成和预览功能。"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.core.crypto import decrypt_password
from app.models.user import User
from app.models.instance import Instance
from app.models.ticket import Ticket
from app.schemas.rollback import (
RollbackPreviewRequest,
RollbackPreviewResponse,
RollbackGenerateResponse,
RollbackGenerateRequest,
)
from app.services.rollback_generator import RollbackGenerator, RollbackGeneratorError
router = APIRouter(tags=["回滚 SQL"])
async def _get_ticket(db: AsyncSession, ticket_id: int) -> Ticket:
"""获取工单并检查是否存在。
Args:
db: 数据库会话
ticket_id: 工单ID
Returns:
工单对象
Raises:
HTTPException: 工单不存在
"""
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
return ticket
async def _get_instance(db: AsyncSession, instance_id: int) -> Instance:
"""获取实例并检查是否存在。
Args:
db: 数据库会话
instance_id: 实例ID
Returns:
实例对象
Raises:
HTTPException: 实例不存在
"""
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
return instance
@router.post("/tickets/{ticket_id}/generate-rollback", response_model=RollbackGenerateResponse)
async def generate_rollback_sql(
ticket_id: int,
data: RollbackGenerateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""为工单生成回滚 SQL。
根据工单中的 SQL 内容,自动生成对应的回滚 SQL。
支持 INSERT/UPDATE/DELETE 三种 DML 操作。
Args:
ticket_id: 工单ID
data: 生成选项(是否自动保存)
db: 数据库会话
current_user: 当前用户
Returns:
生成的回滚 SQL 信息
Raises:
HTTPException: 工单不存在、无权访问或生成失败
"""
ticket = await _get_ticket(db, ticket_id)
# 获取关联的数据源实例
instance = await _get_instance(db, ticket.instance_id)
instance.password = decrypt_password(instance.password)
# 生成回滚 SQL
generator = RollbackGenerator()
try:
rollback_sql = await generator.generate(
instance, ticket.target_database, ticket.sql_content
)
except RollbackGeneratorError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"回滚 SQL 生成失败: {str(e)}")
# 自动保存到工单
saved = False
if data.auto_save:
ticket.rollback_sql = rollback_sql
await db.commit()
saved = True
# 检测 SQL 类型
from app.services.sql_analyzer import SQLAnalyzer
analyzer = SQLAnalyzer()
sql_type = analyzer._detect_sql_type(ticket.sql_content)
return RollbackGenerateResponse(
ticket_id=ticket_id,
original_sql=ticket.sql_content,
rollback_sql=rollback_sql,
sql_type=sql_type.value if sql_type else None,
saved=saved,
)
@router.post("/rollback/preview", response_model=RollbackPreviewResponse)
async def preview_rollback_sql(
req: RollbackPreviewRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""预览回滚 SQL(不保存)。
为给定的 SQL 语句生成回滚 SQL 预览,不执行实际数据库操作,
也不保存到任何工单。
Args:
req: 预览请求(实例ID、数据库、SQL)
db: 数据库会话
current_user: 当前用户
Returns:
回滚 SQL 预览
Raises:
HTTPException: 实例不存在或无权限
"""
instance = await _get_instance(db, req.instance_id)
instance.password = decrypt_password(instance.password)
generator = RollbackGenerator()
try:
rollback_sql = await generator.preview(instance, req.database, req.sql)
except Exception as e:
raise HTTPException(status_code=500, detail=f"回滚 SQL 预览失败: {str(e)}")
# 检测 SQL 类型
from app.services.sql_analyzer import SQLAnalyzer
analyzer = SQLAnalyzer()
sql_type = analyzer._detect_sql_type(req.sql)
warning = None
if rollback_sql.startswith("--"):
# 提取警告信息
lines = rollback_sql.split("\n")
warning_lines = [l for l in lines if l.startswith("--") and "无法" in l]
if warning_lines:
warning = warning_lines[0].replace("-- ", "")
return RollbackPreviewResponse(
original_sql=req.sql,
rollback_sql=rollback_sql,
sql_type=sql_type.value if sql_type else None,
warning=warning,
)
@@ -1,287 +0,0 @@
"""定时任务 API — 基于 SQL 的自动化任务管理。"""
import time
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.models.user import User
from app.models.instance import Instance
from app.models.scheduled_task import ScheduledTask, ScheduledTaskLog
from app.schemas.scheduled_task import (
ScheduledTaskCreate, ScheduledTaskUpdate,
ScheduledTaskResponse, ScheduledTaskLogResponse, TaskRunResponse,
)
from app.services.db_connector import DBConnector
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/scheduled-tasks", tags=["自动化任务"])
def _serialize(task: ScheduledTask, instance_name: str = None, instance_db_type: str = None) -> dict:
return {
"id": task.id,
"name": task.name,
"description": task.description,
"task_sql_type": task.task_sql_type,
"instance_id": task.instance_id,
"instance_name": instance_name,
"instance_db_type": instance_db_type,
"target_db": task.target_db,
"target_table": task.target_table,
"sql_content": task.sql_content,
"cron_expression": task.cron_expression,
"is_enabled": task.is_enabled,
"last_run_at": task.last_run_at,
"last_run_status": task.last_run_status,
"last_run_message": task.last_run_message,
"next_run_at": task.next_run_at,
"run_count": task.run_count or 0,
"created_by": task.created_by,
"created_at": task.created_at,
}
async def _get_instance(db: AsyncSession, instance_id: int) -> Instance:
result = await db.execute(select(Instance).where(Instance.id == instance_id))
inst = result.scalar_one_or_none()
if not inst:
raise HTTPException(404, "数据源不存在")
return inst
# ── CRUD ──────────────────────────────────────────────────────
@router.get("", response_model=list[ScheduledTaskResponse])
async def list_tasks(
task_sql_type: Optional[str] = None,
is_enabled: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""列出所有定时任务。"""
q = select(ScheduledTask).order_by(ScheduledTask.created_at.desc())
if task_sql_type:
q = q.where(ScheduledTask.task_sql_type == task_sql_type)
if is_enabled is not None:
q = q.where(ScheduledTask.is_enabled == is_enabled)
result = await db.execute(q)
tasks = result.scalars().all()
# batch load instance info
inst_ids = {t.instance_id for t in tasks}
inst_map = {}
if inst_ids:
ir = await db.execute(select(Instance).where(Instance.id.in_(inst_ids)))
for inst in ir.scalars().all():
inst_map[inst.id] = inst
return [_serialize(t, inst_map.get(t.instance_id).name if inst_map.get(t.instance_id) else None,
inst_map.get(t.instance_id).db_type if inst_map.get(t.instance_id) else None)
for t in tasks]
@router.post("", response_model=ScheduledTaskResponse, status_code=201)
async def create_task(
body: ScheduledTaskCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("admin")),
):
"""创建定时任务。"""
# validate instance exists
inst = await _get_instance(db, body.instance_id)
task = ScheduledTask(
name=body.name,
description=body.description,
task_sql_type=body.task_sql_type.value,
instance_id=body.instance_id,
target_db=body.target_db,
target_table=body.target_table,
sql_content=body.sql_content,
cron_expression=body.cron_expression,
is_enabled=body.is_enabled,
created_by=user.id,
)
db.add(task)
await db.flush()
await db.refresh(task)
return _serialize(task, inst.name, inst.db_type)
@router.get("/{task_id}", response_model=ScheduledTaskResponse)
async def get_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""获取任务详情。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
inst = await _get_instance(db, task.instance_id)
return _serialize(task, inst.name, inst.db_type)
@router.put("/{task_id}", response_model=ScheduledTaskResponse)
async def update_task(
task_id: int,
body: ScheduledTaskUpdate,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""更新任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
update_data = body.model_dump(exclude_unset=True)
if "task_sql_type" in update_data and update_data["task_sql_type"] is not None:
update_data["task_sql_type"] = update_data["task_sql_type"].value
for k, v in update_data.items():
setattr(task, k, v)
await db.flush()
await db.refresh(task)
inst = await _get_instance(db, task.instance_id)
return _serialize(task, inst.name, inst.db_type)
@router.delete("/{task_id}")
async def delete_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""删除任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
await db.delete(task)
return {"detail": "已删除"}
@router.patch("/{task_id}/toggle")
async def toggle_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""启用/禁用任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
task.is_enabled = not task.is_enabled
await db.flush()
return {"is_enabled": task.is_enabled}
# ── 执行 ──────────────────────────────────────────────────────
@router.post("/{task_id}/run", response_model=TaskRunResponse)
async def run_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""手动执行一次任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
inst = await _get_instance(db, task.instance_id)
target_db = task.target_db or inst.database or ""
log = ScheduledTaskLog(
task_id=task.id,
status="running",
sql_content=task.sql_content,
)
db.add(log)
await db.flush()
start = time.time()
try:
msg = await DBConnector.execute_dml(inst, target_db, task.sql_content)
elapsed = int((time.time() - start) * 1000)
# parse affected rows from message like "执行成功,影响 3 行"
affected = 0
import re
m = re.search(r'(\d+)', msg)
if m:
affected = int(m.group(1))
log.status = "success"
log.affected_rows = affected
log.duration_ms = elapsed
log.finished_at = __import__("datetime").datetime.now()
task.last_run_at = __import__("datetime").datetime.now()
task.last_run_status = "success"
task.last_run_message = msg
task.run_count = (task.run_count or 0) + 1
await db.flush()
return TaskRunResponse(success=True, message=msg, affected_rows=affected, duration_ms=elapsed)
except Exception as e:
elapsed = int((time.time() - start) * 1000)
error_msg = str(e)
log.status = "failed"
log.error_message = error_msg
log.duration_ms = elapsed
log.finished_at = __import__("datetime").datetime.now()
task.last_run_at = __import__("datetime").datetime.now()
task.last_run_status = "failed"
task.last_run_message = error_msg
task.run_count = (task.run_count or 0) + 1
await db.flush()
return TaskRunResponse(success=False, message=error_msg, duration_ms=elapsed)
# ── 执行日志 ──────────────────────────────────────────────────
@router.get("/{task_id}/logs", response_model=list[ScheduledTaskLogResponse])
async def get_task_logs(
task_id: int,
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""获取任务执行日志。"""
q = (
select(ScheduledTaskLog)
.where(ScheduledTaskLog.task_id == task_id)
.order_by(ScheduledTaskLog.started_at.desc())
.limit(limit)
)
result = await db.execute(q)
logs = result.scalars().all()
return [
{
"id": l.id,
"task_id": l.task_id,
"status": l.status,
"sql_content": l.sql_content,
"affected_rows": l.affected_rows or 0,
"duration_ms": l.duration_ms,
"error_message": l.error_message,
"started_at": l.started_at,
"finished_at": l.finished_at,
}
for l in logs
]
@@ -1,85 +0,0 @@
"""Schema 漂移检测 API。"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.core.crypto import decrypt_password
from app.models.user import User
from app.models.instance import Instance
from app.services.schema_drift_detector import SchemaDriftDetector
router = APIRouter(prefix="/schema-drift", tags=["Schema 漂移检测"])
def _serialize_drift_report(report):
return report.to_dict() if hasattr(report, "to_dict") else report
@router.post("/instances/{instance_id}/capture")
async def capture_schema(
instance_id: int,
database: str = "",
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""捕获 Schema 快照并保存为基线。"""
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
if not database:
database = instance.database or ""
detector = SchemaDriftDetector()
instance.password = decrypt_password(instance.password)
snapshot = await detector.capture_snapshot(instance, database)
await detector.save_baseline(instance_id, database, snapshot)
return {
"message": "Schema 快照已捕获",
"instance_id": instance_id,
"database": database,
"total_columns": len(snapshot),
}
@router.post("/instances/{instance_id}/check")
async def check_drift(
instance_id: int,
database: str = "",
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""检测 Schema 漂移。"""
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
if not database:
database = instance.database or ""
detector = SchemaDriftDetector()
instance.password = decrypt_password(instance.password)
report = await detector.detect_drift(instance, database)
return _serialize_drift_report(report)
@router.get("/instances/{instance_id}/history")
async def schema_history(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""获取 Schema 变更历史。"""
# 简化实现,实际应从数据库查询
return {
"instance_id": instance_id,
"history": [],
"message": "Schema 变更历史功能开发中,当前使用内存存储",
}
-244
View File
@@ -1,244 +0,0 @@
"""系统设置 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()
# 刷新 query_service 的配置缓存,使新设置立即生效
try:
from app.services.query_service import refresh_config
await refresh_config()
except Exception:
pass
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}
@router.post("/cleanup/run")
async def run_manual_cleanup(
current_user: User = Depends(require_role("admin")),
):
"""管理员手动触发日志清理。"""
from app.services.log_cleanup import run_cleanup
stats = await run_cleanup()
return {"message": "清理完成", "stats": stats}
@router.get("/cleanup/preview")
async def preview_cleanup(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""预览将被清理的记录数量(不实际删除)。"""
from datetime import datetime, timedelta, timezone as tz
from sqlalchemy import func as sa_func
await _ensure_settings(db)
async def _get_int(key: str) -> int:
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
s = result.scalar_one_or_none()
return int(s.value) if s else 0
preview = {}
# 工单
ticket_days = await _get_int('cleanup_ticket_days')
if ticket_days > 0:
cutoff = datetime.now(tz.utc) - timedelta(days=ticket_days)
from app.models.ticket import Ticket
result = await db.execute(
select(sa_func.count(Ticket.id)).where(
Ticket.status.in_(['success', 'failed', 'rejected']),
Ticket.create_time < cutoff,
)
)
preview['tickets'] = result.scalar()
# 审计日志
audit_days = await _get_int('cleanup_audit_log_days')
if audit_days > 0:
cutoff = datetime.now(tz.utc) - timedelta(days=audit_days)
from app.models.audit_log import AuditLog
result = await db.execute(
select(sa_func.count(AuditLog.id)).where(AuditLog.created_at < cutoff)
)
preview['audit_logs'] = result.scalar()
# 查询历史
query_days = await _get_int('cleanup_query_history_days')
if query_days > 0:
cutoff = datetime.now(tz.utc) - timedelta(days=query_days)
from app.models.query_history import QueryHistory
result = await db.execute(
select(sa_func.count(QueryHistory.id)).where(QueryHistory.created_at < cutoff)
)
preview['query_histories'] = result.scalar()
# 定时任务日志
task_log_days = await _get_int('cleanup_scheduled_task_log_days')
if task_log_days > 0:
cutoff = datetime.now(tz.utc) - timedelta(days=task_log_days)
from app.models.scheduled_task import ScheduledTaskLog
result = await db.execute(
select(sa_func.count(ScheduledTaskLog.id)).where(ScheduledTaskLog.started_at < cutoff)
)
preview['scheduled_task_logs'] = result.scalar()
return preview
@@ -1,182 +0,0 @@
"""SQL助手 API — 人性化辅助功能。"""
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.crypto import decrypt_password
from app.core.permissions import check_instance_access, check_instance_permission
from app.core.security import get_current_user
from app.models.user import User
from app.models.instance import Instance
from app.services.sql_assistant import SQLAssistant
from app.services.explain_service import ExplainService
router = APIRouter(prefix="/sql-assistant", tags=["SQL助手"])
class SQLAnalyzeRequest(BaseModel):
sql: str
db_type: str = "mysql"
class SQLFormatRequest(BaseModel):
sql: str
@router.post("/analyze")
async def analyze_sql(
req: SQLAnalyzeRequest,
current_user: User = Depends(get_current_user),
):
"""全面分析SQL — 格式化+高亮+风险+建议。
一次调用获取所有辅助信息,帮助用户写出更好的SQL。
"""
if not req.sql or not req.sql.strip():
raise HTTPException(status_code=400, detail="SQL内容不能为空")
result = SQLAssistant.analyze(req.sql, req.db_type)
return result
@router.post("/format")
async def format_sql(
req: SQLFormatRequest,
current_user: User = Depends(get_current_user),
):
"""格式化SQL — 统一代码风格。
- 关键字大写
- 合理换行缩进
"""
if not req.sql or not req.sql.strip():
raise HTTPException(status_code=400, detail="SQL内容不能为空")
result = SQLAssistant.format_sql(req.sql)
return {
"formatted": result.formatted,
"is_valid": result.is_valid,
"error": result.error,
}
@router.post("/highlight")
async def highlight_sql(
req: SQLAnalyzeRequest,
current_user: User = Depends(get_current_user),
):
"""SQL语法高亮+风险检测。
返回高亮文本和风险提示列表。
"""
if not req.sql or not req.sql.strip():
raise HTTPException(status_code=400, detail="SQL内容不能为空")
result = SQLAssistant.highlight_sql(req.sql)
return {
"highlighted": result.highlighted,
"keywords": result.keywords,
"tables": result.tables,
"risks": result.risks,
}
@router.post("/suggestions")
async def query_suggestions(
req: SQLAnalyzeRequest,
current_user: User = Depends(get_current_user),
):
"""获取查询建议 — 优化提示。
基于SQL内容提供人性化的改进建议。
"""
if not req.sql or not req.sql.strip():
raise HTTPException(status_code=400, detail="SQL内容不能为空")
suggestions = SQLAssistant.get_query_suggestions(req.sql, req.db_type)
return {"suggestions": suggestions, "count": len(suggestions)}
@router.post("/parse-explain")
async def parse_explain(
explain_rows: list,
current_user: User = Depends(get_current_user),
):
"""解析MySQL EXPLAIN结果为人类可读格式。"""
if not explain_rows:
raise HTTPException(status_code=400, detail="EXPLAIN结果不能为空")
result = SQLAssistant.parse_explain(explain_rows)
return {
"is_simple": result.is_simple,
"has_index": result.has_index,
"has_full_scan": result.has_full_scan,
"estimated_rows": result.estimated_rows,
"warnings": result.warnings,
"human_readable": result.human_readable,
}
# ===== EXPLAIN 真实执行 API =====
class ExplainRequest(BaseModel):
"""EXPLAIN 请求体。"""
instance_id: int
database: str
query: str
@router.post("/explain")
async def execute_explain(
req: ExplainRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""连接数据源执行 EXPLAIN 并解析为统一树结构。
根据数据源类型自动构造对应的 EXPLAIN 语句:
- MySQL: EXPLAIN FORMAT=JSON <query>
- PostgreSQL: EXPLAIN (FORMAT JSON, ANALYZE false) <query>
- SQLite: EXPLAIN QUERY PLAN <query>
返回统一的树形执行计划、优化警告和汇总统计。
"""
if current_user.role == "guest":
raise HTTPException(status_code=403, detail="访客无权执行 EXPLAIN,请联系管理员申请权限")
if not req.query or not req.query.strip():
raise HTTPException(status_code=400, detail="查询语句不能为空")
# 获取实例
result = await db.execute(select(Instance).where(Instance.id == req.instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在,请确认实例ID正确")
# 权限检查
await check_instance_access(current_user, instance, db)
await check_instance_permission(current_user, instance, "query:select", db)
# 解密密码
instance.password = decrypt_password(instance.password)
start_time = time.time()
try:
explain_result = await ExplainService.execute_explain(
instance, req.database, req.query
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"EXPLAIN 执行失败: {e}")
elapsed_ms = int((time.time() - start_time) * 1000)
explain_result["elapsed_ms"] = elapsed_ms
return explain_result
File diff suppressed because it is too large. Load diff
-292
View File
@@ -1,292 +0,0 @@
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from io import BytesIO
from app.core.database import get_db
from app.core.security import get_current_user, hash_password, require_role
from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate, UserResponse, PasswordReset
from app.services.audit_log_service import AuditLogService
router = APIRouter(prefix="/users", tags=["用户管理"])
@router.get("")
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: Optional[str] = Query(None),
role: Optional[str] = Query(None),
is_active: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""搜索分页用户列表"""
query = select(User)
count_q = select(func.count(User.id))
if search:
like = f"%{search}%"
cond = or_(User.username.contains(search), User.display_name.contains(like), User.email.contains(like))
query = query.where(cond)
count_q = count_q.where(cond)
if role:
query = query.where(User.role == role)
count_q = count_q.where(User.role == role)
if is_active is not None:
query = query.where(User.is_active == is_active)
count_q = count_q.where(User.is_active == is_active)
total = (await db.execute(count_q)).scalar() or 0
query = query.order_by(User.id).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
users = result.scalars().all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": u.id,
"username": u.username,
"display_name": u.display_name,
"email": u.email,
"phone": getattr(u, 'phone', None),
"role": u.role,
"is_active": u.is_active,
"last_login": u.last_login.isoformat() if u.last_login else None,
"created_at": u.created_at.isoformat() if u.created_at else None,
}
for u in users
],
}
@router.post("", response_model=UserResponse, status_code=201)
async def create_user(
data: UserCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""创建用户"""
exists = await db.execute(select(User).where(User.username == data.username))
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail="用户名已存在")
user = User(
username=data.username,
display_name=data.display_name,
hashed_password=hash_password(data.password),
email=data.email,
phone=getattr(data, 'phone', None),
role=data.role,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
# ── 固定路径路由 (必须在 /{user_id} 之前) ──
@router.post("/batch-import")
async def batch_import_users(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""批量导入用户 — 本地模式(Excel) 或 LDAP模式"""
users_data = data.get("users", [])
import_type = data.get("import_type", "local")
skip_existing = data.get("skip_existing", True)
if not users_data:
raise HTTPException(status_code=400, detail="没有导入数据")
success_count = 0
skipped_count = 0
failed_users = []
for u in users_data:
username = u.get("username", "")
display_name = u.get("display_name", "")
if not username or not display_name:
failed_users.append({"username": username, "error": "缺少用户名或姓名"})
continue
exists = await db.execute(select(User).where(User.username == username))
if exists.scalar_one_or_none():
if skip_existing:
skipped_count += 1
continue
failed_users.append({"username": username, "error": "用户名已存在"})
continue
user = User(
username=username,
display_name=display_name,
hashed_password=hash_password(u.get("password", username)),
email=u.get("email"),
phone=u.get("phone"),
role=u.get("role", "developer"),
)
db.add(user)
success_count += 1
await db.commit()
return {
"success_count": success_count,
"skipped_count": skipped_count,
"failed_count": len(failed_users),
"failed_users": failed_users,
}
@router.post("/batch-import-ldap")
async def batch_import_from_ldap(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""从 LDAP 批量导入用户"""
users_data = data.get("users", [])
if not users_data:
raise HTTPException(status_code=400, detail="没有导入数据")
success_count = 0
skipped_count = 0
failed_users = []
for u in users_data:
username = u.get("username", u.get("sAMAccountName", ""))
display_name = u.get("display_name", u.get("displayName", ""))
email = u.get("email", u.get("mail", ""))
if not username:
failed_users.append({"username": "", "error": "缺少用户名"})
continue
exists = await db.execute(select(User).where(User.username == username))
if exists.scalar_one_or_none():
skipped_count += 1
continue
user = User(
username=username,
display_name=display_name or username,
hashed_password=hash_password(username),
email=email or None,
role="developer",
)
db.add(user)
success_count += 1
await db.commit()
return {
"success_count": success_count,
"skipped_count": skipped_count,
"failed_count": len(failed_users),
"failed_users": failed_users,
}
@router.get("/import-template")
async def download_import_template(
current_user: User = Depends(require_role("admin")),
):
"""下载用户导入模板 — CSV 格式"""
import csv, io
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["姓名", "邮箱", "手机", "角色"])
writer.writerow(["张三", "zhangsan@example.com", "13800138000", "developer"])
writer.writerow(["李四", "lisi@example.com", "", "dba"])
return StreamingResponse(
iter([output.getvalue().encode("utf-8-sig")]),
media_type="text/csv;charset=utf-8",
headers={"Content-Disposition": "attachment; filename=users_import_template.csv"},
)
# ── 动态路径路由 ──
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""获取单个用户"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return user
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
data: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""更新用户信息"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
update_data = data.model_dump(exclude_unset=True)
if "password" in update_data:
user.hashed_password = hash_password(update_data.pop("password"))
for key, value in update_data.items():
setattr(user, key, value)
await db.commit()
await db.refresh(user)
return user
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""删除用户"""
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="不能删除自己")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
await db.delete(user)
await db.commit()
return {"message": "用户已删除"}
@router.post("/{user_id}/reset-password")
async def reset_password(
user_id: int,
data: PasswordReset,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""管理员重置用户密码"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.hashed_password = hash_password(data.new_password)
await db.commit()
return {"message": "密码已重置"}
-273
View File
@@ -1,273 +0,0 @@
"""审批流程管理 API。"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.workflow import WorkflowDefinition, WorkflowStep
from app.schemas.workflow import (
WorkflowCreate, WorkflowUpdate, WorkflowResponse,
WorkflowStepCreate,
)
router = APIRouter(prefix="/workflows", tags=["审批流程"])
def _serialize_step(step: WorkflowStep) -> dict:
return {
"id": step.id,
"workflow_id": step.workflow_id,
"step_number": step.step_number,
"name": step.name,
"step_type": step.step_type,
"approver_role": step.approver_role,
"approver_ids": step.approver_ids,
"is_required": step.is_required,
"timeout_hours": step.timeout_hours,
"auto_approve_on_timeout": step.auto_approve_on_timeout,
}
def _serialize_workflow(wf: WorkflowDefinition) -> dict:
return {
"id": wf.id,
"name": wf.name,
"description": wf.description,
"is_default": wf.is_default,
"is_active": wf.is_active,
"created_at": wf.created_at,
"steps": [_serialize_step(s) for s in wf.steps],
}
@router.get("", response_model=List[WorkflowResponse])
async def list_workflows(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
result = await db.execute(
select(WorkflowDefinition)
.where(WorkflowDefinition.is_active == True)
.options(selectinload(WorkflowDefinition.steps))
)
workflows = result.scalars().unique().all()
return [_serialize_workflow(wf) for wf in workflows]
@router.post("", response_model=WorkflowResponse, status_code=201)
async def create_workflow(
data: WorkflowCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
# 如果设为默认,取消其他默认
if data.is_default:
from sqlalchemy import update as sa_update
await db.execute(
sa_update(WorkflowDefinition)
.where(WorkflowDefinition.is_default == True)
.values(is_default=False)
)
workflow = WorkflowDefinition(
name=data.name,
description=data.description,
is_default=data.is_default,
)
db.add(workflow)
await db.flush()
for step_data in data.steps:
step = WorkflowStep(
workflow_id=workflow.id,
step_number=step_data.step_number,
name=step_data.name,
step_type=step_data.step_type,
approver_role=step_data.approver_role,
approver_ids=step_data.approver_ids,
is_required=step_data.is_required,
timeout_hours=step_data.timeout_hours,
auto_approve_on_timeout=step_data.auto_approve_on_timeout,
)
db.add(step)
await db.flush()
wf_id = workflow.id
# Reload with steps — use fresh query by id to avoid identity map issues
db.expire(workflow)
result = await db.execute(
select(WorkflowDefinition)
.where(WorkflowDefinition.id == wf_id)
.options(selectinload(WorkflowDefinition.steps))
)
workflow = result.scalar_one()
return _serialize_workflow(workflow)
@router.get("/{workflow_id}", response_model=WorkflowResponse)
async def get_workflow(
workflow_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
result = await db.execute(
select(WorkflowDefinition)
.where(WorkflowDefinition.id == workflow_id)
.options(selectinload(WorkflowDefinition.steps))
)
workflow = result.scalar_one_or_none()
if not workflow:
raise HTTPException(status_code=404, detail="审批流程不存在")
return _serialize_workflow(workflow)
@router.put("/{workflow_id}", response_model=WorkflowResponse)
async def update_workflow(
workflow_id: int,
data: WorkflowUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(WorkflowDefinition)
.where(WorkflowDefinition.id == workflow_id)
.options(selectinload(WorkflowDefinition.steps))
)
workflow = result.scalar_one_or_none()
if not workflow:
raise HTTPException(status_code=404, detail="审批流程不存在")
if data.name is not None:
workflow.name = data.name
if data.description is not None:
workflow.description = data.description
if data.is_active is not None:
workflow.is_active = data.is_active
# 更新步骤(先删后建)
if data.steps is not None:
from sqlalchemy import delete as sa_delete
await db.execute(
sa_delete(WorkflowStep).where(WorkflowStep.workflow_id == workflow_id)
)
await db.flush()
for step_data in data.steps:
step = WorkflowStep(
workflow_id=workflow.id,
step_number=step_data.step_number,
name=step_data.name,
step_type=step_data.step_type,
approver_role=step_data.approver_role,
approver_ids=step_data.approver_ids,
is_required=step_data.is_required,
timeout_hours=step_data.timeout_hours,
auto_approve_on_timeout=step_data.auto_approve_on_timeout,
)
db.add(step)
await db.flush()
# Reload with steps
db.expire(workflow)
result = await db.execute(
select(WorkflowDefinition)
.where(WorkflowDefinition.id == workflow_id)
.options(selectinload(WorkflowDefinition.steps))
)
workflow = result.scalar_one()
return _serialize_workflow(workflow)
@router.delete("/{workflow_id}")
async def delete_workflow(
workflow_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(
select(WorkflowDefinition).where(WorkflowDefinition.id == workflow_id)
)
workflow = result.scalar_one_or_none()
if not workflow:
raise HTTPException(status_code=404, detail="审批流程不存在")
workflow.is_active = False
await db.commit()
return {"message": "审批流程已删除"}
@router.post("/{workflow_id}/set-default")
async def set_default_workflow(
workflow_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""设为默认审批流程。"""
# 取消所有默认
result = await db.execute(select(WorkflowDefinition))
all_workflows = result.scalars().all()
for wf in all_workflows:
wf.is_default = False
# 设置指定流程为默认
result = await db.execute(
select(WorkflowDefinition).where(WorkflowDefinition.id == workflow_id)
)
workflow = result.scalar_one_or_none()
if not workflow:
raise HTTPException(status_code=404, detail="审批流程不存在")
workflow.is_default = True
await db.commit()
return {"message": "审批流程 '{}' 已设为默认".format(workflow.name)}
@router.post("/{workflow_id}/clone", response_model=WorkflowResponse)
async def clone_workflow(
workflow_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""克隆审批流程。"""
result = await db.execute(
select(WorkflowDefinition)
.where(WorkflowDefinition.id == workflow_id)
.options(selectinload(WorkflowDefinition.steps))
)
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="审批流程不存在")
new_workflow = WorkflowDefinition(
name="{} (副本)".format(source.name),
description=source.description,
is_default=False,
)
db.add(new_workflow)
await db.flush()
for step in source.steps:
new_step = WorkflowStep(
workflow_id=new_workflow.id,
step_number=step.step_number,
name=step.name,
step_type=step.step_type,
approver_role=step.approver_role,
approver_ids=step.approver_ids,
is_required=step.is_required,
timeout_hours=step.timeout_hours,
auto_approve_on_timeout=step.auto_approve_on_timeout,
)
db.add(new_step)
await db.commit()
# Reload with steps
result = await db.execute(
select(WorkflowDefinition)
.where(WorkflowDefinition.id == new_workflow.id)
.options(selectinload(WorkflowDefinition.steps))
)
new_workflow = result.scalar_one()
return _serialize_workflow(new_workflow)
-44
View File
@@ -1,44 +0,0 @@
from fastapi import APIRouter
from app.api.v1.endpoints import (
auth, user, instance, ticket, query, dashboard, audit, audit_rule,
announcement, notification, ldap_config,
instance_detail, backup, redis, dictionary,
instance_group, masking_rule, rollback,
workflow, impact, schema_drift, pool,
mongo, query_history, sql_assistant, db_types,
settings, scheduled_task, audit_log,
)
api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router)
api_router.include_router(user.router)
api_router.include_router(instance.router)
api_router.include_router(ticket.router)
api_router.include_router(query.router)
api_router.include_router(dashboard.router)
api_router.include_router(audit.router)
api_router.include_router(audit_rule.router)
api_router.include_router(announcement.router)
api_router.include_router(notification.router)
api_router.include_router(ldap_config.router)
api_router.include_router(instance_detail.router)
api_router.include_router(backup.router)
api_router.include_router(redis.router)
api_router.include_router(mongo.router)
api_router.include_router(dictionary.router, prefix="/dictionary", tags=["dictionary"])
api_router.include_router(instance_group.router)
api_router.include_router(masking_rule.router)
api_router.include_router(rollback.router)
api_router.include_router(workflow.router)
api_router.include_router(impact.router)
api_router.include_router(schema_drift.router)
api_router.include_router(pool.router)
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=["系统设置"])
api_router.include_router(scheduled_task.router)
api_router.include_router(audit_log.router)
View File
Whitespace-only changes.
-129
View File
@@ -1,129 +0,0 @@
"""操作审计中间件 — 自动记录所有 POST/PUT/DELETE/PATCH 请求。"""
import time
import json
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.database import async_session
from app.models.operation_log import OperationLog
# 不记录审计的路径(高频/非业务)
_EXCLUDE_PREFIXES = (
"/api/v1/audit-logs",
"/api/v1/dashboard",
"/docs",
"/openapi",
"/redoc",
"/api/v1/query/execute", # 查询执行单独记录
)
# 资源类型映射: URL前缀 -> 资源类型
_RESOURCE_MAP = {
"/api/v1/tickets": "ticket",
"/api/v1/instances": "instance",
"/api/v1/users": "user",
"/api/v1/audit-rules": "audit_rule",
"/api/v1/notifications": "notification",
"/api/v1/auth": "auth",
"/api/v1/workflows": "workflow",
"/api/v1/settings": "system_setting",
}
# action 映射
_ACTION_MAP = {
"POST": "create",
"PUT": "update",
"PATCH": "update",
"DELETE": "delete",
}
class AuditLogMiddleware(BaseHTTPMiddleware):
"""自动审计中间件 — 记录所有写操作。"""
async def dispatch(self, request: Request, call_next):
# 只审计写操作
if request.method not in ("POST", "PUT", "PATCH", "DELETE"):
return await call_next(request)
path = request.url.path
# 跳过排除列表
if any(path.startswith(p) for p in _EXCLUDE_PREFIXES):
return await call_next(request)
# 特殊: POST /auth/login 在 auth.py 中单独记录
if path == "/api/v1/auth/login" or path == "/api/v1/auth/logout":
return await call_next(request)
response = await call_next(request)
# 异步记录审计日志(不阻塞响应)
try:
await self._record(request, response.status_code)
except Exception:
pass
return response
@staticmethod
async def _record(request: Request, status_code: int):
path = request.url.path
method = request.method
# 确定 action
action = _ACTION_MAP.get(method, method.lower())
if path.endswith("/review"):
action = "approve" if status_code < 300 else "reject"
elif path.endswith("/execute"):
action = "execute"
elif path.endswith("/cancel"):
action = "cancel"
# 确定资源类型
resource_type = "other"
for prefix, rtype in _RESOURCE_MAP.items():
if path.startswith(prefix):
resource_type = rtype
break
# 提取 resource_id
resource_id = None
parts = path.rstrip("/").split("/")
for p in parts:
if p.isdigit():
resource_id = int(p)
break
# 获取操作者信息
user_id = None
username = None
try:
from app.core.security import decode_token
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
payload = decode_token(auth_header[7:])
user_id = int(payload.get("sub", 0))
username = payload.get("role", "unknown")
except Exception:
pass
# IP
forwarded = request.headers.get("x-forwarded-for")
ip_address = forwarded.split(",")[0].strip() if forwarded else (
request.client.host if request.client else None
)
# 写入DB
async with async_session() as db:
log = OperationLog(
user_id=user_id,
username=username,
action=action,
resource_type=resource_type,
resource_id=resource_id,
detail=f"{method} {path} -> {status_code}",
ip_address=ip_address,
)
db.add(log)
await db.commit()
-40
View File
@@ -1,40 +0,0 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
import secrets
class Settings(BaseSettings):
ENV: str = "development"
DEBUG: bool = True
# JWT secret: 生产环境必须通过环境变量设置
# 若未设置则每次启动随机生成(重启后旧 token 失效)
SECRET_KEY: str = ""
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 # 8小时
ALGORITHM: str = "HS256"
DATABASE_URL: str = "sqlite+aiosqlite:///./sqlite3.db"
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 = ""
# 登录频率限制: 每IP每分钟最多尝试次数
LOGIN_RATE_LIMIT: int = 10
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@lru_cache()
def get_settings() -> Settings:
s = Settings()
if not s.SECRET_KEY:
s.SECRET_KEY = secrets.token_urlsafe(32)
if s.ENV == "development":
import logging
logging.getLogger(__name__).info("SECRET_KEY 未设置,已自动生成(重启后失效)")
return s
-45
View File
@@ -1,45 +0,0 @@
"""AES-256-GCM encryption for instance passwords."""
import base64
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from app.core.config import get_settings
_NONCE_SIZE = 12 # 96-bit nonce for AES-GCM
def _get_key() -> bytes:
"""Derive a 32-byte AES key from settings.SECRET_KEY."""
settings = get_settings()
# Use first 32 bytes of utf-8 encoded secret key (padded if needed)
raw = settings.SECRET_KEY.encode("utf-8")
if len(raw) >= 32:
return raw[:32]
return raw.ljust(32, b"\x00")
def encrypt_password(plain: str) -> str:
"""Encrypt a password string, return base64-encoded ciphertext (nonce + ciphertext)."""
key = _get_key()
nonce = os.urandom(_NONCE_SIZE)
aesgcm = AESGCM(key)
ct = aesgcm.encrypt(nonce, plain.encode("utf-8"), None)
return base64.b64encode(nonce + ct).decode("ascii")
def decrypt_password(encrypted: str) -> str:
"""Decrypt a base64-encoded encrypted password back to plaintext.
If the value is not valid base64 (e.g., legacy plaintext), return as-is.
"""
try:
key = _get_key()
raw = base64.b64decode(encrypted, validate=True)
if len(raw) <= _NONCE_SIZE:
return encrypted # too short to be encrypted
nonce = raw[:_NONCE_SIZE]
ct = raw[_NONCE_SIZE:]
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ct, None).decode("utf-8")
except Exception:
# Not encrypted (legacy plaintext password) — return as-is
return encrypted
-33
View File
@@ -1,33 +0,0 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from typing import AsyncGenerator
from app.core.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with async_session() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def init_db() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
-163
View File
@@ -1,163 +0,0 @@
"""Shared permission checking utilities.
Centralises instance-level access control logic used by both
the query endpoint and the ticket endpoint.
"""
import json
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.instance import Instance
async def check_instance_access(
user: User,
instance: Instance,
db: AsyncSession,
) -> None:
"""Verify that *user* is allowed to access *instance*.
Rules:
- ``admin`` role bypasses all checks.
- ``guest`` role is **not** handled here — callers must reject guests
before calling this function.
- If *instance.group_id* is ``None`` the instance is considered **public**
and any authenticated (non-guest) user may access it.
- If *instance.group_id* is set, the user must belong to that group.
Raises:
HTTPException(403): when access is denied.
"""
# Admin bypasses everything
if user.role == "admin":
return
# Public instance (no group) — accessible to all authenticated users
if instance.group_id is None:
return
# Check group membership — query the association table directly
# to avoid stale identity-map caches in the same session
from app.models.instance_group import user_groups
result = await db.execute(
select(user_groups.c.group_id)
.where(user_groups.c.user_id == user.id)
)
user_group_ids = [row[0] for row in result.all()]
if instance.group_id not in user_group_ids:
raise HTTPException(
status_code=403,
detail="您没有权限访问该数据源,请联系管理员申请权限",
)
async def get_user_accessible_group_ids(user: User, db: AsyncSession) -> list[int]:
"""获取用户可访问的所有权限组 ID 列表。
- admin 返回空列表(后续由调用方处理为"全部")
- 其他角色查询 user_groups 关联表
"""
if user.role == "admin":
return [] # 空列表表示"需要全部",调用方用这个区分
from app.models.instance_group import user_groups
result = await db.execute(
select(user_groups.c.group_id).where(user_groups.c.user_id == user.id)
)
return [row[0] for row in result.all()]
async def get_instance_or_403(
instance_id: int,
user: User,
db: AsyncSession,
) -> Instance:
"""获取实例并检查访问权限,无权限则 403。
供各端点复用,减少重复代码。
"""
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
await check_instance_access(user, instance, db)
return instance
# 权限操作项定义
PERMISSION_ACTIONS = {
"query:select",
"query:write",
"ticket:create",
"ticket:approve",
"ticket:execute",
"ddl:execute",
"slowlog:view",
"backup:manage",
"instance:detail",
}
async def check_instance_permission(
user: User,
instance: Instance,
action: str,
db: AsyncSession,
) -> None:
"""检查用户对实例是否具有指定的操作权限。
在 check_instance_access 通过后调用。
- admin 跳过
- 公开实例 (group_id=None) 允许所有
- 查 user_groups.permissions (用户级覆盖) > InstanceGroup.permissions (组默认)
- 如果都没配置,允许所有(向后兼容)
"""
if user.role == "admin":
return
if instance.group_id is None:
return
effective_perms = await _get_effective_permissions(user, instance, db)
if action not in effective_perms:
raise HTTPException(
status_code=403,
detail=f"您没有 '{action}' 操作权限,请联系管理员",
)
async def _get_effective_permissions(user, instance, db) -> set:
"""获取用户对实例的有效权限集。"""
from app.models.instance_group import InstanceGroup, user_groups
# 1. 查用户级覆盖权限
ug_result = await db.execute(
select(user_groups.c.permissions).where(
user_groups.c.user_id == user.id,
user_groups.c.group_id == instance.group_id,
)
)
ug_row = ug_result.first()
if ug_row and ug_row[0]:
try:
perms = json.loads(ug_row[0])
return set(perms) if perms else set()
except (json.JSONDecodeError, TypeError):
pass
# 2. 查组默认权限
group_result = await db.execute(
select(InstanceGroup).where(InstanceGroup.id == instance.group_id)
)
group = group_result.scalar_one_or_none()
if group:
group_perms = group.get_permissions()
if group_perms:
return set(group_perms)
# 3. 没配置 → 全部允许(向后兼容)
return PERMISSION_ACTIONS
-50
View File
@@ -1,50 +0,0 @@
"""登录频率限制 — 基于IP的滑动窗口计数器。"""
import time
from collections import defaultdict
from typing import Dict, List, Tuple
from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import get_settings
class LoginRateLimitMiddleware(BaseHTTPMiddleware):
"""限制每IP每分钟的登录尝试次数。"""
def __init__(self, app, **kwargs):
super().__init__(app, **kwargs)
self._attempts: Dict[str, List[float]] = defaultdict(list)
async def dispatch(self, request: Request, call_next):
if request.url.path == "/api/v1/auth/login" and request.method == "POST":
client_ip = self._get_client_ip(request)
settings = get_settings()
max_attempts = settings.LOGIN_RATE_LIMIT
now = time.time()
# 清理60秒前的记录
self._attempts[client_ip] = [
t for t in self._attempts[client_ip] if now - t < 60
]
if len(self._attempts[client_ip]) >= max_attempts:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"登录尝试过于频繁,请稍后再试(限制: {max_attempts}次/分钟)",
)
response = await call_next(request)
# 只在登录失败时记录(成功不计入限制)
if response.status_code != 200:
self._attempts[client_ip].append(now)
return response
return await call_next(request)
@staticmethod
def _get_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
-73
View File
@@ -1,73 +0,0 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import jwt, JWTError
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.database import get_db
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
settings = get_settings()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> dict:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
):
from app.models.user import User
payload = decode_token(token)
user_id: Optional[int] = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="无效的认证凭据")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=401, detail="用户不存在")
if not user.is_active:
raise HTTPException(status_code=403, detail="用户已禁用")
return user
def require_role(*roles: str):
async def role_checker(current_user=Depends(get_current_user)):
if current_user.role not in roles:
raise HTTPException(status_code=403, detail="权限不足")
return current_user
return role_checker
Whitespace-only changes.
-149
View File
@@ -1,149 +0,0 @@
import asyncio
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from app.core.config import get_settings
from app.core.rate_limit import LoginRateLimitMiddleware
from app.core.audit_middleware import AuditLogMiddleware
from app.core.database import init_db, async_session
from app.core.security import hash_password
from app.models.user import User
from app.models.instance import Instance
from app.models.ticket import Ticket
from app.models.audit_log import AuditLog
from app.api.v1.router import api_router
settings = get_settings()
async def seed_workflows():
"""创建默认审批流程。"""
async with async_session() as db:
from app.services.workflow_seeds import seed_default_workflows
try:
await seed_default_workflows(db)
except Exception:
pass # 可能已存在
async def seed_data():
"""Create default users with different roles."""
from app.core.security import hash_password
default_users = [
{"username": "admin", "display_name": "超级管理员", "role": "admin", "password": "admin123"},
{"username": "dba", "display_name": "张三", "role": "dba", "password": "admin123"},
{"username": "auditor", "display_name": "老六", "role": "auditor", "password": "admin123"},
{"username": "developer", "display_name": "路人甲", "role": "developer", "password": "admin123"},
]
async with async_session() as db:
for u in default_users:
result = await db.execute(select(User).where(User.username == u["username"]))
if not result.scalar_one_or_none():
db.add(User(
username=u["username"],
display_name=u["display_name"],
role=u["role"],
hashed_password=hash_password(u["password"]),
))
await db.commit()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
from app.core.database import Base
# Import all models so create_all picks them up
import app.models.user # noqa
import app.models.instance # noqa
import app.models.ticket # noqa
import app.models.audit_log # noqa
import app.models.audit_rule # noqa
import app.models.announcement # noqa
import app.models.notification # noqa
import app.models.ldap_config # noqa
import app.models.backup # noqa
import app.models.instance_group # noqa
import app.models.masking_rule # noqa
import app.models.workflow # noqa
import app.models.notification_template # noqa
import app.models.scheduled_task # noqa
import app.models.schema_revision # noqa
import app.models.query_history # noqa
await init_db()
await seed_data()
await seed_workflows()
# 同步审核规则到数据库(首次将硬编码 config/severity 写入 DB)
try:
from app.core.database import async_session
async with async_session() as sync_db:
from app.services.audit_rule_sync import sync_rules_to_db
await sync_rules_to_db(sync_db)
except Exception as e:
import logging
logging.getLogger(__name__).warning("审核规则同步失败: %s", e)
# 确保所有种子设置项存在(含 executor 分组等新增项)
try:
from app.core.database import async_session
async with async_session() as seed_db:
from app.api.v1.endpoints.settings import _ensure_settings
await _ensure_settings(seed_db)
except Exception as e:
import logging
logging.getLogger(__name__).warning("种子设置同步失败: %s", e)
# 启动 APScheduler + 恢复定时工单
from app.services.ticket_scheduler import start_scheduler, recover_scheduled_tickets, shutdown_scheduler
start_scheduler()
recovered = await recover_scheduled_tickets()
if recovered > 0:
import logging
logging.getLogger(__name__).info("恢复了 %d 个定时工单", recovered)
yield
# Shutdown
shutdown_scheduler()
app = FastAPI(
title="SQL-Platform API",
version="1.0.0",
description="数据审核平台 API",
lifespan=lifespan,
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(AuditLogMiddleware)
app.add_middleware(LoginRateLimitMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
@app.get("/health")
async def health_check():
return {"status": "ok", "version": "1.0.0", "time": datetime.now(timezone.utc).isoformat()}
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=9000, reload=True)
-5
View File
@@ -1,5 +0,0 @@
from app.models.instance_group import InstanceGroup, user_groups, instance_group_members # noqa: F401
from app.models.system_setting import SystemSetting # noqa: F401
from app.models.query_history import QueryHistory, QuickQueryModel # noqa: F401
from app.models.ticket_sql_item import TicketSqlItem # noqa: F401
from app.models.operation_log import OperationLog # noqa: F401
-15
View File
@@ -1,15 +0,0 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, func
from app.core.database import Base
class Announcement(Base):
__tablename__ = "announcements"
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(200), nullable=False)
content = Column(Text, nullable=False) # markdown
is_published = Column(Boolean, default=False)
published_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
-45
View File
@@ -1,45 +0,0 @@
"""Audit log model — 全量操作审计记录。
记录平台所有操作行为,支持查询和变更的全链路追踪:
- 查询操作: 查询内容、数据库类型、执行耗时、结果行数
- 变更工单: 工单全生命周期、影响行数、执行结果
- 数据源管理: 增删改查
- 权限操作: 登录/角色变更
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, func
from app.core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, nullable=False, index=True)
username = Column(String(50), nullable=True)
action = Column(String(50), nullable=False, index=True)
target_type = Column(String(50), nullable=False, index=True)
target_id = Column(Integer, nullable=True, index=True)
detail = Column(Text, nullable=True)
# 数据库操作上下文
db_type = Column(String(20), nullable=True, index=True)
database_name = Column(String(100), nullable=True)
instance_name = Column(String(100), nullable=True)
# 执行指标
execution_time_ms = Column(Integer, nullable=True)
rows_affected = Column(Integer, nullable=True)
result_status = Column(String(20), nullable=True)
# 查询内容(SQL/Redis命令/MongoDB操作)
query_content = Column(Text, nullable=True)
# 网络信息
ip_address = Column(String(50), nullable=True)
user_agent = Column(String(200), nullable=True)
# 是否高风险操作
is_risky = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now(), index=True)
-22
View File
@@ -1,22 +0,0 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, func
from app.core.database import Base
class AuditRule(Base):
__tablename__ = "audit_rules"
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(50), nullable=True, unique=True, comment="规则编码,如 MYSQL-DDL-001")
name = Column(String(100), nullable=False, unique=True)
description = Column(Text, nullable=True)
rule_type = Column(String(50), nullable=False) # syntax / performance / safety / naming / DDL / DML
severity = Column(String(20), default="warning") # error / warning / info
db_type = Column(String(20), default="mysql") # mysql / postgres / redis / all
pattern = Column(Text, nullable=True) # regex or keyword pattern
value = Column(String(255), nullable=True) # 规则参数值,如 DDLMaxKeyParts=8 → value="8"
config_json = Column(Text, nullable=True, comment="规则配置JSON,如 {\"max_index\":20,\"enabled\":true}")
is_enabled = Column(Boolean, default=True)
message = Column(String(500), nullable=False) # human-readable message
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
-19
View File
@@ -1,19 +0,0 @@
from sqlalchemy import Column, Integer, String, BigInteger, Text, DateTime, func
from app.core.database import Base
class Backup(Base):
__tablename__ = "backups"
id = Column(Integer, primary_key=True, autoincrement=True)
instance_id = Column(Integer, nullable=False)
title = Column(String(200), nullable=False)
database = Column(String(100), nullable=False)
filename = Column(String(500), nullable=False, default="")
size_bytes = Column(BigInteger, default=0)
status = Column(String(20), default="pending") # pending / running / completed / failed
created_by = Column(Integer, nullable=True)
created_at = Column(DateTime, server_default=func.now())
completed_at = Column(DateTime, nullable=True)
remark = Column(Text, nullable=True)
-36
View File
@@ -1,36 +0,0 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func
from sqlalchemy.orm import relationship
from app.core.database import Base
class Instance(Base):
__tablename__ = "instances"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
db_type = Column(String(20), nullable=False) # mysql / postgres / redis / mongo
host = Column(String(200), nullable=False)
port = Column(Integer, nullable=False)
username = Column(String(100), nullable=False)
password = Column(String(500), nullable=False) # encrypted
database = Column(String(100), nullable=True)
env = Column(String(20), default="development") # production / staging / development
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_id = Column(Integer, ForeignKey("workflow_definitions.id", ondelete="SET NULL"), nullable=True)
excluded_databases = Column(Text, nullable=True) # JSON: ["sys", "information_schema", ...]
mask_fields = Column(Text, nullable=True) # JSON: [{table, field, rule}]
# 权限组关联 (legacy FK, kept for compat)
group_id = Column(Integer, ForeignKey("instance_groups.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# 关系 — legacy group relationship removed; use instance_group_members instead
workflow_def = relationship("WorkflowDefinition")
-78
View File
@@ -1,78 +0,0 @@
from __future__ import annotations
import json
from typing import List, Optional
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Table, func, UniqueConstraint
from sqlalchemy.orm import relationship
from app.core.database import Base
# 用户-权限组关联表
user_groups = Table(
"user_groups",
Base.metadata,
Column("user_id", Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("group_id", Integer, ForeignKey("instance_groups.id", ondelete="CASCADE"), primary_key=True),
Column("permissions", Text, nullable=True), # JSON: 覆盖组默认权限 (legacy, kept for compat)
)
# 实例-权限组关联表(一个权限组可同时包含 readonly 和 write 实例)
instance_group_members = Table(
"instance_group_members",
Base.metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("group_id", Integer, ForeignKey("instance_groups.id", ondelete="CASCADE"), nullable=False),
Column("instance_id", Integer, ForeignKey("instances.id", ondelete="CASCADE"), nullable=False),
Column("access_type", String(20), nullable=False, default="readonly"), # readonly / write
UniqueConstraint("group_id", "instance_id", "access_type", name="uq_group_instance_access"),
)
class InstanceGroup(Base):
__tablename__ = "instance_groups"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, unique=True)
description = Column(Text, nullable=True)
db_type = Column(String(20), nullable=False, default="all") # mysql/redis/pg/mongo/all
permissions = Column(Text, nullable=True) # JSON: ["query:select", ...] (legacy, kept for compat)
access_type = Column(String(20), nullable=True, default="readonly") # legacy, kept for compat
allowed_databases = Column(Text, nullable=True) # JSON array of database names; empty = all databases
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# 关系 — users 保持不变
users = relationship("User", secondary=user_groups, back_populates="groups", lazy="selectin")
# ---- Legacy helpers (kept for backward compatibility) ----
def get_permissions(self) -> list:
if self.permissions:
try:
return json.loads(self.permissions)
except (json.JSONDecodeError, TypeError):
return []
return []
def set_permissions(self, perms: list):
self.permissions = json.dumps(perms, ensure_ascii=False) if perms else None
# ---- New access_type / allowed_databases helpers ----
def get_allowed_databases(self) -> list[str]:
"""返回允许的数据库名列表,空列表表示关联实例的所有库。"""
if self.allowed_databases:
try:
return json.loads(self.allowed_databases)
except (json.JSONDecodeError, TypeError):
return []
return []
def set_allowed_databases(self, databases: list[str] | None):
self.allowed_databases = (
json.dumps(databases, ensure_ascii=False) if databases else None
)
VALID_ACCESS_TYPES = {"readonly", "write"}
-19
View File
@@ -1,19 +0,0 @@
from sqlalchemy import Column, Integer, String, Boolean, Text, DateTime, func
from app.core.database import Base
class LdapConfig(Base):
__tablename__ = "ldap_config"
id = Column(Integer, primary_key=True, autoincrement=True)
ldap_host = Column(String(200), nullable=False)
ldap_port = Column(Integer, default=389)
use_ldaps = Column(Boolean, default=False)
admin_dn = Column(String(500), nullable=False)
admin_password = Column(String(500), nullable=False) # encrypted
search_filter = Column(String(500), nullable=True)
base_dn = Column(String(500), nullable=False)
user_mapping = Column(Text, nullable=True) # JSON string
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
-31
View File
@@ -1,31 +0,0 @@
"""数据脱敏规则模型 — 定义脱敏规则的存储结构。"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, func, UniqueConstraint
from app.core.database import Base
class MaskingRule(Base):
"""数据脱敏规则,用于动态脱敏查询结果。
通过组合 (instance_id, database, table_name, column_name) 唯一确定一条规则。
"""
__tablename__ = "masking_rules"
id = Column(Integer, primary_key=True, autoincrement=True)
instance_id = Column(Integer, ForeignKey("instances.id"), nullable=False)
database = Column(String(100), nullable=False)
table_name = Column(String(100), nullable=False)
column_name = Column(String(100), nullable=False)
mask_type = Column(String(30), nullable=False) # full / partial / hash / truncate / regex
mask_config = Column(Text, nullable=True) # JSON 配置
is_enabled = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint(
"instance_id", "database", "table_name", "column_name",
name="uk_masking_rule_col"
),
)
-15
View File
@@ -1,15 +0,0 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, func
from app.core.database import Base
class NotificationChannel(Base):
__tablename__ = "notification_channels"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
channel_type = Column(String(30), nullable=False) # dingtalk / webhook_feishu / email
config = Column(Text, nullable=False) # JSON string
is_enabled = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
@@ -1,18 +0,0 @@
"""通知模板模型。"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, func
from app.core.database import Base
class NotificationTemplate(Base):
"""通知消息模板。"""
__tablename__ = "notification_templates"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
event_type = Column(String(50), nullable=False)
channel_type = Column(String(30), nullable=False) # dingtalk / feishu / email
title_template = Column(Text, nullable=False)
body_template = Column(Text, nullable=False)
is_enabled = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
-27
View File
@@ -1,27 +0,0 @@
"""操作审计日志模型。"""
from sqlalchemy import Column, Integer, String, Text, DateTime, Index
from sqlalchemy.sql import func
from app.core.database import Base
class OperationLog(Base):
__tablename__ = "operation_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, nullable=True, comment="操作者ID(系统操作为空)")
username = Column(String(50), nullable=True, comment="操作者用户名")
action = Column(String(50), nullable=False, comment="操作类型: login/create/update/delete/execute/approve/reject")
resource_type = Column(String(50), nullable=True, comment="资源类型: ticket/instance/user/audit_rule/notification/workflow")
resource_id = Column(Integer, nullable=True, comment="资源ID")
detail = Column(Text, nullable=True, comment="操作详情")
ip_address = Column(String(50), nullable=True, comment="操作者IP")
user_agent = Column(String(255), nullable=True, comment="浏览器UA")
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
Index("ix_oplog_user_id", "user_id"),
Index("ix_oplog_action", "action"),
Index("ix_oplog_resource", "resource_type", "resource_id"),
Index("ix_oplog_created_at", "created_at"),
)
-40
View File
@@ -1,40 +0,0 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, func
from app.core.database import Base
class QueryHistory(Base):
__tablename__ = "query_histories"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, nullable=False, index=True)
username = Column(String(100), nullable=False, default="")
instance_id = Column(Integer, nullable=False, default=0)
instance_name = Column(String(100), nullable=False, default="")
db_type = Column(String(20), nullable=False, default="")
database_name = Column(String(100), nullable=False, default="")
query_content = Column(Text, nullable=False, default="")
query_type = Column(String(50), nullable=False, default="")
execution_time_ms = Column(Integer, nullable=False, default=0)
result_rows = Column(Integer, nullable=False, default=0)
is_success = Column(Boolean, nullable=False, default=True)
error_message = Column(Text, nullable=True)
is_favorite = Column(Boolean, nullable=False, default=False)
note = Column(Text, nullable=False, default="")
created_at = Column(DateTime, server_default=func.now())
class QuickQueryModel(Base):
__tablename__ = "quick_queries"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(200), nullable=False)
description = Column(Text, nullable=False, default="")
query_content = Column(Text, nullable=False, default="")
db_type = Column(String(20), nullable=False, default="")
database_pattern = Column(String(200), nullable=False, default="*")
is_shared = Column(Boolean, nullable=False, default=False)
created_by = Column(Integer, nullable=False, default=0)
created_by_name = Column(String(100), nullable=False, default="")
usage_count = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, server_default=func.now())
-44
View File
@@ -1,44 +0,0 @@
"""定时任务模型 — 基于纯SQL的自动化任务。"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, func
from app.core.database import Base
class ScheduledTask(Base):
"""定时任务:insert / update / delete 三种类型,指定数据源+库表+纯SQL。"""
__tablename__ = "scheduled_tasks"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
task_sql_type = Column(String(20), nullable=False, default="insert") # insert / update / delete
instance_id = Column(Integer, ForeignKey("instances.id", ondelete="CASCADE"), nullable=False)
target_db = Column(String(100), nullable=True)
target_table = Column(String(100), nullable=True)
sql_content = Column(Text, nullable=False)
cron_expression = Column(String(50), nullable=False)
is_enabled = Column(Boolean, default=True)
# 运行状态
last_run_at = Column(DateTime, nullable=True)
last_run_status = Column(String(20), nullable=True) # success / failed
last_run_message = Column(Text, nullable=True)
next_run_at = Column(DateTime, nullable=True)
run_count = Column(Integer, default=0)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class ScheduledTaskLog(Base):
"""定时任务执行日志。"""
__tablename__ = "scheduled_task_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
task_id = Column(Integer, ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False)
status = Column(String(20), nullable=False) # success / failed / running
sql_content = Column(Text, nullable=True)
affected_rows = Column(Integer, default=0)
duration_ms = Column(Integer, nullable=True)
error_message = Column(Text, nullable=True)
started_at = Column(DateTime, server_default=func.now())
finished_at = Column(DateTime, nullable=True)
-19
View File
@@ -1,19 +0,0 @@
"""Schema 版本历史模型。"""
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func
from app.core.database import Base
class SchemaRevision(Base):
"""Schema 版本历史(Schema-as-Code)。"""
__tablename__ = "schema_revisions"
id = Column(Integer, primary_key=True, autoincrement=True)
instance_id = Column(Integer, ForeignKey("instances.id"), nullable=False)
database = Column(String(100), nullable=False)
revision = Column(String(64), nullable=False)
ddl_content = Column(Text, nullable=True)
checksum = Column(String(64), nullable=False)
applied_by = Column(Integer, ForeignKey("users.id"), nullable=True)
applied_at = Column(DateTime, server_default=func.now())
status = Column(String(20), default="applied")
-36
View File
@@ -1,36 +0,0 @@
"""系统设置模型 — 键值对存储,支持分组。"""
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
-31
View File
@@ -1,31 +0,0 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func
from app.core.database import Base
class Ticket(Base):
__tablename__ = "tickets"
id = Column(Integer, primary_key=True, autoincrement=True)
ticket_no = Column(String(20), unique=True, nullable=True, comment="工单编号,格式: 20260522160543841")
title = Column(String(200), nullable=False)
db_type = Column(String(20), nullable=False)
instance_id = Column(Integer, ForeignKey("instances.id"), nullable=False)
target_database = Column(String(100), nullable=False)
sql_content = Column(Text, nullable=False)
rollback_sql = Column(Text, nullable=True)
status = Column(String(20), default="pending") # pending / reviewing / approved / rejected / executing / success / failed
priority = Column(String(10), default="medium") # high / medium / low
risk_level = Column(String(20), default="low", comment="自动风险等级: low/medium/high/critical")
workflow_id = Column(Integer, ForeignKey("workflow_definitions.id"), nullable=True, comment="关联审批流")
exec_strategy = Column(String(20), default="stop_on_error", comment="执行策略: stop_on_error/continue/transaction")
applicant_id = Column(Integer, ForeignKey("users.id"), nullable=False)
approver_id = Column(Integer, ForeignKey("users.id"), nullable=True)
reason = Column(Text, nullable=True)
audit_result = Column(Text, nullable=True)
execute_result = Column(Text, nullable=True)
create_time = Column(DateTime, server_default=func.now())
review_time = Column(DateTime, nullable=True)
execute_time = Column(DateTime, nullable=True)
scheduled_at = Column(DateTime, nullable=True, comment="定时执行时间,NULL=立即执行")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
-28
View File
@@ -1,28 +0,0 @@
"""工单 SQL 逐条执行记录 — 支持执行进度透明展示。
每条 SQL 单独记录执行状态、耗时、影响行数、错误信息。
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func
from sqlalchemy.orm import relationship
from app.core.database import Base
class TicketSqlItem(Base):
__tablename__ = "ticket_sql_items"
id = Column(Integer, primary_key=True, autoincrement=True)
ticket_id = Column(Integer, ForeignKey("tickets.id"), nullable=False, index=True)
seq = Column(Integer, nullable=False, comment="SQL 顺序号,从 1 开始")
sql_text = Column(Text, nullable=False, comment="单条 SQL 文本")
sql_type = Column(String(20), nullable=True, comment="SQL 类型: DDL/DML/DQL/SET 等")
status = Column(String(20), default="pending", comment="pending/running/success/failed/skipped")
affected_rows = Column(Integer, default=0)
duration_ms = Column(Integer, default=0, comment="执行耗时(毫秒)")
error_message = Column(Text, nullable=True)
started_at = Column(DateTime, nullable=True)
finished_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now())
ticket = relationship("Ticket", backref="sql_items")
-23
View File
@@ -1,23 +0,0 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
from sqlalchemy.orm import relationship
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False, index=True)
display_name = Column(String(100), nullable=False)
email = Column(String(100), unique=True, nullable=True)
phone = Column(String(20), nullable=True)
hashed_password = Column(String(200), nullable=False)
role = Column(String(20), nullable=False, default="developer") # admin / dba / auditor / developer / guest
is_active = Column(Boolean, default=True)
last_login = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
# Many-to-Many relationship with InstanceGroup
groups = relationship("InstanceGroup", secondary="user_groups", back_populates="users", lazy="selectin")
-362
View File
@@ -1,362 +0,0 @@
"""审批流程模型 — 支持多级、并行、条件审批、版本控制与执行日志。"""
import json
from sqlalchemy import (
Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON, func, UniqueConstraint
)
from sqlalchemy.orm import relationship
from app.core.database import Base
class WorkflowDefinition(Base):
"""审批流程定义模板。"""
__tablename__ = "workflow_definitions"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
category = Column(String(50), default="ticket") # ticket / query / instance / general
is_default = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
# ═══════════════════════════════════════════════════════════
# 流程级配置(JSON格式,灵活扩展)
# ═══════════════════════════════════════════════════════════
# 全局流程配置:
# {
# "timeout_strategy": "auto_approve|auto_reject|escalate|notify",
# "timeout_hours": 48,
# "escalation_user_id": 1,
# "allow_transfer": true,
# "allow_skip": false,
# "require_comment": true,
# "parallel_mode": "all|any", // 并行审批模式:全部通过/任一通过
# "notify_channels": ["email", "sms", "webhook"]
# }
flow_config = Column(JSON, nullable=True, default=dict)
# 触发条件配置(支持多条件组合):
# {
# "conditions": [
# {"field": "ticket.type", "operator": "in", "value": ["DDL", "DML"]},
# {"field": "ticket.risk_level", "operator": ">=", "value": "high"},
# {"field": "database.instance_id", "operator": "==", "value": 1}
# ],
# "logic": "AND|OR"
# }
trigger_conditions = Column(JSON, nullable=True)
# 步骤定义关联
steps = relationship(
"WorkflowStep",
back_populates="workflow",
order_by="WorkflowStep.step_number",
cascade="all, delete-orphan"
)
# 版本关联
versions = relationship(
"WorkflowVersion",
back_populates="workflow",
order_by="desc(WorkflowVersion.version_number)",
cascade="all, delete-orphan"
)
# 审批实例关联
approvals = relationship(
"TicketApproval",
back_populates="workflow_def",
lazy="dynamic"
)
# 执行日志关联
execution_logs = relationship(
"WorkflowExecutionLog",
back_populates="workflow",
lazy="dynamic",
order_by="desc(WorkflowExecutionLog.created_at)"
)
def get_current_version(self):
"""获取当前激活的版本。"""
return next((v for v in self.versions if v.is_active), None)
def to_dict(self, include_steps: bool = False) -> dict:
data = {
"id": self.id,
"name": self.name,
"description": self.description,
"category": self.category,
"is_default": self.is_default,
"is_active": self.is_active,
"flow_config": self.flow_config,
"trigger_conditions": self.trigger_conditions,
"step_count": len(self.steps) if self.steps else 0,
"version_count": len(self.versions) if self.versions else 0,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_steps:
data["steps"] = [s.to_dict() for s in (self.steps or [])]
return data
class WorkflowVersion(Base):
"""审批流程版本控制 — 支持流程的版本管理与回溯。"""
__tablename__ = "workflow_versions"
__table_args__ = (UniqueConstraint("workflow_id", "version_number", name="uix_workflow_version"),)
id = Column(Integer, primary_key=True, autoincrement=True)
workflow_id = Column(Integer, ForeignKey("workflow_definitions.id", ondelete="CASCADE"), nullable=False)
version_number = Column(Integer, nullable=False) # 版本号,递增
version_name = Column(String(100), nullable=True) # 版本名称,如 "v2.0-添加并行审批"
is_active = Column(Boolean, default=False) # 是否为当前激活版本
# 版本快照 — 存储此版本完整的流程定义快照
steps_snapshot = Column(JSON, nullable=False)
flow_config_snapshot = Column(JSON, nullable=True)
# 变更说明
change_summary = Column(Text, nullable=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, server_default=func.now())
# 关联
workflow = relationship("WorkflowDefinition", back_populates="versions")
def to_dict(self) -> dict:
return {
"id": self.id,
"workflow_id": self.workflow_id,
"version_number": self.version_number,
"version_name": self.version_name,
"is_active": self.is_active,
"change_summary": self.change_summary,
"steps_count": len(self.steps_snapshot) if self.steps_snapshot else 0,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class WorkflowStep(Base):
"""审批步骤定义 — 支持灵活的条件判断与动态审批人分配。"""
__tablename__ = "workflow_steps"
id = Column(Integer, primary_key=True, autoincrement=True)
workflow_id = Column(Integer, ForeignKey("workflow_definitions.id", ondelete="CASCADE"), nullable=False)
step_number = Column(Integer, nullable=False)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
# 步骤类型: serial(串行) / parallel(并行-会签) / any_one(或签) / condition(条件分支)
step_type = Column(String(20), nullable=False, default="serial")
# 审批人角色
approver_role = Column(String(20), nullable=True) # admin / dba / manager / creator
# 指定审批人ID列表(JSON数组)
approver_ids = Column(JSON, nullable=True)
# 审批人分配策略:
# {
# "type": "role|specific|leader|load_balance|creator|expression",
# "expression": "${ticket.creator.manager_id}", // 动态表达式
# "fallback_user_id": 1, // 无法解析时的 fallback
# "department_id": 2 // 指定部门
# }
approver_assignment = Column(JSON, nullable=True, default=dict)
is_required = Column(Boolean, default=True)
timeout_hours = Column(Integer, default=24)
auto_approve_on_timeout = Column(Boolean, default=False)
# ═══════════════════════════════════════════════════════════
# 条件判断配置(条件步骤使用)
# ═══════════════════════════════════════════════════════════
# 步骤准入条件(满足条件才进入此步骤):
# {
# "conditions": [
# {"field": "ticket.sql_type", "operator": "in", "value": ["ALTER", "DROP"]},
# {"field": "affected_rows", "operator": ">", "value": 1000}
# ],
# "logic": "AND"
# }
entry_conditions = Column(JSON, nullable=True)
# 步骤流转条件(满足条件才进入下一步,否则跳过后续步骤):
# {
# "conditions": [
# {"field": "current.result", "operator": "==", "value": "approved"}
# ],
# "on_false_action": "skip|reject|goto_step",
# "goto_step_number": 3
# }
transition_conditions = Column(JSON, nullable=True)
# ═══════════════════════════════════════════════════════════
# 字段权限配置
# ═══════════════════════════════════════════════════════════
# 此步骤中可查看/编辑的字段:
# {
# "visible_fields": ["ticket.title", "ticket.sql", "ticket.description"],
# "editable_fields": ["ticket.comment"],
# "masked_fields": ["ticket.password", "ticket.secret_key"]
# }
field_permissions = Column(JSON, nullable=True)
workflow = relationship("WorkflowDefinition", back_populates="steps")
def to_dict(self) -> dict:
return {
"id": self.id,
"workflow_id": self.workflow_id,
"step_number": self.step_number,
"name": self.name,
"description": self.description,
"step_type": self.step_type,
"approver_role": self.approver_role,
"approver_ids": self.approver_ids,
"approver_assignment": self.approver_assignment,
"is_required": self.is_required,
"timeout_hours": self.timeout_hours,
"auto_approve_on_timeout": self.auto_approve_on_timeout,
"entry_conditions": self.entry_conditions,
"transition_conditions": self.transition_conditions,
"field_permissions": self.field_permissions,
}
class TicketApproval(Base):
"""工单审批实例(运行时记录)。"""
__tablename__ = "ticket_approvals"
id = Column(Integer, primary_key=True, autoincrement=True)
ticket_id = Column(Integer, ForeignKey("tickets.id"), nullable=False, index=True)
# 关联的流程定义和版本
workflow_id = Column(Integer, ForeignKey("workflow_definitions.id"), nullable=True)
version_id = Column(Integer, ForeignKey("workflow_versions.id"), nullable=True)
step_number = Column(Integer, nullable=False)
step_name = Column(String(100), nullable=True)
step_type = Column(String(20), default="serial") # serial / parallel / any_one / condition
# 审批人信息
approver_id = Column(Integer, ForeignKey("users.id"), nullable=True)
approver_role = Column(String(20), nullable=True)
# 实际执行人(转交场景)
actual_approver_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# 审批动作: pending / approved / rejected / transferred / skipped / auto_approved
action = Column(String(20), default="pending", index=True)
comment = Column(Text, nullable=True)
# 转交信息
transfer_from = Column(Integer, ForeignKey("users.id"), nullable=True)
transfer_reason = Column(Text, nullable=True)
# 审批时间记录
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
completed_at = Column(DateTime, nullable=True)
# 超时标记
is_timeout = Column(Boolean, default=False)
timeout_handled_at = Column(DateTime, nullable=True)
# 关联
workflow_def = relationship("WorkflowDefinition", back_populates="approvals")
approver = relationship("User", foreign_keys=[approver_id])
actual_approver = relationship("User", foreign_keys=[actual_approver_id])
def to_dict(self) -> dict:
return {
"id": self.id,
"ticket_id": self.ticket_id,
"workflow_id": self.workflow_id,
"step_number": self.step_number,
"step_name": self.step_name,
"step_type": self.step_type,
"approver_id": self.approver_id,
"approver_role": self.approver_role,
"approver_name": self.approver.display_name if self.approver else None,
"actual_approver_id": self.actual_approver_id,
"action": self.action,
"comment": self.comment,
"transfer_from": self.transfer_from,
"transfer_reason": self.transfer_reason,
"is_timeout": self.is_timeout,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
}
class WorkflowExecutionLog(Base):
"""流程执行日志 — 记录审批流程的完整执行轨迹,用于审计和问题排查。"""
__tablename__ = "workflow_execution_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
workflow_id = Column(Integer, ForeignKey("workflow_definitions.id"), nullable=False, index=True)
ticket_id = Column(Integer, ForeignKey("tickets.id"), nullable=True, index=True)
step_id = Column(Integer, ForeignKey("workflow_steps.id"), nullable=True)
approval_id = Column(Integer, ForeignKey("ticket_approvals.id"), nullable=True)
# 事件类型:
# workflow_started / step_entered / step_completed / approved / rejected /
# transferred / skipped / timeout / escalated / workflow_completed / workflow_cancelled
event_type = Column(String(50), nullable=False, index=True)
# 事件详情
event_description = Column(Text, nullable=True)
# 执行人
actor_id = Column(Integer, ForeignKey("users.id"), nullable=True)
actor_name = Column(String(100), nullable=True)
# 上下文数据(快照):
# {
# "step_snapshot": {...},
# "approval_snapshot": {...},
# "decision_context": {...},
# "condition_results": [...]
# }
context_data = Column(JSON, nullable=True)
created_at = Column(DateTime, server_default=func.now(), index=True)
# 关联
workflow = relationship("WorkflowDefinition", back_populates="execution_logs")
def to_dict(self) -> dict:
return {
"id": self.id,
"workflow_id": self.workflow_id,
"ticket_id": self.ticket_id,
"step_id": self.step_id,
"approval_id": self.approval_id,
"event_type": self.event_type,
"event_description": self.event_description,
"actor_id": self.actor_id,
"actor_name": self.actor_name,
"context_data": self.context_data,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self) -> str:
return (
f"<WorkflowExecutionLog("
f"workflow={self.workflow_id}, ticket={self.ticket_id}, "
f"event='{self.event_type}', actor='{self.actor_name}')>"
)
View File
Whitespace-only changes.
-26
View File
@@ -1,26 +0,0 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class AnnouncementCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
content: str = Field(..., min_length=1)
class AnnouncementUpdate(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=200)
content: Optional[str] = None
is_published: Optional[bool] = None
class AnnouncementResponse(BaseModel):
id: int
title: str
content: str
is_published: bool
published_at: Optional[datetime] = None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
-50
View File
@@ -1,50 +0,0 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, Literal
class AuditRuleCreate(BaseModel):
code: Optional[str] = Field(default=None, max_length=50)
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
rule_type: Literal["syntax", "performance", "safety", "security", "naming", "DDL", "DML", "DQL", "DCL"]
severity: Literal["error", "warning", "info"] = "warning"
db_type: str = "mysql"
pattern: Optional[str] = None
value: Optional[str] = Field(default=None, max_length=255)
config_json: Optional[str] = Field(default=None, description="规则配置JSON,如 {\"max_index\":20}")
is_enabled: bool = True
message: str = Field(..., min_length=1, max_length=500)
class AuditRuleUpdate(BaseModel):
code: Optional[str] = Field(default=None, max_length=50)
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
description: Optional[str] = None
rule_type: Optional[Literal["syntax", "performance", "safety", "security", "naming", "DDL", "DML", "DQL", "DCL"]] = None
severity: Optional[Literal["error", "warning", "info"]] = None
db_type: Optional[str] = None
pattern: Optional[str] = None
value: Optional[str] = Field(default=None, max_length=255)
config_json: Optional[str] = Field(default=None, description="规则配置JSON,如 {\"max_index\":20}")
is_enabled: Optional[bool] = None
message: Optional[str] = Field(default=None, min_length=1, max_length=500)
class AuditRuleResponse(BaseModel):
id: int
code: Optional[str] = None
name: str
description: Optional[str] = None
rule_type: str
severity: str
db_type: str
pattern: Optional[str] = None
value: Optional[str] = None
config_json: Optional[str] = None
is_enabled: bool
message: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
-23
View File
@@ -1,23 +0,0 @@
from typing import Optional
from pydantic import BaseModel, ConfigDict
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: "UserBrief"
class UserBrief(BaseModel):
id: int
username: str
display_name: str
role: str
email: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
-27
View File
@@ -1,27 +0,0 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class BackupCreate(BaseModel):
title: str
instance_id: int
database: str
remark: Optional[str] = None
class BackupResponse(BaseModel):
id: int
title: str
instance_id: int
database: str
filename: str
size_bytes: int
status: str
created_by: Optional[int] = None
created_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
remark: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
-32
View File
@@ -1,32 +0,0 @@
from pydantic import BaseModel
from typing import Optional, List
class QueryStatsData(BaseModel):
total: int = 0
avg_time_ms: int = 0
slow_count: int = 0
type_distribution: List[dict] = []
class DashboardStats(BaseModel):
ticket_total: int = 0
ticket_pending: int = 0
ticket_month: int = 0
ticket_executing: int = 0
instance_total: int = 0
instance_online: int = 0
user_total: int = 0
queries_today: int = 0
query_stats: Optional[QueryStatsData] = None
class TrendPoint(BaseModel):
date: str
submitted: int = 0
success: int = 0
failed: int = 0
class TrendData(BaseModel):
items: List[TrendPoint] = []
-101
View File
@@ -1,101 +0,0 @@
"""执行相关 Schema — 工单执行报告和数据验证模型。"""
from typing import List, Optional
from pydantic import BaseModel, Field
class ExecutionReportResponse(BaseModel):
"""工单执行报告响应模型。
包含工单执行后的完整结果信息,用于 API 返回。
Attributes:
status: 执行状态(success / failed / partial)
affected_rows: 影响行数(SQL)或成功命令数(Redis)或修改文档数(MongoDB)
duration_ms: 执行耗时(毫秒)
validation_passed: 验证是否通过
warnings: 警告信息列表
errors: 错误信息列表
raw_output: 原始执行输出
before_snapshot_summary: 执行前快照摘要
after_snapshot_summary: 执行后快照摘要
"""
status: str = Field(..., description="执行状态: success / failed / partial")
affected_rows: int = Field(0, ge=0, description="影响行数")
duration_ms: int = Field(0, ge=0, description="执行耗时(毫秒)")
validation_passed: bool = Field(True, description="验证是否通过")
warnings: List[str] = Field(default_factory=list, description="警告信息")
errors: List[str] = Field(default_factory=list, description="错误信息")
raw_output: Optional[str] = Field(None, description="原始执行输出")
before_snapshot_summary: Optional[str] = Field(None, description="执行前快照摘要")
after_snapshot_summary: Optional[str] = Field(None, description="执行后快照摘要")
class Config:
json_schema_extra = {
"example": {
"status": "success",
"affected_rows": 3,
"duration_ms": 145,
"validation_passed": True,
"warnings": [],
"errors": [],
"raw_output": "[UPDATE users SET status='active' WHERE ...] 影响 3 行",
"before_snapshot_summary": "UPDATE 表 `users`: 将有 3 行被更新 (WHERE: status='inactive')",
"after_snapshot_summary": "表 `users`: 当前 150 行",
}
}
class RedisCommandRequest(BaseModel):
"""Redis 命令请求模型。"""
command: str = Field(..., min_length=1, description="Redis 命令")
db: int = Field(0, ge=0, le=15, description="数据库编号")
class RedisTicketContent(BaseModel):
"""Redis 工单内容模型(JSON 格式)。"""
commands: List[RedisCommandRequest] = Field(
..., min_length=1, description="命令列表"
)
class MongoOperationRequest(BaseModel):
"""MongoDB 操作请求模型。
支持的操作类型:
- insertOne / insertMany: 插入文档
- updateOne / updateMany: 更新文档
- deleteOne / deleteMany: 删除文档
- replaceOne: 替换文档
"""
collection: str = Field(..., min_length=1, description="集合名称")
operation: str = Field(
...,
pattern=r"^(insertOne|insertMany|updateOne|updateMany|deleteOne|deleteMany|replaceOne)$",
description="操作类型",
)
filter: Optional[dict] = Field(None, description="查询条件")
update: Optional[dict] = Field(None, description="更新内容")
document: Optional[dict] = Field(None, description="插入/替换的文档")
documents: Optional[List[dict]] = Field(None, description="批量插入的文档列表")
options: Optional[dict] = Field(None, description="额外选项")
class TicketExecuteResponse(BaseModel):
"""工单执行响应模型(API 返回)。"""
id: int = Field(..., description="工单ID")
title: str = Field(..., description="工单标题")
status: str = Field(..., description="工单状态")
db_type: str = Field(..., description="数据库类型")
execute_result: Optional[str] = Field(None, description="执行结果摘要")
execute_time: Optional[str] = Field(None, description="执行时间")
execution_report: Optional[ExecutionReportResponse] = Field(
None, description="详细执行报告"
)
message: Optional[str] = Field(None, description="提示消息")
-21
View File
@@ -1,21 +0,0 @@
"""影响分析 Pydantic Schemas。"""
from typing import List, Optional
from pydantic import BaseModel
class ImpactAnalysisRequest(BaseModel):
instance_id: int
database: str
sql_content: str
class ImpactAnalysisResponse(BaseModel):
sql_type: str
estimated_rows: int
estimated_duration_ms: int
table_size_mb: float
table_row_count: int
index_usage: List[str]
has_full_scan: bool
risk_level: str # low / medium / high / critical
warnings: List[str]
-74
View File
@@ -1,74 +0,0 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
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):
name: str = Field(..., min_length=1, max_length=100)
db_type: Literal["mysql", "postgres", "redis", "mongo"]
host: str = Field(..., min_length=1)
port: int = Field(..., ge=1, le=65535)
username: str = Field(..., min_length=1)
password: str = Field(..., min_length=1)
database: Optional[str] = None
env: Literal["production", "staging", "development"] = "development"
description: Optional[str] = None
access_mode: Literal["readwrite", "read", "write"] = "readwrite"
workflow_id: Optional[int] = Field(None, description="关联的审批流程ID")
excluded_databases: Optional[List[str]] = None
mask_fields: Optional[List[MaskFieldRule]] = None
class InstanceUpdate(BaseModel):
name: Optional[str] = None
host: Optional[str] = None
port: Optional[int] = Field(default=None, ge=1, le=65535)
username: Optional[str] = None
password: Optional[str] = None
database: Optional[str] = None
env: Optional[Literal["production", "staging", "development"]] = None
description: Optional[str] = None
access_mode: Optional[Literal["readwrite", "read", "write"]] = None
workflow_id: Optional[int] = Field(default=None, description="关联的审批流程ID,设为0清除关联")
excluded_databases: Optional[List[str]] = None
mask_fields: Optional[List[MaskFieldRule]] = None
class InstanceResponse(BaseModel):
id: int
name: str
db_type: str
host: str
port: int
username: str
database: Optional[str] = None
env: str
description: Optional[str] = None
status: str
version: Optional[str] = None
access_mode: str = "readwrite"
workflow_id: Optional[int] = None
workflow_name: Optional[str] = None
excluded_databases: Optional[List[str]] = None
mask_fields: Optional[List[dict]] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ConnectionTestResult(BaseModel):
success: bool
message: str
version: Optional[str] = None
-78
View File
@@ -1,78 +0,0 @@
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel, ConfigDict, Field, field_validator
class InstanceGroupCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
db_type: str = Field(default="all", pattern=r"^(mysql|redis|pg|mongo|all)$")
allowed_databases: Optional[List[str]] = Field(
default=None,
description="允许访问的数据库名列表,为空表示关联实例的所有库",
)
# Legacy field kept for backward compatibility
permissions: Optional[List[str]] = None
is_active: bool = True
# New: instance ids by access_type
readonly_instance_ids: List[int] = Field(default_factory=list)
write_instance_ids: List[int] = Field(default_factory=list)
class InstanceGroupUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
description: Optional[str] = None
db_type: Optional[str] = Field(default=None, pattern=r"^(mysql|redis|pg|mongo|all)$")
allowed_databases: Optional[List[str]] = Field(
default=None,
description="允许访问的数据库名列表,为空表示关联实例的所有库",
)
# Legacy field kept for backward compatibility
permissions: Optional[List[str]] = None
is_active: Optional[bool] = None
# New: instance ids by access_type
readonly_instance_ids: Optional[List[int]] = None
write_instance_ids: Optional[List[int]] = None
class InstanceGroupResponse(BaseModel):
id: int
name: str
description: Optional[str] = None
db_type: str
# Legacy field
permissions: Optional[List[str]] = None
# New fields
allowed_databases: Optional[List[str]] = None
is_active: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
readonly_instance_count: int = 0
write_instance_count: int = 0
user_count: int = 0
model_config = ConfigDict(from_attributes=True)
class InstanceGroupDetailResponse(InstanceGroupResponse):
readonly_instances: List[dict] = []
write_instances: List[dict] = []
users: List[dict] = []
class InstanceAssignRequest(BaseModel):
instance_ids: List[int] = Field(..., min_length=1)
access_type: str = Field(default="readonly", pattern=r"^(readonly|write)$")
class InstanceRemoveRequest(BaseModel):
instance_ids: List[int] = Field(..., min_length=1)
class UserAssignRequest(BaseModel):
user_ids: List[int] = Field(..., min_length=1)
permissions: Optional[List[str]] = None
class UserGroupUpdateRequest(BaseModel):
group_ids: List[int] = Field(default_factory=list)
-46
View File
@@ -1,46 +0,0 @@
from datetime import datetime
from typing import Optional, Dict, Any
from pydantic import BaseModel, ConfigDict, Field
class LdapConfigCreate(BaseModel):
ldap_host: str = Field(..., min_length=1, max_length=200)
ldap_port: int = Field(default=389, ge=1, le=65535)
use_ldaps: bool = False
admin_dn: str = Field(..., min_length=1, max_length=500)
admin_password: Optional[str] = Field(default=None, max_length=500)
search_filter: Optional[str] = Field(default=None, max_length=500)
base_dn: str = Field(..., min_length=1, max_length=500)
user_mapping: Optional[Dict[str, Any]] = None
class LdapConfigUpdate(BaseModel):
ldap_host: Optional[str] = Field(default=None, min_length=1, max_length=200)
ldap_port: Optional[int] = Field(default=None, ge=1, le=65535)
use_ldaps: Optional[bool] = None
admin_dn: Optional[str] = Field(default=None, min_length=1, max_length=500)
admin_password: Optional[str] = Field(default=None, min_length=1, max_length=500)
search_filter: Optional[str] = Field(default=None, max_length=500)
base_dn: Optional[str] = Field(default=None, min_length=1, max_length=500)
user_mapping: Optional[Dict[str, Any]] = None
class LdapConfigResponse(BaseModel):
id: int
ldap_host: str
ldap_port: int
use_ldaps: bool
admin_dn: str
admin_password: str # masked in endpoint
search_filter: Optional[str] = None
base_dn: str
user_mapping: Optional[Dict[str, Any]] = None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class LdapTestRequest(BaseModel):
test_user: str = Field(..., min_length=1)
test_password: str = Field(..., min_length=1)
-59
View File
@@ -1,59 +0,0 @@
"""数据脱敏规则 Pydantic Schemas — 用于 MaskingRule CRUD 操作。"""
from datetime import datetime
from typing import Optional, Dict, Any, List
from pydantic import BaseModel, ConfigDict, Field
class MaskingRuleBase(BaseModel):
"""脱敏规则基础 Schema。"""
database: str = Field(..., min_length=1, max_length=100, description="数据库名称")
table_name: str = Field(..., min_length=1, max_length=100, description="表名,* 表示所有表")
column_name: str = Field(..., min_length=1, max_length=100, description="列名,* 表示所有列")
mask_type: str = Field(..., pattern=r"^(full|partial|hash|truncate|regex)$")
mask_config: Optional[Dict[str, Any]] = Field(default=None, description="脱敏配置(JSON)")
is_enabled: bool = Field(default=True, description="是否启用")
class MaskingRuleCreate(MaskingRuleBase):
"""创建脱敏规则请求 Schema。"""
pass
class MaskingRuleUpdate(BaseModel):
"""更新脱敏规则请求 Schema。"""
database: Optional[str] = Field(default=None, max_length=100)
table_name: Optional[str] = Field(default=None, max_length=100)
column_name: Optional[str] = Field(default=None, max_length=100)
mask_type: Optional[str] = Field(default=None, pattern=r"^(full|partial|hash|truncate|regex)$")
mask_config: Optional[Dict[str, Any]] = Field(default=None, description="脱敏配置(JSON)")
is_enabled: Optional[bool] = Field(default=None, description="是否启用")
class MaskingRuleResponse(MaskingRuleBase):
"""脱敏规则响应 Schema。"""
id: int
instance_id: int
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class MaskingRuleBatchCreate(BaseModel):
"""批量创建脱敏规则请求 Schema。"""
rules: List[MaskingRuleCreate] = Field(..., min_length=1, max_length=100)
class MaskingRuleListResponse(BaseModel):
"""脱敏规则列表响应 Schema。"""
total: int
items: List[MaskingRuleResponse]
class MaskingRuleQueryParams(BaseModel):
"""脱敏规则查询参数 Schema。"""
database: Optional[str] = None
table_name: Optional[str] = None
keyword: Optional[str] = None
page: int = Field(default=1, ge=1)
page_size: int = Field(default=20, ge=1, le=100)
-29
View File
@@ -1,29 +0,0 @@
from datetime import datetime
from typing import Optional, Dict, Any
from pydantic import BaseModel, ConfigDict, Field
class NotificationChannelCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
channel_type: str = Field(..., pattern=r"^(dingtalk|webhook_feishu|email)$")
config: Dict[str, Any]
is_enabled: bool = True
class NotificationChannelUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
channel_type: Optional[str] = Field(default=None, pattern=r"^(dingtalk|webhook_feishu|email)$")
config: Optional[Dict[str, Any]] = None
is_enabled: Optional[bool] = None
class NotificationChannelResponse(BaseModel):
id: int
name: str
channel_type: str
config: Dict[str, Any] # passwords masked in endpoint
is_enabled: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
-49
View File
@@ -1,49 +0,0 @@
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class QueryRequest(BaseModel):
"""统一查询请求模型。
支持 MySQL、PostgreSQL、Redis、MongoDB 四种数据库的查询请求。
Attributes:
instance_id: 数据源实例ID
database: 目标数据库名/Redis DB号/MongoDB database
sql: 查询语句(SQL/Redis命令/MongoDB查询JSON)
options: 额外查询选项
"""
instance_id: int
database: Optional[str] = None
sql: str = Field(..., min_length=1, description="查询语句(SQL/Redis命令/MongoDB查询JSON)")
options: Optional[Dict[str, Any]] = Field(default=None, description="额外选项(如 limit, offset 等)")
class UnifiedQueryResult(BaseModel):
"""统一查询结果模型。
兼容 MySQL、PostgreSQL、Redis、MongoDB 四种数据库的返回格式。
SQL 数据库返回标准表格格式,Redis/MongoDB 同时返回格式化表格和原始结果。
Attributes:
columns: SQL表格列名列表
rows: SQL表格行数据列表(字典格式)
raw_result: Redis/MongoDB原始结果
total: 总行数/总文档数
elapsed_ms: 执行耗时(毫秒)
truncated: 是否因超过行数限制而截断
db_type: 数据库类型(mysql/postgres/redis/mongo)
query_type: 查询类型(SELECT/GET/find等)
"""
columns: List[str] = Field(default=[], description="SQL表格列名")
rows: List[Dict[str, Any]] = Field(default=[], description="SQL表格行数据")
raw_result: Any = Field(default=None, description="Redis/MongoDB原始结果")
total: int = Field(default=0, description="总行数")
elapsed_ms: int = Field(default=0, description="执行耗时(毫秒)")
truncated: bool = Field(default=False, description="是否截断")
db_type: str = Field(default="", description="数据库类型")
query_type: str = Field(default="", description="查询类型")
# 保留旧名兼容
QueryResult = UnifiedQueryResult
-33
View File
@@ -1,33 +0,0 @@
"""回滚操作 Pydantic Schemas — 用于回滚 SQL 生成和预览。"""
from typing import Optional
from pydantic import BaseModel, Field
class RollbackPreviewRequest(BaseModel):
"""预览回滚 SQL 请求 Schema。"""
instance_id: int = Field(..., description="数据源实例ID")
database: str = Field(..., min_length=1, max_length=100, description="数据库名称")
sql: str = Field(..., min_length=1, description="原始 DML 语句")
class RollbackPreviewResponse(BaseModel):
"""预览回滚 SQL 响应 Schema。"""
original_sql: str = Field(..., description="原始 SQL")
rollback_sql: str = Field(..., description="生成的回滚 SQL")
sql_type: Optional[str] = Field(default=None, description="SQL 类型 (INSERT/UPDATE/DELETE)")
warning: Optional[str] = Field(default=None, description="警告信息")
class RollbackGenerateRequest(BaseModel):
"""为工单生成回滚 SQL 请求 Schema。"""
auto_save: bool = Field(default=True, description="是否自动保存到工单")
class RollbackGenerateResponse(BaseModel):
"""生成回滚 SQL 响应 Schema。"""
ticket_id: int = Field(..., description="工单ID")
original_sql: str = Field(..., description="原始 SQL")
rollback_sql: str = Field(..., description="生成的回滚 SQL")
sql_type: Optional[str] = Field(default=None, description="SQL 类型")
saved: bool = Field(default=False, description="是否已保存到工单")
warning: Optional[str] = Field(default=None, description="警告信息")
-82
View File
@@ -1,82 +0,0 @@
"""定时任务 Schema — 基于 SQL 的自动化任务。"""
from typing import Optional, List
from pydantic import BaseModel, Field
from datetime import datetime
from enum import Enum
class TaskSqlType(str, Enum):
insert = "insert"
update = "update"
delete = "delete"
class ScheduledTaskCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
task_sql_type: TaskSqlType
instance_id: int
target_db: Optional[str] = None
target_table: Optional[str] = None
sql_content: str = Field(..., min_length=1)
cron_expression: str = Field(..., min_length=1)
is_enabled: bool = True
class ScheduledTaskUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
task_sql_type: Optional[TaskSqlType] = None
instance_id: Optional[int] = None
target_db: Optional[str] = None
target_table: Optional[str] = None
sql_content: Optional[str] = Field(None, min_length=1)
cron_expression: Optional[str] = Field(None, min_length=1)
is_enabled: Optional[bool] = None
class ScheduledTaskResponse(BaseModel):
id: int
name: str
description: Optional[str]
task_sql_type: str
instance_id: int
instance_name: Optional[str] = None
instance_db_type: Optional[str] = None
target_db: Optional[str]
target_table: Optional[str]
sql_content: str
cron_expression: str
is_enabled: bool
last_run_at: Optional[datetime]
last_run_status: Optional[str]
last_run_message: Optional[str]
next_run_at: Optional[datetime]
run_count: int
created_by: Optional[int]
created_at: datetime
class Config:
from_attributes = True
class ScheduledTaskLogResponse(BaseModel):
id: int
task_id: int
status: str
sql_content: Optional[str]
affected_rows: int
duration_ms: Optional[int]
error_message: Optional[str]
started_at: datetime
finished_at: Optional[datetime]
class Config:
from_attributes = True
class TaskRunResponse(BaseModel):
success: bool
message: str
affected_rows: int = 0
duration_ms: int = 0
-39
View File
@@ -1,39 +0,0 @@
"""系统设置 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]
-60
View File
@@ -1,60 +0,0 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, Literal
class TicketCreate(BaseModel):
title: str = Field(..., min_length=5, max_length=200)
db_type: Literal["mysql", "postgres", "redis", "mongo"]
instance_id: int
target_database: str = Field(..., min_length=1)
sql_content: str = Field(..., min_length=1)
sql_type: Optional[str] = None
rollback_sql: Optional[str] = None
reason: str = Field(..., min_length=5)
priority: Literal["high", "medium", "low"] = "medium"
exec_strategy: Optional[str] = "stop_on_error"
scheduled_at: Optional[datetime] = None
class TicketReview(BaseModel):
action: Literal["approve", "reject"]
comment: str = Field(default="", max_length=500)
class TicketResponse(BaseModel):
id: int
ticket_no: Optional[str] = None
title: str
db_type: str
instance_id: int
instance_name: Optional[str] = None
target_database: str
sql_content: str
rollback_sql: Optional[str] = None
status: str
priority: str
risk_level: Optional[str] = "low"
workflow_id: Optional[int] = None
applicant_id: int
applicant_name: Optional[str] = None
approver_id: Optional[int] = None
approver_name: Optional[str] = None
current_approver_name: Optional[str] = None
reason: Optional[str] = None
audit_result: Optional[str] = None
execute_result: Optional[str] = None
create_time: datetime
review_time: Optional[datetime] = None
execute_time: Optional[datetime] = None
scheduled_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class TicketListParams(BaseModel):
page: int = 1
page_size: int = 10
status: Optional[str] = None
db_type: Optional[str] = None
keyword: Optional[str] = None
-43
View File
@@ -1,43 +0,0 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional
class UserCreate(BaseModel):
username: str = Field(..., min_length=2, max_length=50)
display_name: str = Field(..., min_length=1, max_length=100)
password: str = Field(..., min_length=6)
email: Optional[str] = None
phone: Optional[str] = None
role: str = Field(default="developer", pattern="^(admin|dba|developer|auditor)$")
class UserUpdate(BaseModel):
display_name: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
role: Optional[str] = Field(default=None, pattern="^(admin|dba|developer|auditor)$")
is_active: Optional[bool] = None
class UserResponse(BaseModel):
id: int
username: str
display_name: str
email: Optional[str] = None
phone: Optional[str] = None
role: str
is_active: bool
last_login: Optional[datetime] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class PasswordChange(BaseModel):
old_password: str
new_password: str = Field(..., min_length=6)
class PasswordReset(BaseModel):
new_password: str = Field(..., min_length=6)
-79
View File
@@ -1,79 +0,0 @@
"""审批流程 Pydantic Schemas。"""
from typing import List, Optional
from pydantic import BaseModel, ConfigDict
from datetime import datetime
class WorkflowStepCreate(BaseModel):
step_number: int
name: str
step_type: str = "serial" # serial / parallel / any_one
approver_role: Optional[str] = None
approver_ids: Optional[str] = None # JSON list
is_required: bool = True
timeout_hours: int = 24
auto_approve_on_timeout: bool = False
class WorkflowStepResponse(WorkflowStepCreate):
id: int
workflow_id: int
model_config = ConfigDict(from_attributes=True)
class WorkflowCreate(BaseModel):
name: str
description: Optional[str] = None
is_default: bool = False
steps: List[WorkflowStepCreate]
class WorkflowUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
steps: Optional[List[WorkflowStepCreate]] = None
class WorkflowResponse(BaseModel):
id: int
name: str
description: Optional[str]
is_default: bool
is_active: bool
created_at: Optional[datetime]
steps: List[WorkflowStepResponse]
model_config = ConfigDict(from_attributes=True)
class TicketApprovalResponse(BaseModel):
id: int
ticket_id: int
step_number: int
step_name: Optional[str]
approver_id: Optional[int]
approver_role: Optional[str]
approver_name: Optional[str] = None
action: str
comment: Optional[str]
created_at: Optional[datetime]
updated_at: Optional[datetime]
class ApprovalProgressResponse(BaseModel):
ticket_id: int
current_step: int
total_steps: int
status: str
steps: List[dict]
class ApprovalActionRequest(BaseModel):
comment: Optional[str] = ""
class TransferRequest(BaseModel):
to_user_id: int
comment: Optional[str] = ""
View File
Whitespace-only changes.
-790
View File
@@ -1,790 +0,0 @@
"""SQL Audit Engine — rule-based SQL review for tickets.
Checks SQL against configurable rules and returns structured results:
- level: "error" (must fix), "warning" (should review), "info" (FYI)
- rule: rule identifier
- message: human-readable description
Supports:
- MySQL / PostgreSQL SQL audit
- Redis command audit
- MongoDB operation audit
- Dynamic rule loading from configuration
- Risk level assessment (LOW / MEDIUM / HIGH / CRITICAL)
- Weighted scoring algorithm
"""
import json
import logging
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Protocol
logger = logging.getLogger(__name__)
class Severity(str, Enum):
ERROR = "error"
WARNING = "warning"
INFO = "info"
class RiskLevel(str, Enum):
"""风险等级评估。"""
LOW = "low" # 低风险,一般建议
MEDIUM = "medium" # 中风险,需要关注
HIGH = "high" # 高风险,强烈建议修改
CRITICAL = "critical" # 严重风险,必须修改
@dataclass
class AuditFinding:
rule: str
level: Severity
message: str
risk_level: RiskLevel = RiskLevel.MEDIUM # 新增:风险等级
suggestion: str = "" # 新增:修改建议
category: str = "general" # 新增:规则分类
def to_dict(self) -> dict:
return {
"rule": self.rule,
"level": self.level.value,
"message": self.message,
"risk_level": self.risk_level.value,
"suggestion": self.suggestion,
"category": self.category,
}
@dataclass
class AuditReport:
sql: str
findings: List[AuditFinding] = field(default_factory=list)
db_type: str = "mysql"
risk_level: RiskLevel = RiskLevel.LOW
risk_score: int = 0
@property
def passed(self) -> bool:
return not any(f.level == Severity.ERROR for f in self.findings)
@property
def has_warnings(self) -> bool:
return any(f.level == Severity.WARNING for f in self.findings)
@property
def score(self) -> int:
"""加权评分算法: 100 = clean, error扣10-30分, warning扣5-15分, info扣1-3分。
根据风险等级调整扣分权重:
- CRITICAL: error扣30分
- HIGH: error扣20分, warning扣15分
- MEDIUM: error扣10分, warning扣5分
- LOW: info扣1-3分
"""
s = 100
for f in self.findings:
if f.risk_level == RiskLevel.CRITICAL:
if f.level == Severity.ERROR:
s -= 30
elif f.level == Severity.WARNING:
s -= 15
elif f.level == Severity.INFO:
s -= 5
elif f.risk_level == RiskLevel.HIGH:
if f.level == Severity.ERROR:
s -= 20
elif f.level == Severity.WARNING:
s -= 10
elif f.level == Severity.INFO:
s -= 3
elif f.risk_level == RiskLevel.MEDIUM:
if f.level == Severity.ERROR:
s -= 10
elif f.level == Severity.WARNING:
s -= 5
elif f.level == Severity.INFO:
s -= 2
else: # LOW
if f.level == Severity.ERROR:
s -= 5
elif f.level == Severity.WARNING:
s -= 3
elif f.level == Severity.INFO:
s -= 1
return max(0, s)
def to_dict(self) -> dict:
return {
"passed": self.passed,
"score": self.score,
"db_type": self.db_type,
"risk_level": self.risk_level.value,
"risk_score": self.risk_score,
"findings": [f.to_dict() for f in self.findings],
}
def calculate_risk_level(self) -> RiskLevel:
"""根据发现的问题计算整体风险等级。"""
if any(f.level == Severity.ERROR and f.risk_level == RiskLevel.CRITICAL for f in self.findings):
return RiskLevel.CRITICAL
if any(f.level == Severity.ERROR and f.risk_level == RiskLevel.HIGH for f in self.findings):
return RiskLevel.HIGH
if any(f.level == Severity.ERROR for f in self.findings):
return RiskLevel.HIGH
critical_warnings = sum(
1 for f in self.findings
if f.level == Severity.WARNING and f.risk_level in (RiskLevel.CRITICAL, RiskLevel.HIGH)
)
if critical_warnings >= 2:
return RiskLevel.HIGH
total_warnings = sum(1 for f in self.findings if f.level == Severity.WARNING)
if total_warnings >= 3:
return RiskLevel.MEDIUM
if self.findings:
return RiskLevel.LOW
return RiskLevel.LOW
# ===== 规则配置协议 =====
class AuditRule(Protocol):
"""审核规则协议。"""
rule_code: str
category: str
severity: Severity
risk_level: RiskLevel
description: str
def check(self, content: str, upper: str, report: AuditReport) -> None:
"""执行规则检查。"""
...
# ===== 具体规则类 =====
@dataclass
class SQLAuditRule:
"""SQL审核规则基类。"""
rule_code: str
category: str
severity: Severity
risk_level: RiskLevel
description: str
suggestion: str = ""
enabled: bool = True
def check(self, content: str, upper: str, report: AuditReport) -> None:
pass
class PatternAuditRule(SQLAuditRule):
"""基于正则表达式模式的审核规则。"""
def __init__(
self,
rule_code: str,
category: str,
severity: Severity,
risk_level: RiskLevel,
description: str,
suggestion: str,
pattern: str,
exclude_pattern: Optional[str] = None,
):
super().__init__(
rule_code=rule_code,
category=category,
severity=severity,
risk_level=risk_level,
description=description,
suggestion=suggestion,
)
self.pattern = re.compile(pattern, re.IGNORECASE)
self.exclude_pattern = re.compile(exclude_pattern, re.IGNORECASE) if exclude_pattern else None
def check(self, content: str, upper: str, report: AuditReport) -> None:
if not self.enabled:
return
if self.pattern.search(upper):
if self.exclude_pattern and self.exclude_pattern.search(upper):
return
report.findings.append(AuditFinding(
rule=self.rule_code,
level=self.severity,
message=self.description,
risk_level=self.risk_level,
suggestion=self.suggestion,
category=self.category,
))
# ===== MySQL 审核规则集 =====
MYSQL_AUDIT_RULES: List[SQLAuditRule] = [
PatternAuditRule(
rule_code="DROP_TABLE_DANGER",
category="ddl",
severity=Severity.WARNING,
risk_level=RiskLevel.CRITICAL,
description="DROP TABLE 操作不可逆,请确认已备份",
suggestion="使用 DROP TABLE IF EXISTS 并在操作前备份数据",
pattern=r"\bDROP\s+TABLE\b",
),
PatternAuditRule(
rule_code="TRUNCATE_DANGER",
category="ddl",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="TRUNCATE 会清空表数据且不可回滚",
suggestion="使用 DELETE 配合 WHERE 子句替代,或使用事务",
pattern=r"\bTRUNCATE\b",
),
PatternAuditRule(
rule_code="UPDATE_NO_WHERE",
category="dml",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="UPDATE 缺少 WHERE 条件,将更新全表",
suggestion="添加 WHERE 条件限制更新范围",
pattern=r"\bUPDATE\s+\w+\s+SET\b(?!.*\bWHERE\b)",
),
PatternAuditRule(
rule_code="DELETE_NO_WHERE",
category="dml",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="DELETE 缺少 WHERE 条件,将删除全表数据",
suggestion="添加 WHERE 条件限制删除范围",
pattern=r"\bDELETE\s+FROM\s+\w+\b(?!.*\bWHERE\b)",
),
PatternAuditRule(
rule_code="SELECT_STAR",
category="query",
severity=Severity.INFO,
risk_level=RiskLevel.LOW,
description="SELECT * 可能返回过多列,建议明确指定字段",
suggestion="明确指定需要的字段名,减少网络传输和内存使用",
pattern=r"SELECT\s+\*(?:\s|,|\))",
),
PatternAuditRule(
rule_code="DML_WITH_LIMIT",
category="dml",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="UPDATE/DELETE 使用 LIMIT 可能导致部分数据变更",
suggestion="使用 WHERE 条件精确控制影响范围,避免 LIMIT",
pattern=r"\b(UPDATE|DELETE)\b.*\bLIMIT\b",
),
PatternAuditRule(
rule_code="LARGE_IN_CLAUSE",
category="query",
severity=Severity.WARNING,
risk_level=RiskLevel.MEDIUM,
description="IN 子句包含过多值,性能较差",
suggestion="使用临时表或 JOIN 替代大 IN 子句",
pattern=r"\bIN\s*\([^)]{500,}\)",
),
PatternAuditRule(
rule_code="FULL_LIKE_SCAN",
category="query",
severity=Severity.INFO,
risk_level=RiskLevel.LOW,
description="LIKE '%...' 无法使用索引,大数据量下性能较差",
suggestion="考虑使用全文索引或前缀匹配 LIKE 'prefix%'",
pattern=r"\bLIKE\s+'%[^']*'",
),
PatternAuditRule(
rule_code="JOIN_NO_ON",
category="query",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="JOIN 缺少 ON 条件,可能产生笛卡尔积",
suggestion="添加 ON 条件明确连接关系",
pattern=r"\bJOIN\b",
exclude_pattern=r"\b(NATURAL\s+JOIN|ON\b|CROSS\s+JOIN)",
),
PatternAuditRule(
rule_code="MULTI_STATEMENT",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="单次提交不支持多条 SQL 语句(分号分隔)",
suggestion="拆分为多个工单,每条语句单独提交",
pattern=r";.*\b(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)",
),
PatternAuditRule(
rule_code="SLEEP_INJECTION",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="检测到时间延迟函数(SLEEP/BENCHMARK),可能存在注入风险",
suggestion="移除时间延迟函数",
pattern=r"\b(SLEEP|BENCHMARK)\s*\(",
),
PatternAuditRule(
rule_code="INTO_OUTFILE",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="SELECT INTO OUTFILE/DUMPFILE 被禁止(文件写入风险)",
suggestion="使用客户端工具导出数据",
pattern=r"\bINTO\s+(OUTFILE|DUMPFILE)\b",
),
PatternAuditRule(
rule_code="PROCEDURE_CALL",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.HIGH,
description="存储过程/函数调用可能存在安全风险",
suggestion="避免使用存储过程,改用标准 SQL",
pattern=r"\b(CALL|EXEC|EXECUTE)\b",
),
PatternAuditRule(
rule_code="UNION_INJECTION",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="检测到 UNION 注入模式",
suggestion="检查 SQL 来源合法性",
pattern=r"\bUNION\s+(ALL\s+)?SELECT\b",
),
]
# ===== PostgreSQL 审核规则集 =====
POSTGRES_AUDIT_RULES: List[SQLAuditRule] = [
PatternAuditRule(
rule_code="PG_DROP_TABLE",
category="ddl",
severity=Severity.WARNING,
risk_level=RiskLevel.CRITICAL,
description="DROP TABLE 操作不可逆,请确认已备份",
suggestion="使用 DROP TABLE IF EXISTS 并在操作前备份数据",
pattern=r"\bDROP\s+TABLE\b",
),
PatternAuditRule(
rule_code="PG_TRUNCATE",
category="ddl",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="TRUNCATE 会清空表数据且不可回滚",
suggestion="使用 DELETE 配合 WHERE 子句或事务",
pattern=r"\bTRUNCATE\b",
),
PatternAuditRule(
rule_code="PG_UPDATE_NO_WHERE",
category="dml",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="UPDATE 缺少 WHERE 条件,将更新全表",
suggestion="添加 WHERE 条件限制更新范围",
pattern=r"\bUPDATE\s+\w+\s+SET\b(?!.*\bWHERE\b)",
),
PatternAuditRule(
rule_code="PG_DELETE_NO_WHERE",
category="dml",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="DELETE 缺少 WHERE 条件,将删除全表数据",
suggestion="添加 WHERE 条件限制删除范围",
pattern=r"\bDELETE\s+FROM\s+\w+\b(?!.*\bWHERE\b)",
),
PatternAuditRule(
rule_code="PG_SELECT_STAR",
category="query",
severity=Severity.INFO,
risk_level=RiskLevel.LOW,
description="SELECT * 可能返回过多列",
suggestion="明确指定需要的字段名",
pattern=r"SELECT\s+\*(?:\s|,|\))",
),
PatternAuditRule(
rule_code="PG_VACUUM_DANGER",
category="maintenance",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="VACUUM FULL 会锁定表并重建",
suggestion="在低峰期执行 VACUUM FULL,或使用普通 VACUUM",
pattern=r"\bVACUUM\s+FULL\b",
),
PatternAuditRule(
rule_code="PG_LOCK_TABLE",
category="locking",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="LOCK TABLE 会显式锁定表,可能导致阻塞",
suggestion="使用事务隔离级别替代显式锁",
pattern=r"\bLOCK\s+TABLE\b",
),
PatternAuditRule(
rule_code="PG_CREATE_EXTENSION",
category="ddl",
severity=Severity.WARNING,
risk_level=RiskLevel.MEDIUM,
description="CREATE EXTENSION 需要超级用户权限",
suggestion="确认扩展安全性并由 DBA 执行",
pattern=r"\bCREATE\s+EXTENSION\b",
),
PatternAuditRule(
rule_code="PG_COPY_FROM_FILE",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="COPY FROM FILE 存在文件系统访问风险",
suggestion="使用 COPY FROM STDIN 或客户端工具导入",
pattern=r"\bCOPY\b.*\bFROM\s+(?!STDIN)",
),
PatternAuditRule(
rule_code="PG_MULTI_STATEMENT",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="单次提交不支持多条 SQL 语句",
suggestion="拆分为多个工单提交",
pattern=r";.*\b(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)",
),
PatternAuditRule(
rule_code="PG_SLEEP_INJECTION",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="检测到时间延迟函数(PG_SLEEP),可能存在注入风险",
suggestion="移除时间延迟函数",
pattern=r"\bPG_SLEEP\s*\(",
),
]
# ===== Redis 审核规则集 =====
REDIS_AUDIT_RULES: List[SQLAuditRule] = [
PatternAuditRule(
rule_code="REDIS_FLUSH",
category="danger",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="FLUSHDB/FLUSHALL 会清空所有数据",
suggestion="确认备份数据并在维护窗口执行",
pattern=r"\b(FLUSHDB|FLUSHALL)\b",
),
PatternAuditRule(
rule_code="REDIS_KEY_DELETE",
category="dml",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="批量删除键(DEL KEYS)风险较高",
suggestion="使用 UNLINK 异步删除,或使用 SCAN 分批删除",
pattern=r"\bDEL\s+(?!\w+\b)",
),
PatternAuditRule(
rule_code="REDIS_SHUTDOWN",
category="danger",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="SHUTDOWN 会关闭 Redis 服务器",
suggestion="通过运维面板操作服务器",
pattern=r"\bSHUTDOWN\b",
),
PatternAuditRule(
rule_code="REDIS_CONFIG_SET",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="CONFIG SET 可修改服务器配置",
suggestion="通过配置文件修改并由 DBA 审核",
pattern=r"\bCONFIG\s+SET\b",
),
PatternAuditRule(
rule_code="REDIS_REPLICAOF",
category="danger",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="REPLICAOF/SLAVEOF 会改变主从关系",
suggestion="通过运维面板管理主从配置",
pattern=r"\b(REPLICAOF|SLAVEOF)\b",
),
PatternAuditRule(
rule_code="REDIS_DEBUG",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="DEBUG 命令可能导致数据损坏或信息泄露",
suggestion="禁止在生产环境使用 DEBUG 命令",
pattern=r"\bDEBUG\b",
),
]
# ===== MongoDB 审核规则集 =====
MONGODB_AUDIT_RULES: List[SQLAuditRule] = [
PatternAuditRule(
rule_code="MONGO_DROP_COLLECTION",
category="ddl",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="drop 操作会删除整个集合",
suggestion="使用 deleteMany 删除文档保留集合",
pattern=r'"operation"\s*:\s*"drop"',
),
PatternAuditRule(
rule_code="MONGO_DROP_INDEX",
category="ddl",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="dropIndex 会影响查询性能",
suggestion="确认索引不再使用后再删除",
pattern=r'"operation"\s*:\s*"dropIndex"',
),
PatternAuditRule(
rule_code="MONGO_DELETE_NO_FILTER",
category="dml",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="deleteMany 缺少 filter 将删除所有文档",
suggestion="添加 filter 条件限制删除范围",
pattern=r'"operation"\s*:\s*"deleteMany"(?!.*"filter"\s*:\s*\{[^}])',
),
PatternAuditRule(
rule_code="MONGO_UPDATE_NO_FILTER",
category="dml",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="updateMany 缺少 filter 将更新所有文档",
suggestion="添加 filter 条件限制更新范围",
pattern=r'"operation"\s*:\s*"updateMany"(?!.*"filter"\s*:\s*\{[^}])',
),
PatternAuditRule(
rule_code="MONGO_UNSET_FIELD",
category="dml",
severity=Severity.WARNING,
risk_level=RiskLevel.MEDIUM,
description="$unset 会删除字段,数据不可恢复",
suggestion="确认字段确实不再需要",
pattern=r'"\$unset"',
),
PatternAuditRule(
rule_code="MONGO_RENAME_COLLECTION",
category="ddl",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="renameCollection 会重命名集合",
suggestion="确认新名称不会与现有集合冲突",
pattern=r'"operation"\s*:\s*"renameCollection"',
),
PatternAuditRule(
rule_code="MONGO_JAVASCRIPT",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="$where/$function 可执行任意 JavaScript",
suggestion="使用标准查询操作符替代",
pattern=r'"\$(where|function|accumulator)"',
),
PatternAuditRule(
rule_code="MONGO_OUT_STAGE",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="aggregate $out/$merge 阶段会写入数据",
suggestion="使用 find 查询替代",
pattern=r'"\$(out|merge)"',
),
]
# ===== 规则注册表 =====
RULE_REGISTRY: Dict[str, List[SQLAuditRule]] = {
"mysql": MYSQL_AUDIT_RULES,
"postgres": POSTGRES_AUDIT_RULES,
"postgresql": POSTGRES_AUDIT_RULES,
"redis": REDIS_AUDIT_RULES,
"mongo": MONGODB_AUDIT_RULES,
}
class SQLAuditEngine:
"""增强的规则审核引擎。
支持多数据库类型的规则审核,动态规则加载,风险等级评估。
Attributes:
env: 环境类型(development/production)
db_type: 数据库类型
custom_rules: 自定义规则列表
"""
def __init__(self, env: str = "development", db_type: str = "mysql"):
self.env = env
self.db_type = db_type.lower()
self.custom_rules: List[SQLAuditRule] = []
def add_custom_rule(self, rule: SQLAuditRule) -> None:
"""添加自定义审核规则。
Args:
rule: 自定义规则对象
"""
self.custom_rules.append(rule)
logger.info("添加自定义规则: %s", rule.rule_code)
def load_rules_from_config(self, config: List[Dict[str, Any]]) -> None:
"""从配置加载自定义规则。
配置格式:
[
{
"rule_code": "RULE_NAME",
"category": "security",
"severity": "error",
"risk_level": "critical",
"description": "规则描述",
"suggestion": "修改建议",
"pattern": r"正则表达式",
"exclude_pattern": r"排除正则" (可选)
}
]
Args:
config: 规则配置列表
"""
for cfg in config:
try:
severity = Severity(cfg.get("severity", "warning"))
risk_level = RiskLevel(cfg.get("risk_level", "medium"))
rule = PatternAuditRule(
rule_code=cfg["rule_code"],
category=cfg.get("category", "custom"),
severity=severity,
risk_level=risk_level,
description=cfg["description"],
suggestion=cfg.get("suggestion", ""),
pattern=cfg["pattern"],
exclude_pattern=cfg.get("exclude_pattern"),
)
self.custom_rules.append(rule)
logger.info("从配置加载规则: %s", rule.rule_code)
except (KeyError, re.error) as e:
logger.error("加载规则配置失败: %s, error: %s", cfg.get("rule_code", "unknown"), e)
def audit(self, sql: str) -> AuditReport:
"""执行审核。
根据 db_type 选择对应的规则集,执行所有规则检查。
Args:
sql: 要审核的 SQL/命令/JSON
Returns:
AuditReport: 审核报告
"""
report = AuditReport(sql=sql, db_type=self.db_type)
clean = sql.strip()
upper = clean.upper()
# 空内容检查
if not clean:
report.findings.append(AuditFinding(
rule="EMPTY_CONTENT",
level=Severity.ERROR,
message="审核内容不能为空",
risk_level=RiskLevel.CRITICAL,
category="validation",
))
return report
# 获取数据库类型对应的规则
rules = RULE_REGISTRY.get(self.db_type, [])
# 执行内置规则
for rule in rules:
if rule.enabled:
rule.check(clean, upper, report)
# 执行自定义规则
for rule in self.custom_rules:
if rule.enabled:
rule.check(clean, upper, report)
# 环境特定规则
if self.env == "production":
self._check_production_rules(clean, upper, report)
# 计算风险等级
report.risk_level = report.calculate_risk_level()
report.risk_score = report.score
return report
def _check_production_rules(self, clean: str, upper: str, report: AuditReport) -> None:
"""生产环境额外规则。
Args:
clean: 原始内容
upper: 大写内容
report: 审核报告
"""
if self.db_type in ("mysql", "postgres", "postgresql"):
# ALTER TABLE 限制
if re.search(r"\bALTER\s+TABLE\b", upper):
report.findings.append(AuditFinding(
rule="ALTER_TABLE_PRODUCTION",
level=Severity.ERROR,
message="生产环境 ALTER TABLE 需 DBA 特别审批",
risk_level=RiskLevel.HIGH,
suggestion="在低峰期执行,使用 pt-online-schema-change 等在线工具",
category="production",
))
# CREATE INDEX 限制
if re.search(r"\bCREATE\s+(UNIQUE\s+)?INDEX\b", upper):
report.findings.append(AuditFinding(
rule="CREATE_INDEX_PRODUCTION",
level=Severity.WARNING,
message="生产环境创建索引需确认影响范围",
risk_level=RiskLevel.MEDIUM,
suggestion="使用 ONLINE/CONCURRENT 选项避免锁表",
category="production",
))
# 没有索引的 UPDATE/DELETE
if re.search(r"\b(UPDATE|DELETE)\b", upper):
report.findings.append(AuditFinding(
rule="DML_PRODUCTION_CAUTION",
level=Severity.WARNING,
message="生产环境 UPDATE/DELETE 需要确认 WHERE 条件有索引覆盖",
risk_level=RiskLevel.HIGH,
suggestion="使用 EXPLAIN 确认执行计划使用了索引",
category="production",
))
@staticmethod
def get_supported_rules(db_type: str) -> List[Dict[str, str]]:
"""获取指定数据库类型支持的所有规则列表。
Args:
db_type: 数据库类型
Returns:
规则信息列表
"""
rules = RULE_REGISTRY.get(db_type.lower(), [])
return [
{
"rule_code": r.rule_code,
"category": r.category,
"severity": r.severity.value,
"risk_level": r.risk_level.value,
"description": r.description,
"suggestion": r.suggestion,
}
for r in rules
if r.enabled
]
File diff suppressed because it is too large. Load diff
-78
View File
@@ -1,78 +0,0 @@
"""Audit log service — centralized logging for all operations."""
from typing import Optional, List, Dict, Any
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog
class AuditLogService:
@staticmethod
async def log(
db: AsyncSession,
user_id: int,
action: str,
target_type: str,
target_id: Optional[int] = None,
detail: Optional[str] = None,
ip_address: Optional[str] = None,
) -> AuditLog:
"""Create an audit log entry."""
entry = AuditLog(
user_id=user_id,
action=action,
target_type=target_type,
target_id=target_id,
detail=detail[:500] if detail else None,
ip_address=ip_address,
)
db.add(entry)
# Don't commit — let the caller's transaction handle it
await db.flush()
return entry
@staticmethod
async def list_logs(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
user_id: Optional[int] = None,
action: Optional[str] = None,
target_type: Optional[str] = None,
) -> Dict[str, Any]:
"""List audit logs with pagination and filters."""
stmt = select(AuditLog)
if user_id:
stmt = stmt.where(AuditLog.user_id == user_id)
if action:
stmt = stmt.where(AuditLog.action == action)
if target_type:
stmt = stmt.where(AuditLog.target_type == target_type)
total_stmt = select(func.count()).select_from(stmt.subquery())
total = (await db.execute(total_stmt)).scalar()
stmt = stmt.order_by(AuditLog.created_at.desc())
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
logs = result.scalars().all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": log.id,
"user_id": log.user_id,
"action": log.action,
"target_type": log.target_type,
"target_id": log.target_id,
"detail": log.detail,
"ip_address": log.ip_address,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
],
}
-45
View File
@@ -1,45 +0,0 @@
"""操作审计日志服务 — 轻量级,各 endpoint 直接调用。"""
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import Request
from app.models.operation_log import OperationLog
async def log_operation(
db: AsyncSession,
*,
action: str,
resource_type: Optional[str] = None,
resource_id: Optional[int] = None,
detail: Optional[str] = None,
user_id: Optional[int] = None,
username: Optional[str] = None,
request: Optional[Request] = None,
) -> None:
"""记录操作日志(fire-and-forget,不抛异常)。"""
try:
ip_address = None
user_agent = None
if request:
forwarded = request.headers.get("x-forwarded-for")
ip_address = forwarded.split(",")[0].strip() if forwarded else (
request.client.host if request.client else None
)
user_agent = request.headers.get("user-agent", "")[:255]
log = OperationLog(
user_id=user_id,
username=username,
action=action,
resource_type=resource_type,
resource_id=resource_id,
detail=detail,
ip_address=ip_address,
user_agent=user_agent,
)
db.add(log)
await db.flush()
except Exception:
# 审计日志不应影响业务流程
pass
-637
View File
@@ -1,637 +0,0 @@
"""Preset audit rule seed data for MySQL, PostgreSQL, Redis, and MongoDB."""
from typing import List, Dict, Any
# ── MySQL 预设规则 ──────────────────────────────────────────────
MYSQL_SEED_RULES: List[Dict[str, Any]] = [
{
"name": "DDLMultiToCommit",
"description": "DDL 允许单个工单提交多条DDL",
"rule_type": "DDL",
"severity": "info",
"db_type": "mysql",
"pattern": None,
"value": None,
"message": "DDL 允许单个工单提交多条DDL语句",
},
{
"name": "DDLAllowMultiAlter",
"description": "DDL 允许多alter",
"rule_type": "DDL",
"severity": "info",
"db_type": "mysql",
"pattern": None,
"value": None,
"message": "DDL 允许在单条ALTER TABLE中使用多个修改操作",
},
{
"name": "DDLAllowColumnType",
"description": "允许字段类型转换",
"rule_type": "DDL",
"severity": "warning",
"db_type": "mysql",
"pattern": None,
"value": None,
"message": "允许字段类型转换,请确认数据兼容性",
},
{
"name": "DDLAllowChangeColumnPosition",
"description": "允许 AFTER/FIRST 修改列位置",
"rule_type": "DDL",
"severity": "info",
"db_type": "mysql",
"pattern": None,
"value": None,
"message": "允许使用 AFTER/FIRST 修改列位置",
},
{
"name": "DDLEnableDropTable",
"description": "允许删表操作",
"rule_type": "DDL",
"severity": "warning",
"db_type": "mysql",
"pattern": None,
"value": None,
"message": "允许执行 DROP TABLE 操作,请确认已备份",
},
{
"name": "DDLEnablePrimaryKey",
"description": "检查表必须包含主键",
"rule_type": "DDL",
"severity": "error",
"db_type": "mysql",
"pattern": None,
"value": None,
"message": "建表必须指定主键",
},
{
"name": "DDLCheckTableComment",
"description": "强制表注释",
"rule_type": "DDL",
"severity": "warning",
"db_type": "mysql",
"pattern": None,
"value": None,
"message": "建表时必须添加表注释(COMMENT)",
},
{
"name": "DMLAllowInsertNull",
"description": "允许 Insert Null 值",
"rule_type": "DML",
"severity": "info",
"db_type": "mysql",
"pattern": None,
"value": None,
"message": "允许 INSERT 语句中包含 NULL 值",
},
{
"name": "DMLWhere",
"description": "强制 DML 语句包含 WHERE 条件",
"rule_type": "DML",
"severity": "error",
"db_type": "mysql",
"pattern": None,
"value": None,
"message": "UPDATE/DELETE 语句必须包含 WHERE 条件",
},
{
"name": "DDLMaxKeyParts",
"description": "单索引字段上限",
"rule_type": "DDL",
"severity": "warning",
"db_type": "mysql",
"pattern": None,
"value": "8",
"message": "单索引包含的字段数不得超过8个",
},
{
"name": "DDLMaxKey",
"description": "单表索引上限",
"rule_type": "DDL",
"severity": "warning",
"db_type": "mysql",
"pattern": None,
"value": "12",
"message": "单表索引数量不得超过12个",
},
{
"name": "MaxDDLAffectRows",
"description": "DDL 最大影响行数",
"rule_type": "DDL",
"severity": "warning",
"db_type": "mysql",
"pattern": None,
"value": "0",
"message": "DDL 最大影响行数为0(不限制)",
},
{
"name": "DDLMaxCharLength",
"description": "CHAR 类型最大长度",
"rule_type": "DDL",
"severity": "warning",
"db_type": "mysql",
"pattern": None,
"value": "10",
"message": "CHAR 类型长度不得超过10,超出请使用 VARCHAR",
},
{
"name": "MaxTableNameLen",
"description": "表名长度限制",
"rule_type": "DDL",
"severity": "warning",
"db_type": "mysql",
"pattern": None,
"value": "45",
"message": "表名长度不得超过45个字符",
},
{
"name": "DMLMaxInsertRows",
"description": "INSERT 最大行数限制",
"rule_type": "DML",
"severity": "warning",
"db_type": "mysql",
"pattern": None,
"value": "500",
"message": "单次 INSERT 最多插入500行",
},
]
# ── PostgreSQL 预设规则 ─────────────────────────────────────────
POSTGRES_SEED_RULES: List[Dict[str, Any]] = [
{
"name": "PGDDLMultiToCommit",
"description": "PostgreSQL DDL 允许单个工单提交多条DDL",
"rule_type": "DDL",
"severity": "info",
"db_type": "postgres",
"pattern": None,
"value": None,
"message": "DDL 允许单个工单提交多条DDL语句",
},
{
"name": "PGDDLAllowColumnType",
"description": "PostgreSQL 允许字段类型转换",
"rule_type": "DDL",
"severity": "warning",
"db_type": "postgres",
"pattern": None,
"value": None,
"message": "允许字段类型转换,请确认数据兼容性",
},
{
"name": "PGDDLEnablePrimaryKey",
"description": "PostgreSQL 检查表必须包含主键",
"rule_type": "DDL",
"severity": "error",
"db_type": "postgres",
"pattern": None,
"value": None,
"message": "建表必须指定主键",
},
{
"name": "PGDDLEnableDropTable",
"description": "PostgreSQL 允许删表操作",
"rule_type": "DDL",
"severity": "warning",
"db_type": "postgres",
"pattern": None,
"value": None,
"message": "允许执行 DROP TABLE 操作,请确认已备份",
},
{
"name": "PGDDLCheckTableComment",
"description": "PostgreSQL 强制表注释",
"rule_type": "DDL",
"severity": "warning",
"db_type": "postgres",
"pattern": None,
"value": None,
"message": "建表时必须添加表注释(COMMENT ON TABLE)",
},
{
"name": "PGDMLWhere",
"description": "PostgreSQL 强制 DML 包含 WHERE 条件",
"rule_type": "DML",
"severity": "error",
"db_type": "postgres",
"pattern": None,
"value": None,
"message": "UPDATE/DELETE 语句必须包含 WHERE 条件",
},
{
"name": "PGDMLAllowInsertNull",
"description": "PostgreSQL 允许 Insert Null 值",
"rule_type": "DML",
"severity": "info",
"db_type": "postgres",
"pattern": None,
"value": None,
"message": "允许 INSERT 语句中包含 NULL 值",
},
{
"name": "PGDDLMaxKeyParts",
"description": "PostgreSQL 单索引字段上限",
"rule_type": "DDL",
"severity": "warning",
"db_type": "postgres",
"pattern": None,
"value": "8",
"message": "单索引包含的字段数不得超过8个",
},
{
"name": "PGDDLMaxKey",
"description": "PostgreSQL 单表索引上限",
"rule_type": "DDL",
"severity": "warning",
"db_type": "postgres",
"pattern": None,
"value": "12",
"message": "单表索引数量不得超过12个",
},
{
"name": "PGMaxTableNameLen",
"description": "PostgreSQL 表名长度限制",
"rule_type": "DDL",
"severity": "warning",
"db_type": "postgres",
"pattern": None,
"value": "63",
"message": "表名长度不得超过63个字符",
},
{
"name": "PGDMLMaxInsertRows",
"description": "PostgreSQL INSERT 最大行数限制",
"rule_type": "DML",
"severity": "warning",
"db_type": "postgres",
"pattern": None,
"value": "500",
"message": "单次 INSERT 最多插入500行",
},
]
# ── MongoDB 预设规则 ────────────────────────────────────────────
MONGODB_SEED_RULES: List[Dict[str, Any]] = [
{
"name": "MongoDB集合命名规范(snake_case)",
"description": "MongoDB 集合名应使用小写下划线命名(snake_case)",
"rule_type": "DDL",
"severity": "warning",
"db_type": "mongodb",
"pattern": r"^[a-z][a-z0-9_]*$",
"value": None,
"message": "集合名不符合 snake_case 命名规范,应使用小写下划线格式",
},
{
"name": "MongoDB集合名最大长度限制",
"description": "MongoDB 集合名最大长度为 64 字节",
"rule_type": "DDL",
"severity": "warning",
"db_type": "mongodb",
"pattern": None,
"value": "64",
"message": "集合名长度超过64字节,请缩短集合名",
},
{
"name": "MongoDB禁止删除集合",
"description": "生产环境禁止执行删除集合操作",
"rule_type": "DDL",
"severity": "error",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "禁止执行删除集合操作,数据将永久丢失",
},
{
"name": "MongoDB禁止删除数据库",
"description": "禁止执行 dropDatabase 操作",
"rule_type": "DDL",
"severity": "error",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "禁止执行 dropDatabase 操作,将删除整个数据库",
},
{
"name": "MongoDB禁止删除索引",
"description": "禁止执行删除索引操作",
"rule_type": "DDL",
"severity": "error",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "禁止执行删除索引操作,可能导致查询性能急剧下降",
},
{
"name": "MongoDB推荐Schema Validator",
"description": "创建集合时建议使用 JSON Schema Validator",
"rule_type": "DDL",
"severity": "info",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "建议为集合添加 JSON Schema Validator 以约束文档结构",
},
{
"name": "MongoDB查询必须带filter",
"description": "find/delete/update 操作必须包含 filter 条件",
"rule_type": "DQL",
"severity": "error",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "查询/更新/删除操作缺少 filter 条件,将操作全集合数据",
},
{
"name": "MongoDB推荐指定projection",
"description": "查询推荐指定 projection 限制返回字段",
"rule_type": "DQL",
"severity": "info",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "查询未指定 projection,建议添加 projection 限制返回字段",
},
{
"name": "MongoDB禁止$where/$function",
"description": "禁止使用 $where 和 $function 执行 JavaScript",
"rule_type": "DQL",
"severity": "error",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "禁止使用 $where/$function 执行 JavaScript,存在严重安全风险",
},
{
"name": "MongoDB禁止前缀模糊匹配",
"description": "禁止正则表达式前缀模糊匹配,无法有效使用索引",
"rule_type": "DQL",
"severity": "warning",
"db_type": "mongodb",
"pattern": r'/\^|"\$regex"\s*:\s*"\^',
"value": None,
"message": "前缀模糊匹配无法有效使用索引,建议调整查询策略",
},
{
"name": "MongoDB查询返回结果上限",
"description": "查询返回结果建议不超过 10000 条",
"rule_type": "DQL",
"severity": "warning",
"db_type": "mongodb",
"pattern": None,
"value": "10000",
"message": "查询未设置 limit 或 limit 超过 10000 条,建议分页查询",
},
{
"name": "MongoDB禁止聚合$out/$merge",
"description": "聚合管道禁止 $out/$merge 写操作",
"rule_type": "DQL",
"severity": "error",
"db_type": "mongodb",
"pattern": r'"\$(out|merge)"',
"value": None,
"message": "禁止在聚合管道中使用 $out/$merge 阶段,可能覆盖现有集合",
},
{
"name": "MongoDB聚合管道阶段数上限",
"description": "聚合管道阶段数建议不超过 20 个",
"rule_type": "DQL",
"severity": "warning",
"db_type": "mongodb",
"pattern": None,
"value": "20",
"message": "聚合管道阶段数超过20个,请优化查询结构",
},
{
"name": "MongoDB$unwind大数据量警告",
"description": "$unwind 展开数组可能导致文档数量急剧膨胀",
"rule_type": "DQL",
"severity": "warning",
"db_type": "mongodb",
"pattern": r'"\$unwind"',
"value": None,
"message": "$unwind 可能急剧增加文档数量,请确认数组字段元素数量",
},
{
"name": "MongoDB批量写入上限",
"description": "批量写入建议不超过 1000 条",
"rule_type": "DML",
"severity": "warning",
"db_type": "mongodb",
"pattern": None,
"value": "1000",
"message": "批量写入超过1000条,请分批写入",
},
{
"name": "MongoDBupdateMany必须带条件",
"description": "updateMany 必须包含 filter 条件",
"rule_type": "DML",
"severity": "error",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "updateMany 操作缺少 filter 条件,将更新全集合文档",
},
{
"name": "MongoDBdeleteMany必须带条件",
"description": "deleteMany 必须包含 filter 条件",
"rule_type": "DML",
"severity": "error",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "deleteMany 操作缺少 filter 条件,将删除全集合文档",
},
{
"name": "MongoDB禁止unset关键字段",
"description": "禁止 $unset 关键字段(_id, create_time等)",
"rule_type": "DML",
"severity": "error",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "禁止使用 $unset 删除关键字段,可能导致数据完整性问题",
},
{
"name": "MongoDB禁止重命名集合",
"description": "禁止执行 renameCollection 操作",
"rule_type": "DML",
"severity": "error",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "禁止重命名集合,可能导致应用连接失败",
},
{
"name": "MongoDB禁止绕过文档验证",
"description": "禁止 bypassDocumentValidation: true",
"rule_type": "DML",
"severity": "error",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "禁止设置 bypassDocumentValidation 绕过 Schema 验证",
},
{
"name": "MongoDB单文档大小限制",
"description": "单文档大小建议不超过 10MB 警告阈值",
"rule_type": "DML",
"severity": "warning",
"db_type": "mongodb",
"pattern": None,
"value": "10485760",
"message": "文档大小超过10MB警告阈值,建议拆分或精简文档",
},
{
"name": "MongoDB禁止嵌套深度超过5层",
"description": "文档嵌套深度建议不超过 5 层",
"rule_type": "DML",
"severity": "warning",
"db_type": "mongodb",
"pattern": None,
"value": "5",
"message": "文档嵌套深度超过5层,建议优化文档结构",
},
{
"name": "MongoDB禁止db.eval()",
"description": "禁止 db.eval() 执行 JavaScript",
"rule_type": "security",
"severity": "error",
"db_type": "mongodb",
"pattern": r'\bdb\.eval\s*\(',
"value": None,
"message": "禁止 db.eval() 执行 JavaScript,存在严重安全风险",
},
{
"name": "MongoDB禁止操作系统集合",
"description": "禁止操作系统集合",
"rule_type": "security",
"severity": "error",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "禁止操作系统集合,存在安全风险",
},
{
"name": "MongoDB禁止MapReduce",
"description": "禁止使用 mapReduce,使用聚合管道替代",
"rule_type": "security",
"severity": "error",
"db_type": "mongodb",
"pattern": r'\bmapReduce\b',
"value": None,
"message": "禁止 mapReduce,请使用聚合管道替代",
},
{
"name": "MongoDB防止$cmd注入",
"description": "禁止直接操作 $cmd 集合",
"rule_type": "security",
"severity": "error",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "禁止直接操作 $cmd 集合,存在注入攻击风险",
},
{
"name": "MongoDB敏感操作审计日志",
"description": "删除、更新等敏感操作应记录审计日志",
"rule_type": "security",
"severity": "info",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "敏感操作应确保已记录审计日志,便于安全追踪",
},
{
"name": "MongoDB缺少索引查询警告",
"description": "查询条件字段应创建索引",
"rule_type": "performance",
"severity": "warning",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "查询条件字段缺少索引可能导致全集合扫描",
},
{
"name": "MongoDB全集合扫描警告",
"description": "缺少 filter 条件将导致全集合扫描",
"rule_type": "performance",
"severity": "warning",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "查询缺少过滤条件,将导致全集合扫描",
},
{
"name": "MongoDB复合索引字段顺序建议",
"description": "复合索引字段顺序应遵循 ESR 规则",
"rule_type": "performance",
"severity": "info",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "建议遵循 ESR 规则设计复合索引: 等值 > 排序 > 范围",
},
{
"name": "MongoDB分片键选择建议",
"description": "分片集合应选择高基数字段作为分片键",
"rule_type": "performance",
"severity": "info",
"db_type": "mongodb",
"pattern": None,
"value": None,
"message": "建议选择高基数均匀分布字段作为分片键",
},
]
# ── Redis 预设规则 ──────────────────────────────────────────────
REDIS_SEED_RULES: List[Dict[str, Any]] = [
{
"name": "RedisAllowFlushDB",
"description": "Redis 允许 FLUSHDB 操作",
"rule_type": "DML",
"severity": "warning",
"db_type": "redis",
"pattern": None,
"value": None,
"message": "允许执行 FLUSHDB 操作,将清空当前数据库",
},
{
"name": "RedisMaxKeyLen",
"description": "Redis Key 最大长度限制",
"rule_type": "DDL",
"severity": "warning",
"db_type": "redis",
"pattern": None,
"value": "256",
"message": "Key 长度不得超过256个字符",
},
{
"name": "RedisRequireTTL",
"description": "Redis 写入操作必须设置 TTL",
"rule_type": "DML",
"severity": "error",
"db_type": "redis",
"pattern": None,
"value": None,
"message": "写入 Key 时必须设置过期时间(TTL)",
},
{
"name": "RedisDefaultTTL",
"description": "Redis 默认 TTL 值(秒)",
"rule_type": "DML",
"severity": "info",
"db_type": "redis",
"pattern": None,
"value": "3600",
"message": "默认 TTL 为3600秒(1小时)",
},
]
# 映射: db_type → 种子规则列表
SEED_RULES_MAP = {
"mysql": MYSQL_SEED_RULES,
"postgres": POSTGRES_SEED_RULES,
"redis": REDIS_SEED_RULES,
"mongodb": MONGODB_SEED_RULES,
}
-500
View File
@@ -1,500 +0,0 @@
"""SQL 审核引擎 V2 规则种子数据。
包含 40+ 条覆盖 DDL/DML/DQL/命名规范/性能/安全等多个维度的审核规则。
提供 seed_all_rules(db) 函数用于一次性初始化所有规则到数据库。
"""
from typing import Any, Dict, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_rule import AuditRule
# ── DDL 规则种子 ──────────────────────────────────────────────
DDL_RULES: List[Dict[str, Any]] = [
{
"name": "建表必须包含主键",
"description": "CREATE TABLE 语句必须包含 PRIMARY KEY 定义,确保每行数据唯一可标识",
"rule_type": "DDL",
"severity": "error",
"db_type": "all",
"pattern": None,
"value": None,
"message": "建表语句缺少主键定义,请添加 PRIMARY KEY",
},
{
"name": "建表必须添加表注释",
"description": "CREATE TABLE 语句必须包含 COMMENT 表注释,便于维护和理解表用途",
"rule_type": "DDL",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": None,
"message": "建表时必须添加表注释 (COMMENT='...')",
},
{
"name": "建表字段必须有注释",
"description": "CREATE TABLE 中的每个字段都应包含 COMMENT 注释",
"rule_type": "DDL",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": None,
"message": "字段缺少注释,请为每个字段添加 COMMENT",
},
{
"name": "禁止 DROP TABLE",
"description": "生产环境禁止执行 DROP TABLE 操作,数据将永久丢失",
"rule_type": "DDL",
"severity": "error",
"db_type": "all",
"pattern": None,
"value": None,
"message": "禁止执行 DROP TABLE 操作,数据将永久丢失",
},
{
"name": "禁止 TRUNCATE TABLE",
"description": "禁止执行 TRUNCATE TABLE 操作,数据不可回滚",
"rule_type": "DDL",
"severity": "error",
"db_type": "all",
"pattern": None,
"value": None,
"message": "禁止执行 TRUNCATE TABLE 操作,数据不可回滚",
},
{
"name": "单表索引数量不得超过20个",
"description": "单表索引数量过多会影响写入性能",
"rule_type": "DDL",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": "20",
"message": "单表索引数量超过20个,会影响写入性能",
},
{
"name": "单索引字段数不得超过8个",
"description": "单索引包含字段数过多会降低索引效率",
"rule_type": "DDL",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": "8",
"message": "单索引包含字段数超过8个,请优化索引设计",
},
{
"name": "禁止使用 FLOAT/DOUBLE 类型",
"description": "浮点类型精度丢失,应使用 DECIMAL",
"rule_type": "DDL",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": None,
"message": "禁止使用 FLOAT/DOUBLE 类型,建议使用 DECIMAL 避免精度丢失",
},
{
"name": "CHAR 类型长度不得超过255",
"description": "CHAR 类型长度过大会浪费存储空间,超出请使用 VARCHAR",
"rule_type": "DDL",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": "255",
"message": "CHAR 类型长度不得超过255,超出请使用 VARCHAR",
},
{
"name": "表名长度不得超过64字符",
"description": "过长的表名不利于维护和使用",
"rule_type": "DDL",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": "64",
"message": "表名长度超过64个字符,请缩短表名",
},
{
"name": "禁止外键约束",
"description": "外键约束影响性能,推荐在应用层处理关联关系",
"rule_type": "DDL",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": None,
"message": "禁止使用 FOREIGN KEY 约束,建议在应用层维护数据一致性",
},
{
"name": "必须包含 create_time/update_time 字段",
"description": "建表必须包含 create_time 和 update_time 审计字段,便于追踪数据变更",
"rule_type": "DDL",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": None,
"message": "建表缺少 create_time 或 update_time 审计字段",
},
]
# ── DML 规则种子 ──────────────────────────────────────────────
DML_RULES: List[Dict[str, Any]] = [
{
"name": "UPDATE/DELETE 必须包含 WHERE 条件",
"description": "UPDATE/DELETE 缺少 WHERE 将影响全表数据,可能导致严重事故",
"rule_type": "DML",
"severity": "error",
"db_type": "all",
"pattern": None,
"value": None,
"message": "UPDATE/DELETE 语句必须包含 WHERE 条件",
},
{
"name": "单次 INSERT 最多500行",
"description": "单次 INSERT 行数过多会导致事务过大,影响性能和复制延迟",
"rule_type": "DML",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": "500",
"message": "单次 INSERT 最多插入500行",
},
{
"name": "UPDATE/DELETE 禁止带 LIMIT",
"description": "UPDATE/DELETE 使用 LIMIT 可能导致部分数据变更,数据不一致",
"rule_type": "DML",
"severity": "warning",
"db_type": "mysql",
"pattern": None,
"value": None,
"message": "UPDATE/DELETE 语句禁止使用 LIMIT",
},
{
"name": "禁止 INSERT 不指定字段列表",
"description": "INSERT 必须显式指定字段列表,避免表结构变更导致错误",
"rule_type": "DML",
"severity": "error",
"db_type": "all",
"pattern": None,
"value": None,
"message": "INSERT 语句必须显式指定字段列表",
},
{
"name": "禁止批量 UPDATE 不带主键条件",
"description": "批量 UPDATE 必须带主键 WHERE 条件,防止误更新大量数据",
"rule_type": "DML",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": None,
"message": "批量 UPDATE 必须包含主键条件",
},
{
"name": "DELETE 必须有 LIMIT(除非带主键条件)",
"description": "DELETE 必须带 LIMIT 或主键 WHERE 条件,防止误删大量数据",
"rule_type": "DML",
"severity": "warning",
"db_type": "mysql",
"pattern": None,
"value": None,
"message": "DELETE 建议添加 LIMIT 或使用主键条件",
},
{
"name": "禁止 REPLACE INTO",
"description": "REPLACE INTO 会先删除再插入,风险较高",
"rule_type": "DML",
"severity": "error",
"db_type": "mysql",
"pattern": None,
"value": None,
"message": "禁止使用 REPLACE INTO,请使用 INSERT ... ON DUPLICATE KEY UPDATE 替代",
},
{
"name": "INSERT 必须指定字段列表",
"description": "INSERT 语句必须显式指定字段列表,避免表结构变更导致错误",
"rule_type": "DML",
"severity": "error",
"db_type": "all",
"pattern": None,
"value": None,
"message": "INSERT 语句必须显式指定字段列表",
},
]
# ── DQL 规则种子 ──────────────────────────────────────────────
DQL_RULES: List[Dict[str, Any]] = [
{
"name": "禁止 SELECT *",
"description": "SELECT * 返回不必要的列,影响网络传输和内存使用",
"rule_type": "DQL",
"severity": "info",
"db_type": "all",
"pattern": None,
"value": None,
"message": "禁止使用 SELECT *,请明确指定需要的字段",
},
{
"name": "SELECT 必须有 LIMIT",
"description": "SELECT 查询应添加 LIMIT 限制返回行数,防止返回过多数据",
"rule_type": "DQL",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": None,
"message": "SELECT 查询缺少 LIMIT,建议添加 LIMIT 防止返回过多数据",
},
{
"name": "禁止在 WHERE 中使用函数",
"description": "WHERE 中使用函数会导致索引失效,性能下降",
"rule_type": "DQL",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": None,
"message": "WHERE 子句中使用函数会导致索引失效",
},
{
"name": "禁止隐式类型转换",
"description": "不同类型比较会导致索引失效,请确保比较双方类型一致",
"rule_type": "DQL",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": None,
"message": "WHERE 条件中存在隐式类型转换风险",
},
{
"name": "ORDER BY 必须使用索引字段",
"description": "ORDER BY 非索引字段会导致文件排序(filesort),影响性能",
"rule_type": "DQL",
"severity": "info",
"db_type": "all",
"pattern": None,
"value": None,
"message": "ORDER BY 字段建议添加索引,避免 filesort 性能问题",
},
]
# ── 命名规范规则种子 ──────────────────────────────────────────
NAMING_RULES: List[Dict[str, Any]] = [
{
"name": "表名必须小写下划线命名",
"description": "表名应使用小写下划线格式 (snake_case),如 user_info",
"rule_type": "naming",
"severity": "warning",
"db_type": "all",
"pattern": r"^[a-z][a-z0-9_]*$",
"value": None,
"message": "表名不符合小写下划线命名规范",
},
{
"name": "字段名必须小写下划线命名",
"description": "字段名应使用小写下划线格式 (snake_case),如 user_name",
"rule_type": "naming",
"severity": "warning",
"db_type": "all",
"pattern": r"^[a-z][a-z0-9_]*$",
"value": None,
"message": "字段名不符合小写下划线命名规范",
},
{
"name": "索引名必须以 idx_ 或 uk_ 开头",
"description": "索引命名规范:普通索引 idx_,唯一索引 uk_,如 idx_user_name",
"rule_type": "naming",
"severity": "info",
"db_type": "all",
"pattern": r"^(idx_|uk_)",
"value": None,
"message": "索引名应以 idx_ 或 uk_ 开头",
},
{
"name": "主键名必须为 id 或 pk_ 开头",
"description": "主键字段命名规范,建议使用 id 或 pk_ 前缀",
"rule_type": "naming",
"severity": "info",
"db_type": "all",
"pattern": r"^(id|pk_)",
"value": None,
"message": "主键名建议使用 'id' 或 'pk_' 前缀",
},
{
"name": "布尔字段以 is_/has_ 开头",
"description": "布尔类型字段应以 is_ 或 has_ 前缀命名,如 is_active",
"rule_type": "naming",
"severity": "info",
"db_type": "all",
"pattern": r"^(is_|has_)",
"value": None,
"message": "布尔字段建议以 is_ 或 has_ 开头命名",
},
]
# ── 性能规则种子 ──────────────────────────────────────────────
PERF_RULES: List[Dict[str, Any]] = [
{
"name": "禁止大表全表扫描",
"description": "大表(>10000行)查询缺少 WHERE 条件会导致全表扫描,严重影响性能",
"rule_type": "performance",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": "10000",
"message": "SELECT 查询缺少 WHERE 条件,大表下会导致全表扫描",
},
{
"name": "IN 子句值数量不得超过100个",
"description": "IN 子句值过多会导致性能下降,建议使用临时表或 JOIN 替代",
"rule_type": "performance",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": "100",
"message": "IN 子句包含超过100个值,建议使用临时表或 JOIN 替代",
},
{
"name": "禁止 LIKE '%xxx%' 前缀模糊匹配",
"description": "前缀模糊匹配无法使用索引,建议使用全文检索",
"rule_type": "performance",
"severity": "warning",
"db_type": "all",
"pattern": r"LIKE\s+'%[^']+'",
"value": None,
"message": "LIKE '%xxx%' 无法使用索引,建议改用全文检索或后缀索引",
},
{
"name": "JOIN 不得超过5个表",
"description": "JOIN 表过多会导致查询计划复杂化和性能下降",
"rule_type": "performance",
"severity": "warning",
"db_type": "all",
"pattern": None,
"value": "5",
"message": "JOIN 涉及超过5个表,请优化查询结构",
},
{
"name": "禁止 ORDER BY RAND()",
"description": "ORDER BY RAND() 性能极差,会导致全表扫描",
"rule_type": "performance",
"severity": "error",
"db_type": "mysql",
"pattern": r"ORDER\s+BY\s+RAND\s*\(",
"value": None,
"message": "禁止 ORDER BY RAND(),性能极差,建议在应用层随机采样",
},
{
"name": "子查询建议改为 JOIN",
"description": "相关子查询性能较差,建议改为 JOIN",
"rule_type": "performance",
"severity": "info",
"db_type": "all",
"pattern": None,
"value": None,
"message": "WHERE 中的子查询建议改为 JOIN 以提升性能",
},
{
"name": "大表禁止 OFFSET 分页",
"description": "大表使用 OFFSET 分页性能差,建议使用游标分页",
"rule_type": "performance",
"severity": "warning",
"db_type": "all",
"pattern": r"OFFSET\s+\d+",
"value": None,
"message": "使用 OFFSET 分页在大表上性能较差,建议改用游标分页",
},
{
"name": "禁止 SELECT DISTINCT(除非必要)",
"description": "DISTINCT 需要去重操作,影响性能",
"rule_type": "performance",
"severity": "info",
"db_type": "all",
"pattern": r"SELECT\s+DISTINCT\b",
"value": None,
"message": "SELECT DISTINCT 影响性能,请确认是否必要",
},
]
# ── 安全规则种子 ──────────────────────────────────────────────
SEC_RULES: List[Dict[str, Any]] = [
{
"name": "禁止查询 password/secret/token 字段",
"description": "禁止在 SELECT 中查询敏感字段,防止密码等敏感信息泄露",
"rule_type": "security",
"severity": "error",
"db_type": "all",
"pattern": None,
"value": None,
"message": "禁止在查询中返回 password/secret/token 等敏感字段",
},
{
"name": "禁止 SQL 中硬编码密码",
"description": "SQL 中不应包含硬编码密码,防止密码泄露",
"rule_type": "security",
"severity": "error",
"db_type": "all",
"pattern": None,
"value": None,
"message": "SQL 中发现可能的硬编码密码,禁止在 SQL 中直接写入密码",
},
{
"name": "禁止 UNION(SQL 注入风险)",
"description": "UNION 语句存在 SQL 注入风险,禁止在工单中使用",
"rule_type": "security",
"severity": "error",
"db_type": "all",
"pattern": r"\bUNION\b",
"value": None,
"message": "禁止 UNION 操作,存在 SQL 注入风险",
},
{
"name": "禁止注释符",
"description": "SQL 中不应包含 -- 或 /* */ 注释,可能存在注入风险",
"rule_type": "security",
"severity": "error",
"db_type": "all",
"pattern": None,
"value": None,
"message": "SQL 中禁止包含注释符,可能存在注入风险",
},
{
"name": "禁止多语句(分号分隔)",
"description": "禁止单条 SQL 中包含多条语句,防止注入攻击",
"rule_type": "security",
"severity": "error",
"db_type": "all",
"pattern": None,
"value": None,
"message": "禁止提交多条 SQL 语句(分号分隔),请拆分为多个工单",
},
]
# ── 合并所有规则 ──────────────────────────────────────────────
ALL_V2_RULES: List[Dict[str, Any]] = (
DDL_RULES + DML_RULES + DQL_RULES + NAMING_RULES + PERF_RULES + SEC_RULES
)
async def seed_all_rules(db: AsyncSession) -> List[AuditRule]:
"""将所有 V2 规则种子数据插入数据库(跳过已存在的规则)。
Args:
db: 数据库异步会话
Returns:
新创建的 AuditRule 列表
"""
created: List[AuditRule] = []
for rule_dict in ALL_V2_RULES:
# 检查是否已存在(按 name 去重)
exists = await db.execute(select(AuditRule).where(AuditRule.name == rule_dict["name"]))
if exists.scalar_one_or_none():
continue # 跳过已存在的规则
rule = AuditRule(**rule_dict)
db.add(rule)
created.append(rule)
await db.commit()
for rule in created:
await db.refresh(rule)
return created
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff. Show more