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:
1 parent
43d1f3a234
commit
ca308e277d
202 files changed
+2265
-51181
No files matched your search
@@ -1,6 +0,0 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
*.db
|
||||
alembic/versions/__pycache__/
|
||||
@@ -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=
|
||||
@@ -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"]
|
||||
@@ -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 +0,0 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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()
|
||||
@@ -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 ###
|
||||
Whitespace-only changes.
Whitespace-only changes.
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": "公告已删除"}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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": "已登出"}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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": "用户权限组已更新"}
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()],
|
||||
}
|
||||
@@ -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": "快捷查询已创建"}
|
||||
@@ -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))
|
||||
@@ -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 变更历史功能开发中,当前使用内存存储",
|
||||
}
|
||||
@@ -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
@@ -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": "密码已重置"}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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.
@@ -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)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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"}
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
),
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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}')>"
|
||||
)
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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] = []
|
||||
@@ -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="提示消息")
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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="警告信息")
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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] = ""
|
||||
Whitespace-only changes.
@@ -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
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,79 +0,0 @@
|
||||
"""审核规则自动同步到数据库。
|
||||
|
||||
启动时从 _RULE_REGISTRY 提取所有注册规则,同步到 audit_rules 表:
|
||||
- 新增规则 → INSERT
|
||||
- 已有规则 → UPDATE severity/config_json/is_enabled(保留用户自定义覆盖)
|
||||
- 不再存在的规则 → 不删除(保留历史数据)
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def sync_rules_to_db(db: AsyncSession) -> int:
|
||||
"""将注册表中的规则同步到数据库。返回同步数量。"""
|
||||
from app.services.audit_engine_v2 import _RULE_REGISTRY, RuleCategory, Severity
|
||||
|
||||
synced = 0
|
||||
for code, (default_rule, _func) in _RULE_REGISTRY.items():
|
||||
# 查找已有记录(按 code 或 name)
|
||||
from app.models.audit_rule import AuditRule
|
||||
stmt = select(AuditRule).where(AuditRule.code == code)
|
||||
result = await db.execute(stmt)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing is None:
|
||||
# 按 name 兼容查找
|
||||
stmt2 = select(AuditRule).where(AuditRule.name == default_rule.name)
|
||||
result2 = await db.execute(stmt2)
|
||||
existing = result2.scalar_one_or_none()
|
||||
|
||||
config_json_str = json.dumps(default_rule.config, ensure_ascii=False) if default_rule.config else None
|
||||
category = default_rule.category.value if isinstance(default_rule.category, RuleCategory) else str(default_rule.category)
|
||||
severity = default_rule.severity.value if isinstance(default_rule.severity, Severity) else str(default_rule.severity)
|
||||
|
||||
if existing is None:
|
||||
# 新增
|
||||
new_rule = AuditRule(
|
||||
code=code,
|
||||
name=default_rule.name,
|
||||
description=default_rule.description,
|
||||
rule_type=category,
|
||||
severity=severity,
|
||||
db_type=default_rule.db_type,
|
||||
pattern=default_rule.pattern,
|
||||
value=None,
|
||||
config_json=config_json_str,
|
||||
is_enabled=default_rule.is_enabled,
|
||||
message=default_rule.message_template,
|
||||
)
|
||||
db.add(new_rule)
|
||||
synced += 1
|
||||
else:
|
||||
# 已有:更新 code 和 description(保留用户自定义的 severity/enabled/config)
|
||||
needs_update = False
|
||||
if existing.code is None:
|
||||
existing.code = code
|
||||
needs_update = True
|
||||
if existing.description != default_rule.description and existing.description is None:
|
||||
existing.description = default_rule.description
|
||||
needs_update = True
|
||||
if existing.message != default_rule.message_template and existing.message is None:
|
||||
existing.message = default_rule.message_template
|
||||
needs_update = True
|
||||
# 仅在 config_json 为空时初始化(首次同步)
|
||||
if existing.config_json is None and config_json_str:
|
||||
existing.config_json = config_json_str
|
||||
needs_update = True
|
||||
if needs_update:
|
||||
synced += 1
|
||||
|
||||
await db.commit()
|
||||
if synced > 0:
|
||||
logger.info(f"审核规则同步完成: {synced} 条更新")
|
||||
return synced
|
||||
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user