diff --git a/.gitignore b/.gitignore index e36abbd..1590675 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,20 @@ -# Backend +# Backend Python __pycache__/ *.pyc venv/ *.db .env *.sqlite3 +*.db-journal +.pytest_cache/ +apscheduler.db + +# Backend Python (preserved but not tracked) +backend-python/ + +# Backend Go +backend/sql-platform +backend/go.sum # Frontend node_modules/ diff --git a/backend/.dockerignore b/backend/.dockerignore deleted file mode 100644 index c19149b..0000000 --- a/backend/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -venv/ -__pycache__/ -*.pyc -.pytest_cache/ -*.db -alembic/versions/__pycache__/ diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index c8bbd47..0000000 --- a/backend/.env.example +++ /dev/null @@ -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= diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 53a6724..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -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"] diff --git a/backend/alembic.ini b/backend/alembic.ini deleted file mode 100644 index 0791816..0000000 --- a/backend/alembic.ini +++ /dev/null @@ -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 /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 diff --git a/backend/alembic/README b/backend/alembic/README deleted file mode 100644 index 98e4f9c..0000000 --- a/backend/alembic/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py deleted file mode 100644 index 61eda46..0000000 --- a/backend/alembic/env.py +++ /dev/null @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako deleted file mode 100644 index 1101630..0000000 --- a/backend/alembic/script.py.mako +++ /dev/null @@ -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"} diff --git a/backend/alembic/versions/5c8b75f38c7e_initial_schema.py b/backend/alembic/versions/5c8b75f38c7e_initial_schema.py deleted file mode 100644 index 90e2d61..0000000 --- a/backend/alembic/versions/5c8b75f38c7e_initial_schema.py +++ /dev/null @@ -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 ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/api/v1/endpoints/__init__.py b/backend/app/api/v1/endpoints/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/api/v1/endpoints/announcement.py b/backend/app/api/v1/endpoints/announcement.py deleted file mode 100644 index 7837fa8..0000000 --- a/backend/app/api/v1/endpoints/announcement.py +++ /dev/null @@ -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": "公告已删除"} diff --git a/backend/app/api/v1/endpoints/audit.py b/backend/app/api/v1/endpoints/audit.py deleted file mode 100644 index 787f591..0000000 --- a/backend/app/api/v1/endpoints/audit.py +++ /dev/null @@ -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, - ) diff --git a/backend/app/api/v1/endpoints/audit_log.py b/backend/app/api/v1/endpoints/audit_log.py deleted file mode 100644 index e0fd5db..0000000 --- a/backend/app/api/v1/endpoints/audit_log.py +++ /dev/null @@ -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} diff --git a/backend/app/api/v1/endpoints/audit_rule.py b/backend/app/api/v1/endpoints/audit_rule.py deleted file mode 100644 index 6550b0c..0000000 --- a/backend/app/api/v1/endpoints/audit_rule.py +++ /dev/null @@ -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 diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py deleted file mode 100644 index 977511c..0000000 --- a/backend/app/api/v1/endpoints/auth.py +++ /dev/null @@ -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": "已登出"} diff --git a/backend/app/api/v1/endpoints/backup.py b/backend/app/api/v1/endpoints/backup.py deleted file mode 100644 index 52d5cae..0000000 --- a/backend/app/api/v1/endpoints/backup.py +++ /dev/null @@ -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 diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py deleted file mode 100644 index 023f37a..0000000 --- a/backend/app/api/v1/endpoints/dashboard.py +++ /dev/null @@ -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 diff --git a/backend/app/api/v1/endpoints/db_types.py b/backend/app/api/v1/endpoints/db_types.py deleted file mode 100644 index 817e46e..0000000 --- a/backend/app/api/v1/endpoints/db_types.py +++ /dev/null @@ -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)} diff --git a/backend/app/api/v1/endpoints/dictionary.py b/backend/app/api/v1/endpoints/dictionary.py deleted file mode 100644 index 0c9e293..0000000 --- a/backend/app/api/v1/endpoints/dictionary.py +++ /dev/null @@ -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} diff --git a/backend/app/api/v1/endpoints/impact.py b/backend/app/api/v1/endpoints/impact.py deleted file mode 100644 index fec35c0..0000000 --- a/backend/app/api/v1/endpoints/impact.py +++ /dev/null @@ -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() diff --git a/backend/app/api/v1/endpoints/instance.py b/backend/app/api/v1/endpoints/instance.py deleted file mode 100644 index fe5b159..0000000 --- a/backend/app/api/v1/endpoints/instance.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/endpoints/instance_detail.py b/backend/app/api/v1/endpoints/instance_detail.py deleted file mode 100644 index 3700f34..0000000 --- a/backend/app/api/v1/endpoints/instance_detail.py +++ /dev/null @@ -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)) diff --git a/backend/app/api/v1/endpoints/instance_group.py b/backend/app/api/v1/endpoints/instance_group.py deleted file mode 100644 index 7f4f011..0000000 --- a/backend/app/api/v1/endpoints/instance_group.py +++ /dev/null @@ -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": "用户权限组已更新"} diff --git a/backend/app/api/v1/endpoints/ldap_config.py b/backend/app/api/v1/endpoints/ldap_config.py deleted file mode 100644 index 1d0204b..0000000 --- a/backend/app/api/v1/endpoints/ldap_config.py +++ /dev/null @@ -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)}") diff --git a/backend/app/api/v1/endpoints/masking_rule.py b/backend/app/api/v1/endpoints/masking_rule.py deleted file mode 100644 index d8083fa..0000000 --- a/backend/app/api/v1/endpoints/masking_rule.py +++ /dev/null @@ -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], - } diff --git a/backend/app/api/v1/endpoints/mongo.py b/backend/app/api/v1/endpoints/mongo.py deleted file mode 100644 index 106c652..0000000 --- a/backend/app/api/v1/endpoints/mongo.py +++ /dev/null @@ -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}") diff --git a/backend/app/api/v1/endpoints/notification.py b/backend/app/api/v1/endpoints/notification.py deleted file mode 100644 index 6532c1b..0000000 --- a/backend/app/api/v1/endpoints/notification.py +++ /dev/null @@ -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 diff --git a/backend/app/api/v1/endpoints/pool.py b/backend/app/api/v1/endpoints/pool.py deleted file mode 100644 index eefdce7..0000000 --- a/backend/app/api/v1/endpoints/pool.py +++ /dev/null @@ -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()], - } diff --git a/backend/app/api/v1/endpoints/query.py b/backend/app/api/v1/endpoints/query.py deleted file mode 100644 index 90568b1..0000000 --- a/backend/app/api/v1/endpoints/query.py +++ /dev/null @@ -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"], - }, - ] - } diff --git a/backend/app/api/v1/endpoints/query_history.py b/backend/app/api/v1/endpoints/query_history.py deleted file mode 100644 index bc713eb..0000000 --- a/backend/app/api/v1/endpoints/query_history.py +++ /dev/null @@ -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": "快捷查询已创建"} diff --git a/backend/app/api/v1/endpoints/redis.py b/backend/app/api/v1/endpoints/redis.py deleted file mode 100644 index 4e06687..0000000 --- a/backend/app/api/v1/endpoints/redis.py +++ /dev/null @@ -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)) diff --git a/backend/app/api/v1/endpoints/rollback.py b/backend/app/api/v1/endpoints/rollback.py deleted file mode 100644 index 196a6e0..0000000 --- a/backend/app/api/v1/endpoints/rollback.py +++ /dev/null @@ -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, - ) diff --git a/backend/app/api/v1/endpoints/scheduled_task.py b/backend/app/api/v1/endpoints/scheduled_task.py deleted file mode 100644 index ff5116f..0000000 --- a/backend/app/api/v1/endpoints/scheduled_task.py +++ /dev/null @@ -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 - ] diff --git a/backend/app/api/v1/endpoints/schema_drift.py b/backend/app/api/v1/endpoints/schema_drift.py deleted file mode 100644 index d0f6bc8..0000000 --- a/backend/app/api/v1/endpoints/schema_drift.py +++ /dev/null @@ -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 变更历史功能开发中,当前使用内存存储", - } diff --git a/backend/app/api/v1/endpoints/settings.py b/backend/app/api/v1/endpoints/settings.py deleted file mode 100644 index e105095..0000000 --- a/backend/app/api/v1/endpoints/settings.py +++ /dev/null @@ -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 diff --git a/backend/app/api/v1/endpoints/sql_assistant.py b/backend/app/api/v1/endpoints/sql_assistant.py deleted file mode 100644 index ebf2251..0000000 --- a/backend/app/api/v1/endpoints/sql_assistant.py +++ /dev/null @@ -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 - - PostgreSQL: EXPLAIN (FORMAT JSON, ANALYZE false) - - SQLite: EXPLAIN QUERY PLAN - - 返回统一的树形执行计划、优化警告和汇总统计。 - """ - 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 diff --git a/backend/app/api/v1/endpoints/ticket.py b/backend/app/api/v1/endpoints/ticket.py deleted file mode 100644 index 6bb3d67..0000000 --- a/backend/app/api/v1/endpoints/ticket.py +++ /dev/null @@ -1,1601 +0,0 @@ -"""工单管理 API — 支持多数据库的变更工单生命周期管理。 - -核心功能: -- 工单创建、审核、执行、取消、回滚 -- 多级审批流支持 -- 工单状态机管理(严格的状态转换) -- 自动 SQL 审核(创建时触发) -- 执行前二次确认 -- 执行后通知触发 -- 完整操作审计 -""" - -from datetime import datetime, timezone -from enum import Enum -from typing import TYPE_CHECKING, Any, Dict, List, Optional - -from fastapi import APIRouter, Depends, HTTPException, Query, Request, status -from pydantic import BaseModel, Field, field_validator -from sqlalchemy import select, func, or_ -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.ticket import Ticket -from app.models.instance import Instance -from app.models.workflow import WorkflowDefinition, WorkflowStep, TicketApproval -from app.models.audit_log import AuditLog -from app.schemas.ticket import TicketCreate, TicketReview, TicketResponse -from app.core.crypto import decrypt_password -from app.core.permissions import check_instance_access, check_instance_permission -from app.services.audit_engine import SQLAuditEngine -from app.services.audit_engine_v2 import SQLAuditEngineV2 -from app.services.audit_log_service import AuditLogService -from app.services.query_service import MAX_QUERY_LENGTH -import json -import logging - -logger = logging.getLogger(__name__) - - -def _generate_ticket_no(ticket_id: int) -> str: - """生成工单编号,格式: YYYYMMDDHHmmss + 微秒前3位 + 序号补齐。 - - 示例: 20260522160543841 - - Args: - ticket_id: 工单自增ID,用作末尾序号 - - Returns: - 工单编号字符串 - """ - now = datetime.now() - base = now.strftime("%Y%m%d%H%M%S") - ms = f"{now.microsecond:06d}"[:3] # 取前3位毫秒 - seq = str(ticket_id).zfill(3)[-3:] # ID 补齐到3位 - return f"{base}{ms}{seq}" - - -def _classify_sql_type(sql: str) -> str: - """根据单条 SQL 语句判断类型。""" - s = sql.strip().upper() - if s.startswith(("SELECT", "SHOW", "EXPLAIN", "DESC", "DESCRIBE")): - return "DQL" - if s.startswith(("INSERT", "UPDATE", "DELETE", "REPLACE")): - return "DML" - if s.startswith(("CREATE", "ALTER", "DROP", "RENAME", "TRUNCATE")): - return "DDL" - if s.startswith(("GRANT", "REVOKE")): - return "DCL" - if s.startswith(("SET", "GET", "HSET", "HMSET", "LPUSH", "RPUSH", "SADD", "ZADD", "DEL", "FLUSHDB", "FLUSHALL")): - return "SET" - return "OTHER" - - -async def _update_sql_items_from_report(db, ticket_id: int, report) -> None: - """根据执行报告更新 SQL items 状态。""" - from app.models.ticket_sql_item import TicketSqlItem - - result = await db.execute( - select(TicketSqlItem) - .where(TicketSqlItem.ticket_id == ticket_id) - .order_by(TicketSqlItem.seq) - ) - items = result.scalars().all() - if not items: - return - - if report.status == "success": - for item in items: - item.status = "success" - item.finished_at = datetime.now(timezone.utc) - else: - # 尝试从 raw_output 解析逐条结果 - exec_errors = getattr(report, "errors", []) - error_stmt = "" - if exec_errors: - err_msg = str(exec_errors[0]) if exec_errors else "" - # 找到出错的语句 - for item in items: - if item.status == "pending": - if not error_stmt: - item.status = "failed" - item.error_message = err_msg - item.finished_at = datetime.now(timezone.utc) - error_stmt = item.sql_text[:50] - else: - item.status = "skipped" - - await db.flush() - - -async def _mark_sql_items_failed(db, ticket_id: int, error_msg: str) -> None: - """标记所有 pending 的 SQL items 为 failed。""" - from app.models.ticket_sql_item import TicketSqlItem - - result = await db.execute( - select(TicketSqlItem) - .where( - TicketSqlItem.ticket_id == ticket_id, - TicketSqlItem.status == "pending", - ) - ) - items = result.scalars().all() - for item in items: - item.status = "failed" - item.error_message = error_msg[:500] - item.finished_at = datetime.now(timezone.utc) - await db.flush() - - -async def _check_maintenance_window(db) -> Optional[str]: - """检查当前是否在维护窗口内。 - - Returns: - None = 允许执行(无窗口限制或在窗口内) - str = 窗口描述(表示不在窗口内,不允许执行) - """ - from app.models.system_setting import SystemSetting - result = await db.execute( - select(SystemSetting).where(SystemSetting.key == "maintenance_window") - ) - setting = result.scalar_one_or_none() - if not setting or not setting.value: - return None # 无配置,不限制 - - import json as _json - try: - window = _json.loads(setting.value) if isinstance(setting.value, str) else setting.value - except Exception: - return None - - enabled = window.get("enabled", False) - if not enabled: - return None - - # 格式: {"enabled": true, "start": "22:00", "end": "06:00", "days": ["mon","tue","wed","thu","fri"]} - start_time = window.get("start", "22:00") - end_time = window.get("end", "06:00") - days = window.get("days", []) # 空=每天 - - now = datetime.now() - current_day = now.strftime("%a").lower()[:3] - current_time = now.strftime("%H:%M") - - # 检查星期 - if days and current_day not in [d.lower()[:3] for d in days]: - return f"{start_time}-{end_time} ({'/'.join(days)})" - - # 检查时间(支持跨午夜) - if start_time <= end_time: - if not (start_time <= current_time <= end_time): - return f"{start_time}-{end_time}" - else: - # 跨午夜: 22:00-06:00 意味着 22:00~24:00 或 00:00~06:00 - if not (current_time >= start_time or current_time <= end_time): - return f"{start_time}-{end_time}(次日)" - - return None # 在窗口内,允许执行 - - -if TYPE_CHECKING: - from app.services.ticket_executor import ExecutionReport - -router = APIRouter(prefix="/tickets", tags=["工单管理"]) - - -# ===== 工单状态机 ===== - -class TicketStatus(str, Enum): - """工单状态枚举。""" - PENDING = "pending" # 待审批 - REVIEWING = "reviewing" # 审批中(多级审批) - APPROVED = "approved" # 已通过 - SCHEDULED = "scheduled" # 定时执行中 - REJECTED = "rejected" # 已拒绝 - EXECUTING = "executing" # 执行中 - SUCCESS = "success" # 执行成功 - FAILED = "failed" # 执行失败 - ROLLBACK = "rollback" # 已回滚 - - -# 状态转换图: 当前状态 -> 允许的目标状态 -TICKET_STATE_TRANSITIONS: Dict[str, List[str]] = { - TicketStatus.PENDING.value: [TicketStatus.REVIEWING.value, TicketStatus.APPROVED.value, TicketStatus.REJECTED.value], - TicketStatus.REVIEWING.value: [TicketStatus.REVIEWING.value, TicketStatus.APPROVED.value, TicketStatus.REJECTED.value], - TicketStatus.APPROVED.value: [TicketStatus.EXECUTING.value, TicketStatus.SCHEDULED.value, TicketStatus.REJECTED.value], - TicketStatus.SCHEDULED.value: [TicketStatus.EXECUTING.value, TicketStatus.APPROVED.value], - TicketStatus.EXECUTING.value: [TicketStatus.SUCCESS.value, TicketStatus.FAILED.value], - TicketStatus.SUCCESS.value: [TicketStatus.ROLLBACK.value], - TicketStatus.FAILED.value: [TicketStatus.ROLLBACK.value, TicketStatus.APPROVED.value], - TicketStatus.REJECTED.value: [], # 终态,不可再转换 - TicketStatus.ROLLBACK.value: [], # 终态,不可再转换 -} - - -def _validate_status_transition(current_status: str, new_status: str) -> bool: - """验证工单状态转换是否合法。 - - Args: - current_status: 当前状态 - new_status: 目标状态 - - Returns: - 转换是否合法 - """ - allowed = TICKET_STATE_TRANSITIONS.get(current_status, []) - return new_status in allowed - - -# ===== 请求模型 ===== - - -class AuditRequest(BaseModel): - """SQL 审核请求。""" - sql_content: str = Field(..., min_length=1, max_length=MAX_QUERY_LENGTH) - instance_id: int - database: str = "" - - @field_validator('sql_content') - @classmethod - def validate_sql_length(cls, v): - if len(v) > MAX_QUERY_LENGTH: - raise ValueError(f"SQL 内容不能超过 {MAX_QUERY_LENGTH} 字符") - return v - - -class TicketExecuteConfirm(BaseModel): - """工单执行二次确认请求。""" - confirmed: bool = Field(..., description="是否确认执行") - comment: str = Field(default="", max_length=500, description="执行备注") - - -# ===== 辅助函数 ===== - - -async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]: - """丰富工单数据,添加申请人/审批人/实例名称/当前审核人等信息。""" - data = { - "id": ticket.id, - "ticket_no": ticket.ticket_no, - "title": ticket.title, - "db_type": ticket.db_type, - "instance_id": ticket.instance_id, - "target_database": ticket.target_database, - "sql_content": ticket.sql_content, - "rollback_sql": ticket.rollback_sql, - "status": ticket.status, - "priority": ticket.priority, - "risk_level": getattr(ticket, "risk_level", "low"), - "workflow_id": getattr(ticket, "workflow_id", None), - "applicant_id": ticket.applicant_id, - "approver_id": ticket.approver_id, - "reason": ticket.reason, - "audit_result": ticket.audit_result, - "execute_result": ticket.execute_result, - "create_time": ticket.create_time, - "review_time": ticket.review_time, - "execute_time": ticket.execute_time, - } - - # Resolve applicant name - if ticket.applicant_id: - r = await db.execute(select(User).where(User.id == ticket.applicant_id)) - applicant = r.scalar_one_or_none() - data["applicant_name"] = applicant.display_name if applicant else "未知" - - # Resolve approver name (最终审批人) - if ticket.approver_id: - r = await db.execute(select(User).where(User.id == ticket.approver_id)) - approver = r.scalar_one_or_none() - data["approver_name"] = approver.display_name if approver else "未知" - - # Resolve instance name - if ticket.instance_id: - r = await db.execute(select(Instance).where(Instance.id == ticket.instance_id)) - inst = r.scalar_one_or_none() - data["instance_name"] = inst.name if inst else "未知" - - # Resolve current approver (审批/执行阶段的当前待审核人) - data["current_approver_name"] = None - data["pending_steps"] = [] - if ticket.status in ("pending", "reviewing", "approved", "executing"): - pending_result = await db.execute( - select(TicketApproval) - .where( - TicketApproval.ticket_id == ticket.id, - TicketApproval.action == "pending", - ) - .order_by(TicketApproval.step_number) - ) - pending_list = pending_result.scalars().all() - - pending_names_all = [] - for pa in pending_list: - step_info = { - "step_number": pa.step_number, - "step_name": pa.step_name, - "step_type": pa.step_type, - "approver_names": [], - } - # Try ticket_approval.approver_id first - if pa.approver_id: - r = await db.execute(select(User).where(User.id == pa.approver_id)) - u = r.scalar_one_or_none() - if u: - step_info["approver_names"].append(u.display_name or u.username) - # Fallback: resolve from workflow_steps.approver_ids - if not step_info["approver_names"] and ticket.workflow_id: - ws_result = await db.execute( - select(WorkflowStep).where( - WorkflowStep.workflow_id == ticket.workflow_id, - WorkflowStep.step_number == pa.step_number, - ) - ) - ws = ws_result.scalar_one_or_none() - if ws and ws.approver_ids: - try: - import json as _json - ids = _json.loads(ws.approver_ids) if isinstance(ws.approver_ids, str) else ws.approver_ids - for uid in (ids or []): - r = await db.execute(select(User).where(User.id == uid)) - u = r.scalar_one_or_none() - if u: - step_info["approver_names"].append(u.display_name or u.username) - except (json.JSONDecodeError, TypeError): - pass - - if step_info["approver_names"]: - pending_names_all.extend(step_info["approver_names"]) - data["pending_steps"].append(step_info) - - # Backward compat: single string - if pending_names_all: - data["current_approver_name"] = ", ".join(dict.fromkeys(pending_names_all)) - - return data - - -async def _get_instance(db: AsyncSession, instance_id: int) -> Instance: - """获取数据源实例,不存在则抛出 404。 - - Args: - db: 数据库会话 - instance_id: 实例ID - - Returns: - Instance 对象 - - 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 _get_ticket(db: AsyncSession, ticket_id: int) -> Ticket: - """获取工单,不存在则抛出 404。 - - Args: - db: 数据库会话 - ticket_id: 工单ID - - Returns: - Ticket 对象 - - 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 - - -def _validate_ticket_content(data: TicketCreate) -> None: - """验证工单内容安全性。 - - Args: - data: 工单创建数据 - - Raises: - HTTPException: 验证失败 - """ - # SQL 内容长度检查 - if len(data.sql_content) > MAX_QUERY_LENGTH: - raise HTTPException( - status_code=400, - detail=f"SQL 内容过长({len(data.sql_content)} 字符),最大允许 {MAX_QUERY_LENGTH} 字符", - ) - - # 检测 null 字节 - if "\x00" in data.sql_content: - raise HTTPException(status_code=400, detail="SQL 内容包含非法字符(null 字节)") - - # 检测零宽字符(可能的注入攻击) - zero_width_chars = ["\u200b", "\u200c", "\u200d", "\ufeff"] - for char in zero_width_chars: - if char in data.sql_content: - raise HTTPException( - status_code=400, - detail="SQL 内容包含非法的零宽 Unicode 字符", - ) - - # 回滚 SQL 长度检查 - if data.rollback_sql and len(data.rollback_sql) > MAX_QUERY_LENGTH: - raise HTTPException( - status_code=400, - detail=f"回滚 SQL 过长({len(data.rollback_sql)} 字符),最大允许 {MAX_QUERY_LENGTH} 字符", - ) - - -# ===== API 端点 ===== - - -@router.get("") -async def list_tickets( - page: int = Query(1, ge=1, description="页码"), - page_size: int = Query(10, ge=1, le=100, description="每页数量"), - status: Optional[str] = None, - db_type: Optional[str] = None, - keyword: Optional[str] = None, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """获取工单列表(按用户身份过滤)。""" - stmt = select(Ticket) - - # 权限过滤 - if current_user.role == "admin": - pass # admin 看全部 - elif current_user.role == "developer": - stmt = stmt.where(Ticket.applicant_id == current_user.id) - else: - # dba/auditor: 自己提交的 + 自己需要审批的 - from app.models.workflow import TicketApproval as _TA - _my_sub = set((await db.execute( - select(Ticket.id).where(Ticket.applicant_id == current_user.id) - )).scalars().all()) - _my_appr = set((await db.execute( - select(_TA.ticket_id).where( - _TA.action == "pending", - _TA.approver_id == current_user.id, - ) - )).scalars().all()) - _my_role = set((await db.execute( - select(_TA.ticket_id).where( - _TA.action == "pending", - _TA.approver_role == current_user.role, - ) - )).scalars().all()) - _all_ids = _my_sub | _my_appr | _my_role - if _all_ids: - stmt = stmt.where(Ticket.id.in_(_all_ids)) - else: - stmt = stmt.where(Ticket.applicant_id == current_user.id) - - # 状态过滤 - if status: - # 验证状态值合法性 - valid_statuses = [s.value for s in TicketStatus] - if status not in valid_statuses: - raise HTTPException(status_code=400, detail=f"无效的状态值: {status}") - stmt = stmt.where(Ticket.status == status) - - if db_type: - stmt = stmt.where(Ticket.db_type == db_type) - - if keyword: - # 限制关键词长度防止 DoS - keyword = keyword[:100] - stmt = stmt.where( - or_( - Ticket.title.contains(keyword), - Ticket.sql_content.contains(keyword), - ) - ) - - # 总数查询 - total_stmt = select(func.count()).select_from(stmt.subquery()) - total_result = await db.execute(total_stmt) - total = total_result.scalar() or 0 - - # 分页查询 - stmt = ( - stmt.order_by(Ticket.create_time.desc()) - .offset((page - 1) * page_size) - .limit(page_size) - ) - result = await db.execute(stmt) - tickets = result.scalars().all() - - items = [] - for t in tickets: - items.append(await _enrich_ticket(t, db)) - - return { - "total": total, - "page": page, - "page_size": page_size, - "items": items, - } - - -@router.post("", status_code=201) -async def create_ticket( - data: TicketCreate, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """创建工单。 - - 流程: - 1. 验证数据源存在和权限 - 2. 验证工单内容安全性 - 3. 自动 SQL 审核 - 4. 创建工单记录 - 5. 如有多级审批流,创建审批步骤 - """ - # 1. 验证数据源 - instance = await _get_instance(db, data.instance_id) - - # 2. 权限检查 - try: - await check_instance_access(current_user, instance, db) - except HTTPException: - raise HTTPException(status_code=403, detail="您没有权限对该数据源提交变更工单") - - await check_instance_permission(current_user, instance, "ticket:create", db) - - # 3. 验证工单内容安全性 - _validate_ticket_content(data) - - # 3.5 EXPLAIN 预检(大表检测合并到 risk_evaluator,不再单独查表大小) - table_sizes = {} - explain_warnings = [] - try: - from app.services.explain_preview import run_explain_preview - import asyncio as _asyncio - explain_result = await _asyncio.wait_for( - run_explain_preview(data.sql_content, instance, data.target_database), - timeout=8.0, - ) - explain_warnings = explain_result.warnings - table_sizes = explain_result.table_sizes or {} - except Exception as e: - import asyncio as _asyncio - logger.warning("EXPLAIN 预检失败(非致命): %s", e) - - # 4. 自动审核 SQL(带超时保护) - env = "production" if instance.env == "production" else "development" - v2_engine = SQLAuditEngineV2(db_session=db, env=env) - try: - v2_report = await _asyncio.wait_for( - v2_engine.audit(data.sql_content, db_type=data.db_type, instance_id=data.instance_id), - timeout=10.0, - ) - except _asyncio.TimeoutError: - logger.warning("SQL 审核超时(10s),跳过审核: ticket title=%s", data.title) - v2_report = None - except Exception as e: - logger.error("SQL 审核失败: %s", e) - v2_report = None - - # 5. 计算风险等级 - from app.services.risk_evaluator import evaluate_risk_level - audit_findings_raw = v2_report.to_dict().get("findings", []) if v2_report else [] - import sqlparse as _sqlparse - stmt_list = [s.strip() for s in _sqlparse.split(data.sql_content) if s.strip()] - risk_level = evaluate_risk_level( - sql_content=data.sql_content, - db_type=data.db_type, - sql_type=data.sql_type or "", - audit_findings=audit_findings_raw, - statement_count=len(stmt_list), - table_sizes=table_sizes, - ) - - # EXPLAIN 结果可能升级风险等级 - if explain_warnings and risk_level == "low": - risk_level = "medium" - - # 6. 匹配审批流 — 优先按 instance.workflow_id,其次按 risk_level 匹配默认流 - workflow = None - if instance.workflow_id: - wf_result = await db.execute( - select(WorkflowDefinition).where( - WorkflowDefinition.id == instance.workflow_id, - WorkflowDefinition.is_active == True, - ) - ) - workflow = wf_result.scalar_one_or_none() - if not workflow: - default_wf_result = await db.execute( - select(WorkflowDefinition).where( - WorkflowDefinition.is_default == True, - WorkflowDefinition.is_active == True, - ) - ) - workflow = default_wf_result.scalar_one_or_none() - - initial_status = TicketStatus.REVIEWING.value if workflow else TicketStatus.PENDING.value - - # 7. 创建工单 - ticket = Ticket( - title=data.title, - db_type=data.db_type, - instance_id=data.instance_id, - target_database=data.target_database, - sql_content=data.sql_content, - rollback_sql=data.rollback_sql, - reason=data.reason, - priority=data.priority, - risk_level=risk_level, - workflow_id=workflow.id if workflow else None, - exec_strategy=data.exec_strategy or "stop_on_error", - applicant_id=current_user.id, - status=initial_status, - audit_result=json.dumps(v2_report.to_dict(), ensure_ascii=False) if v2_report and v2_report.findings else None, - scheduled_at=data.scheduled_at, - ) - db.add(ticket) - await db.flush() # flush to get ticket.id - - # 生成工单编号 - ticket.ticket_no = _generate_ticket_no(ticket.id) - await db.flush() - - # 7. 创建审批步骤(多级审批流) - if workflow: - wf_steps_result = await db.execute( - select(WorkflowStep) - .where(WorkflowStep.workflow_id == workflow.id) - .order_by(WorkflowStep.step_number) - ) - wf_steps = wf_steps_result.scalars().all() - for step in wf_steps: - if step.step_type == "submit": - # 提交阶段自动通过(提交者自己就是审批人) - approval = TicketApproval( - ticket_id=ticket.id, - step_number=step.step_number, - step_name=step.name, - approver_role=step.approver_role, - approver_id=ticket.applicant_id, - actual_approver_id=ticket.applicant_id, - action="approved", - comment="提交工单", - ) - elif step.step_type == "execute": - # 执行阶段不创建审批记录,等所有审核通过后直接执行 - approval = TicketApproval( - ticket_id=ticket.id, - step_number=step.step_number, - step_name=step.name, - approver_role=step.approver_role, - action="pending", - ) - else: - approval = TicketApproval( - ticket_id=ticket.id, - step_number=step.step_number, - step_name=step.name, - approver_role=step.approver_role, - action="pending", - ) - db.add(approval) - await db.flush() - - # 8. 创建 SQL 逐条执行记录 - from app.models.ticket_sql_item import TicketSqlItem - for i, stmt in enumerate(stmt_list, start=1): - stmt_type = _classify_sql_type(stmt) - sql_item = TicketSqlItem( - ticket_id=ticket.id, - seq=i, - sql_text=stmt, - sql_type=stmt_type, - status="pending", - ) - db.add(sql_item) - await db.flush() - - # 9. 记录审计日志 - audit_detail = "" - if v2_report: - audit_detail = f"SQL审核评分: {v2_report.score}, 通过: {v2_report.passed}, 规则数: {len(v2_report.findings)}" - await AuditLogService.log( - db, user_id=current_user.id, action="CREATE_TICKET", - target_type="ticket", target_id=ticket.id, - detail=audit_detail or "工单创建", - ) - - await db.commit() - await db.refresh(ticket) - - # 9. 发送创建通知 - try: - await _send_ticket_notification(db, ticket, "ticket_created") - except Exception as notify_err: - logger.error("创建通知发送失败: %s", notify_err) - - result = await _enrich_ticket(ticket, db) - if v2_report: - result["audit_report"] = v2_report.to_dict() - return result - - -@router.get("/statistics") -async def ticket_statistics( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """获取工单统计信息(按用户身份过滤)。""" - # 构建用户可见工单的基础查询 - base_stmt = select(Ticket) - if current_user.role == "developer": - # developer 只看自己提交的 - base_stmt = base_stmt.where(Ticket.applicant_id == current_user.id) - elif current_user.role not in ("admin",): - # dba/auditor: 看自己提交的 + 自己需要审批的 - from app.models.workflow import TicketApproval - my_approvals = set((await db.execute( - select(TicketApproval.ticket_id).where( - TicketApproval.action == "pending", - TicketApproval.approver_id == current_user.id, - ) - )).scalars().all()) - my_role_approvals = set((await db.execute( - select(TicketApproval.ticket_id).where( - TicketApproval.action == "pending", - TicketApproval.approver_role == current_user.role, - ) - )).scalars().all()) - my_submitted = set((await db.execute( - select(Ticket.id).where(Ticket.applicant_id == current_user.id) - )).scalars().all()) - all_ids = my_submitted | my_approvals | my_role_approvals - if all_ids: - base_stmt = base_stmt.where(Ticket.id.in_(all_ids)) - else: - base_stmt = base_stmt.where(Ticket.applicant_id == current_user.id) - - sub = base_stmt.subquery() - stmt = select(sub.c.status, func.count(sub.c.id)).group_by(sub.c.status) - result = await db.execute(stmt) - stats = {row[0]: row[1] for row in result.all()} - - total = sum(stats.values()) - return { - "total": total, - "pending": stats.get("pending", 0), - "reviewing": stats.get("reviewing", 0), - "approved": stats.get("approved", 0), - "rejected": stats.get("rejected", 0), - "executing": stats.get("executing", 0), - "success": stats.get("success", 0), - "failed": stats.get("failed", 0), - "rollback": stats.get("rollback", 0), - } - - -@router.post("/audit") -async def audit_sql( - data: AuditRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """SQL 审核接口(独立调用)。""" - # 验证实例存在 - instance = await _get_instance(db, data.instance_id) - - env = "production" if instance.env == "production" else "development" - db_type = instance.db_type or "mysql" - - # 使用 V2 引擎进行审核 - v2_engine = SQLAuditEngineV2(db_session=db, env=env) - try: - report = await v2_engine.audit(data.sql_content, db_type=db_type) - except Exception as e: - logger.error("SQL 审核异常: %s", e) - raise HTTPException(status_code=500, detail=f"审核引擎异常: {e}") - - return { - "score": report.score, - "passed": report.passed, - "has_warnings": report.has_warnings, - "risk_level": getattr(report, 'risk_level', 'unknown'), - "summary": getattr(report, 'summary', ''), - "findings": [ - { - "rule": getattr(f, 'rule_code', getattr(f, 'rule', 'unknown')), - "level": f.level.value, - "message": f.message, - "risk_level": getattr(f, 'risk_level', 'medium'), - "suggestion": getattr(f, 'suggestion', ''), - } - for f in report.findings - ], - } - - -@router.get("/{ticket_id}") -async def get_ticket( - ticket_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """获取工单详情。""" - ticket = await _get_ticket(db, ticket_id) - - # 权限检查: developer 只能查看自己的工单 - if current_user.role == "developer" and ticket.applicant_id != current_user.id: - raise HTTPException(status_code=403, detail="无权查看该工单") - - return await _enrich_ticket(ticket, db) - - -@router.get("/{ticket_id}/approvals") -async def get_ticket_approvals( - ticket_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """获取工单的审批进度(多级审批流)。""" - ticket = await _get_ticket(db, ticket_id) - - # 权限检查 - if current_user.role == "developer" and ticket.applicant_id != current_user.id: - raise HTTPException(status_code=403, detail="无权查看该工单") - - approvals_result = await db.execute( - select(TicketApproval) - .where(TicketApproval.ticket_id == ticket_id) - .order_by(TicketApproval.step_number) - ) - approvals = approvals_result.scalars().all() - - # Enrich with approver display names - result = [] - for a in approvals: - approver_name = None - if a.approver_id: - u = await db.execute(select(User).where(User.id == a.approver_id)) - approver_name = u.scalar_one_or_none() - approver_name = approver_name.display_name if approver_name else None - - result.append({ - "id": a.id, - "step_number": a.step_number, - "step_name": a.step_name, - "step_type": a.step_type, - "approver_role": a.approver_role, - "action": a.action, - "approver_id": a.approver_id, - "approver_name": approver_name, - "comment": a.comment, - "created_at": a.created_at.isoformat() if a.created_at else None, - "updated_at": a.updated_at.isoformat() if a.updated_at else None, - "completed_at": a.completed_at.isoformat() if a.completed_at else None, - }) - - return result - - -@router.post("/{ticket_id}/review") -async def review_ticket( - ticket_id: int, - data: TicketReview, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_role("admin", "dba", "auditor")), -): - """审核工单。 - - 支持多级审批流: - - 通过: 按顺序审批,所有步骤通过后状态变为 approved - - 拒绝: 立即拒绝整个工单 - """ - ticket = await _get_ticket(db, ticket_id) - - # 状态机验证: 只能审批 pending 或 reviewing 状态的工单 - if ticket.status not in (TicketStatus.PENDING.value, TicketStatus.REVIEWING.value): - raise HTTPException( - status_code=400, - detail=f"工单状态为 {ticket.status},无法审核。只能审批 pending/reviewing 状态的工单" - ) - - if data.action == "approve": - # 检查多级审批流 - pending_approvals_result = await db.execute( - select(TicketApproval) - .where(TicketApproval.ticket_id == ticket_id) - .order_by(TicketApproval.step_number) - ) - pending_approvals = pending_approvals_result.scalars().all() - - if pending_approvals: - # 加载审批流步骤定义(用于判断 step_type) - wf_steps = [] - if ticket.workflow_id: - ws_result = await db.execute( - select(WorkflowStep) - .where(WorkflowStep.workflow_id == ticket.workflow_id) - .order_by(WorkflowStep.step_number) - ) - wf_steps = ws_result.scalars().all() - - # 多级审批: 找到当前待审批步骤 - current_step = None - for pa in pending_approvals: - if pa.action == "pending": - current_step = pa - break - - if current_step: - # 审批当前步骤 - current_step.action = "approved" - current_step.approver_id = current_user.id - current_step.comment = data.comment or "审批通过" - - # 检查是否还有后续待审批步骤(执行阶段不算,它只需要状态为approved即可执行) - has_next = False - for pa in pending_approvals: - if pa.id != current_step.id and pa.action == "pending": - # 查对应 workflow step 的 step_type - ws = next((s for s in wf_steps if s.step_number == pa.step_number), None) - if ws and ws.step_type != "execute": - has_next = True - break - if has_next: - new_status = TicketStatus.REVIEWING.value - else: - new_status = TicketStatus.APPROVED.value - else: - # 所有步骤已处理 - new_status = TicketStatus.APPROVED.value - else: - # 无审批流 - 单步审批 - new_status = TicketStatus.APPROVED.value - else: - # 拒绝: 标记所有待审批步骤为拒绝 - pending_approvals_result = await db.execute( - select(TicketApproval) - .where(TicketApproval.ticket_id == ticket_id, TicketApproval.action == "pending") - ) - for pa in pending_approvals_result.scalars().all(): - pa.action = "rejected" - pa.approver_id = current_user.id - pa.comment = data.comment or "审批拒绝" - - new_status = TicketStatus.REJECTED.value - - # 验证状态转换合法性 - if not _validate_status_transition(ticket.status, new_status): - raise HTTPException( - status_code=400, - detail=f"非法的状态转换: {ticket.status} -> {new_status}" - ) - - ticket.status = new_status - ticket.approver_id = current_user.id - ticket.review_time = datetime.now(timezone.utc) - ticket.audit_result = data.comment - - await AuditLogService.log( - db, user_id=current_user.id, action="REVIEW_TICKET", - target_type="ticket", target_id=ticket.id, - detail=f"审核动作: {data.action}, 备注: {data.comment}, 新状态: {new_status}", - ) - - await db.commit() - await db.refresh(ticket) - - # 发送审批通知(仅审批通过时通知,拒绝不通知) - if data.action == "approve": - try: - await _send_ticket_notification(db, ticket, "ticket_approved", { - "approver": current_user.display_name, - "comment": data.comment or "审批通过", - }) - except Exception as notify_err: - logger.error("审批通知发送失败: %s", notify_err) - - return await _enrich_ticket(ticket, db) - - -@router.get("/{ticket_id}/timeline") -async def get_ticket_timeline( - ticket_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """获取工单时间线。""" - ticket = await _get_ticket(db, ticket_id) - - if current_user.role == "developer" and ticket.applicant_id != current_user.id: - raise HTTPException(status_code=403, detail="无权查看该工单") - - # 获取审计日志 - log_stmt = ( - select(AuditLog) - .where(AuditLog.target_type == "ticket", AuditLog.target_id == ticket_id) - .order_by(AuditLog.created_at.asc()) - ) - log_result = await db.execute(log_stmt) - logs = log_result.scalars().all() - - timeline = [] - - # 创建事件 - applicant_name = "未知" - if ticket.applicant_id: - r = await db.execute(select(User).where(User.id == ticket.applicant_id)) - applicant = r.scalar_one_or_none() - if applicant: - applicant_name = applicant.display_name - - timeline.append({ - "action": "CREATE_TICKET", - "label": "创建工单", - "user": applicant_name, - "time": ticket.create_time.isoformat() if ticket.create_time else None, - "detail": "提交 SQL 变更工单", - }) - - # 审计日志事件 - for log in logs: - if log.action == "CREATE_TICKET": - continue - r = await db.execute(select(User).where(User.id == log.user_id)) - u = r.scalar_one_or_none() - uname = u.display_name if u else "未知" - - label_map = { - "REVIEW_TICKET": "审核工单", - "EXECUTE_TICKET": "执行工单", - "CANCEL_TICKET": "取消工单", - "ROLLBACK_TICKET": "回滚工单", - } - timeline.append({ - "action": log.action, - "label": label_map.get(log.action, log.action), - "user": uname, - "time": log.created_at.isoformat() if log.created_at else None, - "detail": log.detail, - }) - - # 基于状态的合成事件 - if ticket.review_time: - has_review = any(t["action"] == "REVIEW_TICKET" for t in timeline) - if not has_review: - approver_name = "未知" - if ticket.approver_id: - r = await db.execute(select(User).where(User.id == ticket.approver_id)) - a = r.scalar_one_or_none() - if a: - approver_name = a.display_name - timeline.append({ - "action": "REVIEW_TICKET", - "label": "审核工单", - "user": approver_name, - "time": ticket.review_time.isoformat() if ticket.review_time else None, - "detail": "工单状态变更为 " + str(ticket.status), - }) - - if ticket.execute_time: - has_exec = any(t["action"] == "EXECUTE_TICKET" for t in timeline) - if not has_exec: - timeline.append({ - "action": "EXECUTE_TICKET", - "label": "执行工单", - "user": "系统", - "time": ticket.execute_time.isoformat() if ticket.execute_time else None, - "detail": ticket.execute_result or "执行完成", - }) - - return {"ticket_id": ticket_id, "timeline": timeline} - - -@router.get("/{ticket_id}/sql-items") -async def get_ticket_sql_items( - ticket_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """获取工单 SQL 逐条执行进度。""" - from app.models.ticket_sql_item import TicketSqlItem - - result = await db.execute( - select(TicketSqlItem) - .where(TicketSqlItem.ticket_id == ticket_id) - .order_by(TicketSqlItem.seq) - ) - items = result.scalars().all() - - return { - "ticket_id": ticket_id, - "total": len(items), - "items": [ - { - "id": item.id, - "seq": item.seq, - "sql_text": item.sql_text, - "sql_type": item.sql_type, - "status": item.status, - "affected_rows": item.affected_rows, - "duration_ms": item.duration_ms, - "error_message": item.error_message, - "started_at": item.started_at.isoformat() if item.started_at else None, - "finished_at": item.finished_at.isoformat() if item.finished_at else None, - } - for item in items - ], - "progress": { - "total": len(items), - "success": sum(1 for i in items if i.status == "success"), - "failed": sum(1 for i in items if i.status == "failed"), - "running": sum(1 for i in items if i.status == "running"), - "pending": sum(1 for i in items if i.status == "pending"), - "skipped": sum(1 for i in items if i.status == "skipped"), - }, - } - - -@router.post("/{ticket_id}/cancel") -async def cancel_ticket( - ticket_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """取消工单。 - - 只有申请人或管理员可以取消 pending/reviewing 状态的工单。 - """ - ticket = await _get_ticket(db, ticket_id) - - # 权限检查 - if ticket.applicant_id != current_user.id and current_user.role != "admin": - raise HTTPException(status_code=403, detail="只有申请人或管理员可以取消工单") - - # 状态检查 - if ticket.status not in (TicketStatus.PENDING.value, TicketStatus.REVIEWING.value): - raise HTTPException(status_code=400, detail="只能取消待审批或审批中的工单") - - # 状态转换 - new_status = TicketStatus.REJECTED.value - if not _validate_status_transition(ticket.status, new_status): - raise HTTPException( - status_code=400, - detail=f"非法的状态转换: {ticket.status} -> {new_status}" - ) - - ticket.status = new_status - ticket.audit_result = "工单被取消" - - # 更新审批步骤状态 - pending_approvals_result = await db.execute( - select(TicketApproval) - .where(TicketApproval.ticket_id == ticket_id, TicketApproval.action == "pending") - ) - for pa in pending_approvals_result.scalars().all(): - pa.action = "cancelled" - pa.comment = "工单被取消" - - await AuditLogService.log( - db, user_id=current_user.id, action="CANCEL_TICKET", - target_type="ticket", target_id=ticket.id, - detail="取消工单", - ) - - await db.commit() - await db.refresh(ticket) - return await _enrich_ticket(ticket, db) - - -@router.post("/{ticket_id}/rollback") -async def rollback_ticket( - ticket_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_role("admin", "dba")), -): - """回滚工单。 - - 只能回滚 success 或 failed 状态的工单。 - """ - ticket = await _get_ticket(db, ticket_id) - - if ticket.status not in (TicketStatus.SUCCESS.value, TicketStatus.FAILED.value): - raise HTTPException(status_code=400, detail="只能回滚已执行(成功或失败)的工单") - - # 状态转换 - new_status = TicketStatus.ROLLBACK.value - if not _validate_status_transition(ticket.status, new_status): - raise HTTPException( - status_code=400, - detail=f"非法的状态转换: {ticket.status} -> {new_status}" - ) - - rollback_sql = ticket.rollback_sql or "未提供回滚 SQL" - - await AuditLogService.log( - db, user_id=current_user.id, action="ROLLBACK_TICKET", - target_type="ticket", target_id=ticket.id, - detail=f"请求回滚工单,回滚SQL: {rollback_sql[:200]}", - ) - - ticket.status = new_status - await db.commit() - await db.refresh(ticket) - - return { - "message": "回滚请求已记录,请手动执行以下回滚 SQL", - "ticket_id": ticket_id, - "rollback_sql": rollback_sql, - "status": ticket.status, - } - - -@router.post("/{ticket_id}/execute") -async def execute_ticket( - ticket_id: int, - confirm: Optional[TicketExecuteConfirm] = None, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_role("admin", "dba")), -): - """执行工单。 - - 使用多数据库工单执行引擎,按 db_type 自动分发到对应执行器。 - - 执行流程: - 1. 验证工单状态为 approved - 2. 二次确认(可选) - 3. 更新状态为 executing - 4. 执行变更 - 5. 更新结果和状态 - 6. 记录审计日志 - 7. 发送通知 - - Args: - ticket_id: 工单 ID - confirm: 二次确认参数 - db: 数据库会话 - current_user: 当前用户(需要 admin 或 dba 角色) - - Returns: - 执行后的工单详情,包含执行报告 - """ - # 1. 查询工单 - ticket = await _get_ticket(db, ticket_id) - - # 2. 状态检查 - if ticket.status != TicketStatus.APPROVED.value: - raise HTTPException(status_code=400, detail="只能执行已审批的工单") - - # 3. 二次确认检查 - if confirm and not confirm.confirmed: - raise HTTPException(status_code=400, detail="未确认执行,操作已取消") - - # 3.1 高危操作强制二次确认 - risk_level = getattr(ticket, "risk_level", "low") - if risk_level in ("high", "critical"): - if not confirm or not confirm.confirmed: - from app.services.risk_evaluator import RISK_LEVEL_CONFIG - risk_cfg = RISK_LEVEL_CONFIG.get(risk_level, {}) - raise HTTPException( - status_code=409, - detail=f"该工单风险等级为「{risk_cfg.get('label', risk_level)}」,需要二次确认后才能执行。请传入 confirmed=true", - ) - - # 4. 查询数据源实例 - instance = await _get_instance(db, ticket.instance_id) - - # 5. 权限检查 - await check_instance_permission(current_user, instance, "ticket:execute", db) - - # 5.1 维护窗口检查 — 非定时工单检查是否在维护窗口内 - if not ticket.scheduled_at: - maintenance_window = await _check_maintenance_window(db) - if maintenance_window: - raise HTTPException( - status_code=403, - detail=f"当前不在维护窗口内,允许执行时间: {maintenance_window}", - ) - - # 6. 定时执行:如果 scheduled_at 在未来,改为 scheduled 状态 - if ticket.scheduled_at: - now = datetime.now(timezone.utc) - scheduled_aware = ticket.scheduled_at.replace(tzinfo=timezone.utc) if ticket.scheduled_at.tzinfo is None else ticket.scheduled_at - if scheduled_aware > now: - if not _validate_status_transition(ticket.status, TicketStatus.SCHEDULED.value): - raise HTTPException(status_code=400, detail=f"非法的状态转换: {ticket.status} -> scheduled") - ticket.status = TicketStatus.SCHEDULED.value - await db.commit() - await db.refresh(ticket) - - # 启动后台定时执行 - from app.services.ticket_scheduler import schedule_ticket_execution - await schedule_ticket_execution(ticket.id, scheduled_aware) - - ticket_data = await _enrich_ticket(ticket, db) - return ticket_data - - # 7. 状态转换: approved -> executing - if not _validate_status_transition(ticket.status, TicketStatus.EXECUTING.value): - raise HTTPException( - status_code=400, - detail=f"非法的状态转换: {ticket.status} -> {TicketStatus.EXECUTING.value}" - ) - - ticket.status = TicketStatus.EXECUTING.value - await db.commit() - await db.refresh(ticket) - - # 8. 后台异步执行(避免大表操作阻塞 HTTP 连接) - import asyncio - from app.services.ticket_executor import TicketExecutor - - async def _do_execute(): - """后台执行工单,独立 session 更新结果。""" - from app.core.database import async_session - async with async_session() as bg_db: - try: - # 重新加载对象 - t = await bg_db.get(type(ticket), ticket.id) - inst = await bg_db.get(type(instance), instance.id) - if not t or not inst: - return - inst.password = decrypt_password(inst.password) - executor = TicketExecutor() - report = await executor.execute(t, inst) - - # 更新状态 - new_status = TicketStatus.SUCCESS.value if report.status == "success" else TicketStatus.FAILED.value - if _validate_status_transition(TicketStatus.EXECUTING.value, new_status): - t.status = new_status - else: - t.status = TicketStatus.FAILED.value - t.execute_result = report.to_json() - t.execute_time = datetime.now(timezone.utc) - - await _update_sql_items_from_report(bg_db, t.id, report) - - # 审计日志 - log_detail = ( - f"执行结果: {report.status}, 影响行数: {report.affected_rows}, " - f"耗时: {report.duration_ms}ms, 验证通过: {report.validation_passed}" - ) - if report.errors: - log_detail += f", 错误: {'; '.join(report.errors[:3])}" - await AuditLogService.log( - bg_db, user_id=current_user.id, - action="EXECUTE_TICKET", target_type="ticket", - target_id=t.id, detail=log_detail, - ) - # 通知 - try: - await _send_execution_notification(bg_db, t, report) - except Exception as ne: - logger.error("执行通知发送失败: %s", ne) - await bg_db.commit() - except Exception as e: - logger.exception("后台执行工单 %d 异常", ticket_id) - try: - t = await bg_db.get(type(ticket), ticket.id) - if t: - t.status = TicketStatus.FAILED.value - t.execute_result = json.dumps({"status": "failed", "errors": [f"{type(e).__name__}: {e}"]}, ensure_ascii=False) - t.execute_time = datetime.now(timezone.utc) - await bg_db.commit() - except Exception: - logger.exception("更新失败状态异常") - - # 启动后台 task,立即返回 executing 状态 - asyncio.get_event_loop().create_task(_do_execute()) - - ticket_data = await _enrich_ticket(ticket, db) - return ticket_data - - -async def _get_user_name(db: AsyncSession, user_id: Optional[int]) -> str: - """获取用户显示名称。""" - if not user_id: - return "系统" - result = await db.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - return user.display_name if user else "未知" - - -async def _get_instance_name(db: AsyncSession, instance_id: Optional[int]) -> str: - """获取实例名称。""" - if not instance_id: - return "未知实例" - result = await db.execute(select(Instance).where(Instance.id == instance_id)) - inst = result.scalar_one_or_none() - return inst.name if inst else "未知实例" - - -async def _get_enabled_channels(db: AsyncSession) -> list: - """获取已启用的通知渠道。""" - from app.models.notification import NotificationChannel - result = await db.execute( - select(NotificationChannel).where(NotificationChannel.is_enabled == True) - ) - return result.scalars().all() - - -async def _send_ticket_notification( - db: AsyncSession, ticket: Ticket, event_type: str, - extra_data: Optional[Dict[str, Any]] = None, -) -> None: - """通用工单通知发送。 - - Args: - db: 数据库会话 - ticket: 工单对象 - event_type: 事件类型 (ticket_created / ticket_approved / ticket_rejected / ticket_executed / ticket_failed) - extra_data: 额外模板数据 (如 approver, operator, error 等) - """ - from app.services.notification_sender import NotificationSender - - channels = await _get_enabled_channels(db) - if not channels: - return - - # 获取关联信息 - applicant_name = await _get_user_name(db, ticket.applicant_id) - instance_name = await _get_instance_name(db, ticket.instance_id) - sql_preview = ticket.sql_content[:100] + "..." if len(ticket.sql_content) > 100 else ticket.sql_content - - # Redis database 格式化: "0" → "db0" - target_db = ticket.target_database or "" - if ticket.db_type == "redis" and target_db.isdigit(): - target_db = f"db{target_db}" - - notification_data = { - "title": ticket.title, - "ticket_id": ticket.id, - "ticket_no": ticket.ticket_no or str(ticket.id), - "db_type": ticket.db_type, - "target_database": target_db, - "instance_name": instance_name, - "sql_preview": sql_preview, - "applicant": applicant_name, - "approver": "未知", - "operator": "系统", - "comment": "", - "result": "", - "duration": 0, - "affected_rows": 0, - "error": "", - } - - # 合并额外数据 - if extra_data: - notification_data.update(extra_data) - - sender = NotificationSender() - await sender.notify_ticket_event( - ticket_id=ticket.id, - event_type=event_type, - channels=channels, - ticket_data=notification_data, - ) - - -async def _send_execution_notification( - db: AsyncSession, ticket: Ticket, report: "ExecutionReport" -) -> None: - """发送工单执行完成通知(兼容旧调用)。""" - from app.services.notification_sender import NotificationSender - - channels = await _get_enabled_channels(db) - if not channels: - return - - event_type = "ticket_executed" if report.status == "success" else "ticket_failed" - operator_name = await _get_user_name(db, ticket.approver_id) - instance_name = await _get_instance_name(db, ticket.instance_id) - applicant_name = await _get_user_name(db, ticket.applicant_id) - sql_preview = ticket.sql_content[:100] + "..." if len(ticket.sql_content) > 100 else ticket.sql_content - - # Redis database 格式化: "0" → "db0" - target_db = ticket.target_database or "" - if ticket.db_type == "redis" and target_db.isdigit(): - target_db = f"db{target_db}" - - notification_data = { - "title": ticket.title, - "ticket_id": ticket.id, - "ticket_no": ticket.ticket_no or str(ticket.id), - "db_type": ticket.db_type, - "target_database": target_db, - "instance_name": instance_name, - "sql_preview": sql_preview, - "applicant": applicant_name, - "operator": operator_name, - "result": report.status, - "duration": report.duration_ms, - "affected_rows": report.affected_rows, - "error": "; ".join(report.errors[:3]) if report.errors else "", - "approver": operator_name, - "comment": "", - } - - sender = NotificationSender() - await sender.notify_ticket_event( - ticket_id=ticket.id, - event_type=event_type, - channels=channels, - ticket_data=notification_data, - ) - - -# ===== 定时任务管理 ===== - -@router.get("/scheduled/list") -async def list_scheduled_tickets( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_role("admin", "dba")), -): - """列出所有定时执行中的工单。""" - result = await db.execute( - select(Ticket) - .where(Ticket.status == TicketStatus.SCHEDULED.value) - .order_by(Ticket.scheduled_at.asc()) - ) - tickets = result.scalars().all() - data = [] - for t in tickets: - inst_result = await db.execute(select(Instance).where(Instance.id == t.instance_id)) - inst = inst_result.scalar_one_or_none() - data.append({ - "id": t.id, - "ticket_no": t.ticket_no, - "title": t.title, - "db_type": t.db_type, - "instance_name": inst.name if inst else "未知", - "instance_db_type": inst.db_type if inst else "", - "target_database": t.target_database, - "sql_content": t.sql_content[:200] + "..." if len(t.sql_content) > 200 else t.sql_content, - "status": t.status, - "scheduled_at": t.scheduled_at.isoformat() if t.scheduled_at else None, - "priority": t.priority, - "create_time": t.create_time.isoformat() if t.create_time else None, - }) - return {"items": data, "total": len(data)} - - -@router.post("/{ticket_id}/cancel-schedule") -async def cancel_scheduled_ticket( - ticket_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_role("admin", "dba")), -): - """取消定时执行,将工单状态改回 approved。""" - ticket = await _get_ticket(db, ticket_id) - if ticket.status != TicketStatus.SCHEDULED.value: - raise HTTPException(status_code=400, detail="只能取消定时执行中的工单") - - if not _validate_status_transition(ticket.status, TicketStatus.APPROVED.value): - raise HTTPException(status_code=400, detail="非法的状态转换") - - # 取消后台任务 - from app.services.ticket_scheduler import cancel_ticket_execution - cancel_ticket_execution(ticket.id) - - ticket.status = TicketStatus.APPROVED.value - await db.commit() - await db.refresh(ticket) - - await AuditLogService.log( - db, user_id=current_user.id, action="CANCEL_SCHEDULED_TICKET", - target_type="ticket", target_id=ticket.id, - detail=f"取消定时执行: {ticket.scheduled_at}", - ) - - return {"message": "已取消定时执行", "ticket_id": ticket_id, "status": ticket.status} diff --git a/backend/app/api/v1/endpoints/user.py b/backend/app/api/v1/endpoints/user.py deleted file mode 100644 index 1f8c658..0000000 --- a/backend/app/api/v1/endpoints/user.py +++ /dev/null @@ -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": "密码已重置"} diff --git a/backend/app/api/v1/endpoints/workflow.py b/backend/app/api/v1/endpoints/workflow.py deleted file mode 100644 index f00b761..0000000 --- a/backend/app/api/v1/endpoints/workflow.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py deleted file mode 100644 index 9f71607..0000000 --- a/backend/app/api/v1/router.py +++ /dev/null @@ -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) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/core/audit_middleware.py b/backend/app/core/audit_middleware.py deleted file mode 100644 index ac0e89b..0000000 --- a/backend/app/core/audit_middleware.py +++ /dev/null @@ -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() diff --git a/backend/app/core/config.py b/backend/app/core/config.py deleted file mode 100644 index 4588003..0000000 --- a/backend/app/core/config.py +++ /dev/null @@ -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 diff --git a/backend/app/core/crypto.py b/backend/app/core/crypto.py deleted file mode 100644 index 8e86aaf..0000000 --- a/backend/app/core/crypto.py +++ /dev/null @@ -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 diff --git a/backend/app/core/database.py b/backend/app/core/database.py deleted file mode 100644 index 4de3f51..0000000 --- a/backend/app/core/database.py +++ /dev/null @@ -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) diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py deleted file mode 100644 index bc2b33f..0000000 --- a/backend/app/core/permissions.py +++ /dev/null @@ -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 diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py deleted file mode 100644 index 7626b23..0000000 --- a/backend/app/core/rate_limit.py +++ /dev/null @@ -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" diff --git a/backend/app/core/security.py b/backend/app/core/security.py deleted file mode 100644 index 3b653a4..0000000 --- a/backend/app/core/security.py +++ /dev/null @@ -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 diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/main.py b/backend/app/main.py deleted file mode 100644 index 0e79cc8..0000000 --- a/backend/app/main.py +++ /dev/null @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index 1b78202..0000000 --- a/backend/app/models/__init__.py +++ /dev/null @@ -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 diff --git a/backend/app/models/announcement.py b/backend/app/models/announcement.py deleted file mode 100644 index 7450b7f..0000000 --- a/backend/app/models/announcement.py +++ /dev/null @@ -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()) diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py deleted file mode 100644 index 0ad87db..0000000 --- a/backend/app/models/audit_log.py +++ /dev/null @@ -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) diff --git a/backend/app/models/audit_rule.py b/backend/app/models/audit_rule.py deleted file mode 100644 index e7d1229..0000000 --- a/backend/app/models/audit_rule.py +++ /dev/null @@ -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()) diff --git a/backend/app/models/backup.py b/backend/app/models/backup.py deleted file mode 100644 index b177fab..0000000 --- a/backend/app/models/backup.py +++ /dev/null @@ -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) diff --git a/backend/app/models/instance.py b/backend/app/models/instance.py deleted file mode 100644 index f3ac37d..0000000 --- a/backend/app/models/instance.py +++ /dev/null @@ -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") diff --git a/backend/app/models/instance_group.py b/backend/app/models/instance_group.py deleted file mode 100644 index e4d06dd..0000000 --- a/backend/app/models/instance_group.py +++ /dev/null @@ -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"} diff --git a/backend/app/models/ldap_config.py b/backend/app/models/ldap_config.py deleted file mode 100644 index f46f165..0000000 --- a/backend/app/models/ldap_config.py +++ /dev/null @@ -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()) diff --git a/backend/app/models/masking_rule.py b/backend/app/models/masking_rule.py deleted file mode 100644 index dca4b69..0000000 --- a/backend/app/models/masking_rule.py +++ /dev/null @@ -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" - ), - ) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py deleted file mode 100644 index 7ae3525..0000000 --- a/backend/app/models/notification.py +++ /dev/null @@ -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()) diff --git a/backend/app/models/notification_template.py b/backend/app/models/notification_template.py deleted file mode 100644 index d9d434c..0000000 --- a/backend/app/models/notification_template.py +++ /dev/null @@ -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()) diff --git a/backend/app/models/operation_log.py b/backend/app/models/operation_log.py deleted file mode 100644 index 669bc21..0000000 --- a/backend/app/models/operation_log.py +++ /dev/null @@ -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"), - ) diff --git a/backend/app/models/query_history.py b/backend/app/models/query_history.py deleted file mode 100644 index d76012b..0000000 --- a/backend/app/models/query_history.py +++ /dev/null @@ -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()) diff --git a/backend/app/models/scheduled_task.py b/backend/app/models/scheduled_task.py deleted file mode 100644 index c4fb661..0000000 --- a/backend/app/models/scheduled_task.py +++ /dev/null @@ -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) diff --git a/backend/app/models/schema_revision.py b/backend/app/models/schema_revision.py deleted file mode 100644 index 38f28b0..0000000 --- a/backend/app/models/schema_revision.py +++ /dev/null @@ -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") diff --git a/backend/app/models/system_setting.py b/backend/app/models/system_setting.py deleted file mode 100644 index e593723..0000000 --- a/backend/app/models/system_setting.py +++ /dev/null @@ -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 diff --git a/backend/app/models/ticket.py b/backend/app/models/ticket.py deleted file mode 100644 index 2628344..0000000 --- a/backend/app/models/ticket.py +++ /dev/null @@ -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()) diff --git a/backend/app/models/ticket_sql_item.py b/backend/app/models/ticket_sql_item.py deleted file mode 100644 index 5bb4afd..0000000 --- a/backend/app/models/ticket_sql_item.py +++ /dev/null @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py deleted file mode 100644 index 402ec3c..0000000 --- a/backend/app/models/user.py +++ /dev/null @@ -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") diff --git a/backend/app/models/workflow.py b/backend/app/models/workflow.py deleted file mode 100644 index d24989f..0000000 --- a/backend/app/models/workflow.py +++ /dev/null @@ -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"" - ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/schemas/announcement.py b/backend/app/schemas/announcement.py deleted file mode 100644 index 05a389c..0000000 --- a/backend/app/schemas/announcement.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/audit_rule.py b/backend/app/schemas/audit_rule.py deleted file mode 100644 index ae0772f..0000000 --- a/backend/app/schemas/audit_rule.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py deleted file mode 100644 index 8bc9934..0000000 --- a/backend/app/schemas/auth.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/backup.py b/backend/app/schemas/backup.py deleted file mode 100644 index a9fc1e6..0000000 --- a/backend/app/schemas/backup.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py deleted file mode 100644 index 706eb15..0000000 --- a/backend/app/schemas/dashboard.py +++ /dev/null @@ -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] = [] diff --git a/backend/app/schemas/execution.py b/backend/app/schemas/execution.py deleted file mode 100644 index 0869368..0000000 --- a/backend/app/schemas/execution.py +++ /dev/null @@ -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="提示消息") diff --git a/backend/app/schemas/impact.py b/backend/app/schemas/impact.py deleted file mode 100644 index 9e4d11e..0000000 --- a/backend/app/schemas/impact.py +++ /dev/null @@ -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] diff --git a/backend/app/schemas/instance.py b/backend/app/schemas/instance.py deleted file mode 100644 index 292a680..0000000 --- a/backend/app/schemas/instance.py +++ /dev/null @@ -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 diff --git a/backend/app/schemas/instance_group.py b/backend/app/schemas/instance_group.py deleted file mode 100644 index 24cbe06..0000000 --- a/backend/app/schemas/instance_group.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/ldap_config.py b/backend/app/schemas/ldap_config.py deleted file mode 100644 index 84d3df4..0000000 --- a/backend/app/schemas/ldap_config.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/masking.py b/backend/app/schemas/masking.py deleted file mode 100644 index 7af2e2e..0000000 --- a/backend/app/schemas/masking.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py deleted file mode 100644 index 5f59196..0000000 --- a/backend/app/schemas/notification.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/query.py b/backend/app/schemas/query.py deleted file mode 100644 index 4379896..0000000 --- a/backend/app/schemas/query.py +++ /dev/null @@ -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 diff --git a/backend/app/schemas/rollback.py b/backend/app/schemas/rollback.py deleted file mode 100644 index 2798abb..0000000 --- a/backend/app/schemas/rollback.py +++ /dev/null @@ -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="警告信息") diff --git a/backend/app/schemas/scheduled_task.py b/backend/app/schemas/scheduled_task.py deleted file mode 100644 index b26b275..0000000 --- a/backend/app/schemas/scheduled_task.py +++ /dev/null @@ -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 diff --git a/backend/app/schemas/system_setting.py b/backend/app/schemas/system_setting.py deleted file mode 100644 index 86de60e..0000000 --- a/backend/app/schemas/system_setting.py +++ /dev/null @@ -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] diff --git a/backend/app/schemas/ticket.py b/backend/app/schemas/ticket.py deleted file mode 100644 index 8952808..0000000 --- a/backend/app/schemas/ticket.py +++ /dev/null @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py deleted file mode 100644 index 8b2cea7..0000000 --- a/backend/app/schemas/user.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/workflow.py b/backend/app/schemas/workflow.py deleted file mode 100644 index eec7f9e..0000000 --- a/backend/app/schemas/workflow.py +++ /dev/null @@ -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] = "" diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/services/audit_engine.py b/backend/app/services/audit_engine.py deleted file mode 100644 index 1b16288..0000000 --- a/backend/app/services/audit_engine.py +++ /dev/null @@ -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 - ] diff --git a/backend/app/services/audit_engine_v2.py b/backend/app/services/audit_engine_v2.py deleted file mode 100644 index 15a5441..0000000 --- a/backend/app/services/audit_engine_v2.py +++ /dev/null @@ -1,1732 +0,0 @@ -"""SQL 审核引擎 V2 — 可配置规则引擎,支持 200+ 审核规则。 - -对标 Yearning/Archery/Bytebase 的审核能力,支持: -- DDL 规则 (建表规范、索引规范、字段规范) -- DML 规则 (WHERE 条件、批量限制、事务规范) -- DQL 规则 (SELECT * 检测、LIMIT 检查) -- 命名规范 (表名/字段名/索引名规范) -- 性能规则 (全表扫描、索引使用、大 IN 子句) -- 安全规则 (敏感字段、SQL 注入风险、密码硬编码) -- MySQL 专用规则 (62+ 条,覆盖 MySQL 特有语法) -- PostgreSQL 专用规则 (45+ 条,覆盖 PG 特有语法) -- Redis 专用规则 (43+ 条,覆盖 Redis 命令和数据结构) -- MongoDB 专用规则 (42+ 条,覆盖 MongoDB 查询和聚合) - -使用方式: - engine = SQLAuditEngineV2(db_session) - report = await engine.audit("UPDATE users SET status='active'", db_type="mysql") -""" - -from __future__ import annotations - -import re -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, Callable, Coroutine, Dict, List, Optional, Pattern, Set, Tuple - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - - -# ────────────────────────────────────────────── -# 枚举与数据类定义 -# ────────────────────────────────────────────── - - -class Severity(str, Enum): - """审核发现项严重级别。""" - ERROR = "error" - WARNING = "warning" - INFO = "info" - - -class RuleCategory(str, Enum): - """规则分类。""" - DDL = "DDL" - DML = "DML" - DQL = "DQL" - NAMING = "naming" - PERFORMANCE = "performance" - SECURITY = "security" - - -@dataclass -class AuditRuleV2: - """规则定义数据类。 - - Attributes: - code: 规则唯一编码,如 "DDL-001" - name: 规则名称 - category: 规则分类 - severity: 严重级别 - db_type: 适用的数据库类型 (mysql/postgres/redis/all) - description: 规则描述 - is_enabled: 是否启用 - config: 规则配置字典(如 {"max_index_count": 12}) - pattern: 可选的正则表达式模式 - message_template: 发现违规时的消息模板 - """ - code: str - name: str - category: RuleCategory - severity: Severity - db_type: str = "all" - description: str = "" - is_enabled: bool = True - config: Dict[str, Any] = field(default_factory=dict) - pattern: Optional[str] = None - message_template: str = "" - - @property - def compiled_pattern(self) -> Optional[Pattern[str]]: - """编译并缓存正则表达式。""" - if self.pattern is None: - return None - return re.compile(self.pattern, re.IGNORECASE) - - -@dataclass -class AuditFindingV2: - """审核发现项数据类。 - - Attributes: - rule_code: 触发的规则编码 - rule_name: 规则名称 - level: 严重级别 - message: 发现项描述消息 - position: 可选的 SQL 位置信息 - """ - rule_code: str - rule_name: str - level: Severity - message: str - position: Optional[int] = None - - -@dataclass -class AuditReportV2: - """审核报告数据类。 - - Attributes: - sql: 被审核的 SQL 语句 - findings: 审核发现项列表 - db_type: 数据库类型 - score: 审核评分 (100分满分) - """ - sql: str - findings: List[AuditFindingV2] = field(default_factory=list) - db_type: str = "mysql" - extra_info: Dict[str, Any] = field(default_factory=dict) - - @property - def passed(self) -> bool: - """是否通过审核(无 ERROR 级别发现项)。""" - return not any(f.level == Severity.ERROR for f in self.findings) - - @property - def has_warnings(self) -> bool: - """是否包含 WARNING 级别发现项。""" - return any(f.level == Severity.WARNING for f in self.findings) - - @property - def score(self) -> int: - """计算审核评分。 - - 100分满分,每个 ERROR -10分,WARNING -5分,INFO -2分,最低0分。 - """ - s = 100 - for f in self.findings: - if f.level == Severity.ERROR: - s -= 10 - elif f.level == Severity.WARNING: - s -= 5 - elif f.level == Severity.INFO: - s -= 2 - return max(0, s) - - @property - def summary(self) -> str: - """生成审核摘要。""" - if not self.findings: - return "审核通过,未发现违规项" - error_count = sum(1 for f in self.findings if f.level == Severity.ERROR) - warning_count = sum(1 for f in self.findings if f.level == Severity.WARNING) - info_count = sum(1 for f in self.findings if f.level == Severity.INFO) - parts: List[str] = [] - if error_count: - parts.append(f"{error_count}个错误") - if warning_count: - parts.append(f"{warning_count}个警告") - if info_count: - parts.append(f"{info_count}个提示") - return f"审核评分: {self.score}分, " + ", ".join(parts) - - def to_dict(self) -> Dict[str, Any]: - """转换为字典格式(向后兼容旧版 AuditReport)。""" - return { - "passed": self.passed, - "score": self.score, - "findings": [ - { - "rule": f.rule_code, - "level": f.level.value, - "message": f.message, - } - for f in self.findings - ], - "summary": self.summary, - "db_type": self.db_type, - } - - # 向后兼容旧版 AuditReport 的 has_errors 属性 - @property - def has_errors(self) -> bool: - """是否存在错误级别发现项。""" - return any(f.level == Severity.ERROR for f in self.findings) - - -# ────────────────────────────────────────────── -# SQL 辅助解析函数 -# ────────────────────────────────────────────── - - -def _extract_table_name(sql_upper: str, sql_clean: str) -> Optional[str]: - """从 CREATE TABLE / ALTER TABLE 语句中提取表名。 - - 从 sql_clean 提取以保留原始大小写,用于命名规范检查。 - """ - m = re.search(r"\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"']?(\w+)[`\"']?", sql_clean, re.IGNORECASE) - if m: - return m.group(1) - m = re.search(r"\bALTER\s+TABLE\s+[`\"']?(\w+)[`\"']?", sql_clean, re.IGNORECASE) - if m: - return m.group(1) - return None - - -def _extract_column_definitions(sql_upper: str) -> List[Dict[str, str]]: - """从 CREATE TABLE (...) 中提取字段定义列表。""" - # 找到括号内的内容 - paren_match = re.search(r"\((.*)\)", sql_upper, re.DOTALL) - if not paren_match: - return [] - - inner = paren_match.group(1) - # 按逗号分割(注意:需要处理括号嵌套,简化处理) - parts = [] - depth = 0 - current = "" - for char in inner: - if char == "(": - depth += 1 - current += char - elif char == ")": - depth -= 1 - current += char - elif char == "," and depth == 0: - parts.append(current.strip()) - current = "" - else: - current += char - if current.strip(): - parts.append(current.strip()) - - columns: List[Dict[str, str]] = [] - for part in parts: - # 跳过约束定义 (PRIMARY KEY, FOREIGN KEY, INDEX, KEY, UNIQUE) - if re.match(r"^(PRIMARY\s+KEY|FOREIGN\s+KEY|INDEX|KEY|UNIQUE|CONSTRAINT|CHECK)\b", part, re.IGNORECASE): - continue - # 提取字段名和类型 - col_match = re.match(r"[`\"']?(\w+)[`\"']?\s+(\w+)", part, re.IGNORECASE) - if col_match: - columns.append({ - "name": col_match.group(1), - "type": col_match.group(2).upper(), - "full": part, - }) - return columns - - -def _extract_index_definitions(sql_upper: str) -> List[Dict[str, Any]]: - """从 CREATE TABLE (...) 中提取索引定义列表。""" - paren_match = re.search(r"\((.*)\)", sql_upper, re.DOTALL) - if not paren_match: - return [] - - inner = paren_match.group(1) - parts = [] - depth = 0 - current = "" - for char in inner: - if char == "(": - depth += 1 - current += char - elif char == ")": - depth -= 1 - current += char - elif char == "," and depth == 0: - parts.append(current.strip()) - current = "" - else: - current += char - if current.strip(): - parts.append(current.strip()) - - indexes: List[Dict[str, Any]] = [] - for part in parts: - # PRIMARY KEY - pk_match = re.match(r"PRIMARY\s+KEY\s*\(([^)]+)\)", part, re.IGNORECASE) - if pk_match: - cols = [c.strip().strip("`'\"") for c in pk_match.group(1).split(",")] - indexes.append({"type": "PRIMARY", "name": "PRIMARY", "columns": cols}) - continue - # UNIQUE KEY / INDEX - uk_match = re.match(r"UNIQUE\s+(?:INDEX\s+|KEY\s+)?[`\"']?(\w*)[`\"']?\s*\(([^)]+)\)", part, re.IGNORECASE) - if uk_match: - cols = [c.strip().strip("`'\"") for c in uk_match.group(2).split(",")] - indexes.append({"type": "UNIQUE", "name": uk_match.group(1) or "", "columns": cols}) - continue - # KEY / INDEX - idx_match = re.match(r"(?:KEY|INDEX)\s+[`\"']?(\w+)[`\"']?\s*\(([^)]+)\)", part, re.IGNORECASE) - if idx_match: - cols = [c.strip().strip("`'\"") for c in idx_match.group(2).split(",")] - indexes.append({"type": "INDEX", "name": idx_match.group(1), "columns": cols}) - continue - # FOREIGN KEY - fk_match = re.match(r"FOREIGN\s+KEY\s+[`\"']?(\w*)[`\"']?\s*\(([^)]+)\)", part, re.IGNORECASE) - if fk_match: - cols = [c.strip().strip("`'\"") for c in fk_match.group(2).split(",")] - indexes.append({"type": "FOREIGN", "name": fk_match.group(1) or "", "columns": cols}) - return indexes - - -def _is_snake_case(name: str) -> bool: - """检查是否为小写下划线命名。""" - if not name: - return False - return bool(re.match(r"^[a-z][a-z0-9_]*$", name)) - - -def _count_in_values(sql_upper: str) -> List[int]: - """统计所有 IN 子句中的值数量。""" - counts: List[int] = [] - # 匹配 IN (...) 模式 - for match in re.finditer(r"\bIN\s*\(([^)]+)\)", sql_upper, re.IGNORECASE): - inner = match.group(1) - # 统计逗号数量 + 1 - if inner.strip(): - count = inner.count(",") + 1 - counts.append(count) - return counts - - -def _extract_order_by_columns(sql_upper: str) -> List[str]: - """提取 ORDER BY 中的字段名。""" - cols: List[str] = [] - # 匹配 ORDER BY 后的内容,直到 LIMIT/OFFSET/GROUP/HAVING/UNION/; 或结束 - ob_match = re.search(r"\bORDER\s+BY\s+(.+?)(?:\s+(?:LIMIT|OFFSET|GROUP|HAVING|UNION|;)\s*|$)", sql_upper, re.IGNORECASE | re.DOTALL) - if ob_match: - inner = ob_match.group(1).strip().rstrip(";") - if inner: - for part in inner.split(","): - # 去除 ASC/DESC - col = re.sub(r"\s+(ASC|DESC)\s*$", "", part.strip(), flags=re.IGNORECASE).strip() - if col: - cols.append(col) - return cols - - -def _count_joins(sql_upper: str) -> int: - """统计 JOIN 数量。""" - return len(re.findall(r"\bJOIN\b", sql_upper, re.IGNORECASE)) - - -def _extract_select_columns(sql_upper: str) -> List[str]: - """提取 SELECT 后的字段列表。""" - m = re.search(r"\bSELECT\s+(.+?)\s+FROM\b", sql_upper, re.IGNORECASE | re.DOTALL) - if not m: - return [] - inner = m.group(1) - # 处理简单的字段列表(忽略子查询中的复杂情况) - if "*" in inner and len(inner.strip()) == 1: - return ["*"] - cols = [] - current = "" - depth = 0 - for char in inner: - if char == "(": - depth += 1 - current += char - elif char == ")": - depth -= 1 - current += char - elif char == "," and depth == 0: - cols.append(current.strip()) - current = "" - else: - current += char - if current.strip(): - cols.append(current.strip()) - return cols - - -def _has_primary_key_condition(sql_upper: str, table_name: str = "") -> bool: - """检查 WHERE 条件是否包含主键条件。""" - where_match = re.search(r"\bWHERE\b(.+)$", sql_upper, re.IGNORECASE | re.DOTALL) - if not where_match: - return False - where_clause = where_match.group(1) - # 检查是否包含 id = 或类似主键条件 - pk_patterns = [ - r"\bid\s*[=<>]", - r"\bid\s+IN\s*\(", - r"\b\w+_id\s*[=<>]", - r"\bpk_\w+\s*[=<>]", - ] - return any(re.search(p, where_clause, re.IGNORECASE) for p in pk_patterns) - - -def _has_where_with_function(sql_upper: str) -> bool: - """检查 WHERE 子句中是否使用了函数。""" - where_match = re.search(r"\bWHERE\b(.+)$", sql_upper, re.IGNORECASE | re.DOTALL) - if not where_match: - return False - where_clause = where_match.group(1) - # 匹配 WHERE 中使用函数的模式,如 WHERE YEAR(col) = ... - # 函数名通常是字母+下划线,后面跟左括号 - return bool(re.search(r"\b[A-Z_][A-Z0-9_]*\s*\([^)]+", where_clause, re.IGNORECASE)) - - -# ────────────────────────────────────────────── -# 规则检查函数注册表 -# ────────────────────────────────────────────── - - -RuleCheckFunc = Callable[[str, str, AuditReportV2, AuditRuleV2], None] - -_RULE_REGISTRY: Dict[str, Tuple[AuditRuleV2, RuleCheckFunc]] = {} - - -def register_rule(rule: AuditRuleV2) -> Callable[[RuleCheckFunc], RuleCheckFunc]: - """注册规则的装饰器工厂。 - - 用法:: - - @register_rule(AuditRuleV2(...)) - def check_xxx(sql_clean, sql_upper, report, rule): ... - """ - def decorator(func: RuleCheckFunc) -> RuleCheckFunc: - _RULE_REGISTRY[rule.code] = (rule, func) - return func - return decorator - - -def get_all_registered_rules() -> List[AuditRuleV2]: - """获取所有已注册的规则定义(去重)。""" - return [rule for rule, _ in _RULE_REGISTRY.values()] - - -# ═══════════════════════════════════════════════ -# DDL 规则 (至少12条) -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="DDL-001", - name="建表必须包含主键", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="all", - description="CREATE TABLE 语句必须包含 PRIMARY KEY 定义", - message_template="建表语句缺少主键定义,请添加 PRIMARY KEY", -)) -def check_ddl_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """建表必须包含主键。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - # 检查是否有 PRIMARY KEY 定义 - if "PRIMARY KEY" not in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="DDL-002", - name="建表必须添加表注释", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="all", - description="CREATE TABLE 语句必须包含 COMMENT 表注释", - message_template="建表时必须添加表注释 (COMMENT='...')", -)) -def check_ddl_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """建表必须添加表注释。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - # 检查表级 COMMENT(支持 MySQL COMMENT= 或 PostgreSQL COMMENT ON) - has_comment = ( - re.search(r"COMMENT\s*[=\s]+['\"]", sql_clean, re.IGNORECASE) or - re.search(r"\bCOMMENT\s+ON\s+TABLE\b", sql_upper, re.IGNORECASE) - ) - if not has_comment: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="DDL-003", - name="建表字段必须有注释", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="all", - description="CREATE TABLE 中的每个字段都应包含 COMMENT", - message_template="字段 '{column}' 缺少注释,请添加 COMMENT", -)) -def check_ddl_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """建表字段必须有注释。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - columns = _extract_column_definitions(sql_upper) - for col in columns: - # 检查字段定义中是否包含 COMMENT - if "COMMENT" not in col["full"]: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(column=col["name"]), - )) - - -@register_rule(AuditRuleV2( - code="DDL-004", - name="禁止 DROP TABLE", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="all", - description="生产环境禁止执行 DROP TABLE 操作", - config={"env_production_only": True}, - message_template="禁止执行 DROP TABLE 操作,数据将永久丢失", -)) -def check_ddl_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 DROP TABLE(生产环境)。""" - if not re.search(r"\bDROP\s+TABLE\b", sql_upper, re.IGNORECASE): - return - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="DDL-005", - name="禁止 TRUNCATE TABLE", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="all", - description="禁止执行 TRUNCATE TABLE 操作", - message_template="禁止执行 TRUNCATE TABLE 操作,数据不可回滚", -)) -def check_ddl_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 TRUNCATE TABLE。""" - if not re.search(r"\bTRUNCATE\b", sql_upper, re.IGNORECASE): - return - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="DDL-006", - name="单表索引数量不得超过12个", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="all", - config={"max_index_count": 12}, - message_template="单表索引数量为 {count} 个,超过最大限制 {max}", -)) -def check_ddl_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """单表索引数量检查。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - max_count = rule.config.get("max_index_count", 12) - indexes = _extract_index_definitions(sql_upper) - idx_count = len([i for i in indexes if i["type"] in ("INDEX", "UNIQUE")]) - if idx_count > max_count: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(count=idx_count, max=max_count), - )) - - -@register_rule(AuditRuleV2( - code="DDL-007", - name="单索引字段数不得超过8个", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="all", - config={"max_index_columns": 8}, - message_template="索引 '{name}' 包含 {count} 个字段,超过最大限制 {max}", -)) -def check_ddl_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """单索引字段数检查。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - max_cols = rule.config.get("max_index_columns", 8) - indexes = _extract_index_definitions(sql_upper) - for idx in indexes: - if len(idx["columns"]) > max_cols: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format( - name=idx["name"], count=len(idx["columns"]), max=max_cols - ), - )) - - -@register_rule(AuditRuleV2( - code="DDL-008", - name="禁止使用 FLOAT/DOUBLE 类型", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="all", - description="浮点类型精度丢失,应使用 DECIMAL", - message_template="字段 '{column}' 使用了 {type} 类型,建议使用 DECIMAL 避免精度丢失", -)) -def check_ddl_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止使用 FLOAT/DOUBLE。""" - if not re.search(r"\bCREATE\s+TABLE\b|\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - return - columns = _extract_column_definitions(sql_upper) - for col in columns: - if col["type"] in ("FLOAT", "DOUBLE", "REAL"): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(column=col["name"], type=col["type"]), - )) - - -@register_rule(AuditRuleV2( - code="DDL-009", - name="CHAR 类型长度不得超过10", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="all", - config={"max_char_length": 10}, - message_template="CHAR 类型长度不得超过 {max},超出请使用 VARCHAR", -)) -def check_ddl_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """CHAR 类型长度检查。""" - if not re.search(r"\bCREATE\s+TABLE\b|\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - return - max_len = rule.config.get("max_char_length", 10) - # 匹配 CHAR(N) 模式 - for match in re.finditer(r"\bCHAR\s*\((\d+)\)", sql_upper, re.IGNORECASE): - length = int(match.group(1)) - if length > max_len: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(max=max_len), - )) - - -@register_rule(AuditRuleV2( - code="DDL-010", - name="表名长度不得超过45字符", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="all", - config={"max_table_name_length": 45}, - message_template="表名 '{name}' 长度为 {length},超过最大限制 {max}", -)) -def check_ddl_010(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """表名长度检查。""" - table_name = _extract_table_name(sql_upper, sql_clean) - if not table_name: - return - max_len = rule.config.get("max_table_name_length", 45) - if len(table_name) > max_len: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(name=table_name, length=len(table_name), max=max_len), - )) - - -@register_rule(AuditRuleV2( - code="DDL-011", - name="禁止外键约束", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="all", - description="外键约束影响性能,推荐在应用层处理关联", - message_template="禁止使用 FOREIGN KEY 约束,建议在应用层维护数据一致性", -)) -def check_ddl_011(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止外键约束。""" - if not re.search(r"\bCREATE\s+TABLE\b|\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - return - if "FOREIGN KEY" in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="DDL-012", - name="必须包含 create_time/update_time 字段", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="all", - description="建表必须包含 create_time 和 update_time 审计字段", - message_template="建表缺少审计字段: {missing}", -)) -def check_ddl_012(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """必须包含 create_time/update_time 字段。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - columns = _extract_column_definitions(sql_upper) - col_names = [c["name"].lower() for c in columns] - required = ["create_time", "update_time"] - missing = [r for r in required if r not in col_names] - if missing: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(missing=", ".join(missing)), - )) - - -# ═══════════════════════════════════════════════ -# DML 规则 (至少8条) -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="DML-001", - name="UPDATE/DELETE 必须包含 WHERE 条件", - category=RuleCategory.DML, - severity=Severity.ERROR, - db_type="all", - description="UPDATE/DELETE 缺少 WHERE 将影响全表数据", - message_template="{operation} 语句缺少 WHERE 条件,将操作全表数据", -)) -def check_dml_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """UPDATE/DELETE 必须包含 WHERE。""" - for keyword in ("UPDATE", "DELETE"): - if re.match(rf"\s*{keyword}\b", sql_upper, re.IGNORECASE): - if "WHERE" not in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(operation=keyword), - )) - - -@register_rule(AuditRuleV2( - code="DML-002", - name="单次 INSERT 最多500行", - category=RuleCategory.DML, - severity=Severity.WARNING, - db_type="all", - config={"max_insert_rows": 500}, - message_template="单次 INSERT 包含 {count} 行,超过最大限制 {max}", -)) -def check_dml_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """单次 INSERT 行数检查。""" - if not re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE): - return - max_rows = rule.config.get("max_insert_rows", 500) - # 统计 VALUES 子句数量(简化:通过右括号数量估计) - values_sections = re.findall(r"\bVALUES\s*\((.+?)\)", sql_upper, re.IGNORECASE | re.DOTALL) - if not values_sections: - return - # 多行 VALUES (value1), (value2), (value3) - values_match = re.search(r"\bVALUES\s+(.+)", sql_upper, re.IGNORECASE | re.DOTALL) - if values_match: - values_part = values_match.group(1) - # 计算行数(通过未闭合的括号组) - # 简化:计算 VALUES 后的顶层括号组数量 - row_count = 0 - depth = 0 - for i, char in enumerate(values_part): - if char == "(": - if depth == 0: - row_count += 1 - depth += 1 - elif char == ")": - depth -= 1 - if row_count > max_rows: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(count=row_count, max=max_rows), - )) - - -@register_rule(AuditRuleV2( - code="DML-003", - name="UPDATE/DELETE 禁止带 LIMIT", - category=RuleCategory.DML, - severity=Severity.WARNING, - db_type="mysql", - description="UPDATE/DELETE 使用 LIMIT 可能导致部分数据变更", - message_template="{operation} 语句禁止使用 LIMIT,可能导致数据变更不完整", -)) -def check_dml_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """UPDATE/DELETE 禁止带 LIMIT。""" - for keyword in ("UPDATE", "DELETE"): - if re.match(rf"\s*{keyword}\b", sql_upper, re.IGNORECASE): - if "LIMIT" in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(operation=keyword), - )) - - -@register_rule(AuditRuleV2( - code="DML-004", - name="禁止 INSERT 不指定字段列表", - category=RuleCategory.DML, - severity=Severity.ERROR, - db_type="all", - description="INSERT 必须显式指定字段列表", - message_template="INSERT 语句必须显式指定字段列表,如 INSERT INTO t (col1, col2) VALUES ...", -)) -def check_dml_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 INSERT 不指定字段列表。""" - # 匹配 INSERT INTO table VALUES 模式(没有字段列表) - if re.search(r"\bINSERT\s+(?:INTO\s+)?\w+\s*\(?\w*\)?\s+VALUES\b", sql_upper, re.IGNORECASE): - # 检查是否不包含字段列表 (col1, col2) - # 通过检查 INSERT INTO table ( 模式 - if not re.search(r"\bINSERT\s+(?:INTO\s+)?\w+\s*\([^)]+\)\s+VALUES\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="DML-005", - name="禁止批量 UPDATE 不带主键条件", - category=RuleCategory.DML, - severity=Severity.WARNING, - db_type="all", - description="批量 UPDATE 必须带主键 WHERE 条件", - message_template="批量 UPDATE 必须包含主键条件,当前 WHERE 条件不含主键", -)) -def check_dml_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """批量 UPDATE 必须带主键条件。""" - if not re.match(r"\s*UPDATE\b", sql_upper, re.IGNORECASE): - return - if "WHERE" not in sql_upper: - return # 已被 DML-001 覆盖 - if not _has_primary_key_condition(sql_upper): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="DML-006", - name="DELETE 必须有 LIMIT(除非带主键条件)", - category=RuleCategory.DML, - severity=Severity.WARNING, - db_type="mysql", - description="DELETE 必须带 LIMIT 或主键 WHERE 条件", - message_template="DELETE 建议添加 LIMIT 或使用主键条件防止误删大量数据", -)) -def check_dml_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """DELETE 必须有 LIMIT 或主键条件。""" - if not re.match(r"\s*DELETE\b", sql_upper, re.IGNORECASE): - return - # 如果有主键条件则通过 - if _has_primary_key_condition(sql_upper): - return - # 如果有 LIMIT 则通过 - if "LIMIT" in sql_upper: - return - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="DML-007", - name="禁止 REPLACE INTO", - category=RuleCategory.DML, - severity=Severity.ERROR, - db_type="mysql", - description="REPLACE INTO 会先删除再插入,风险较高", - message_template="禁止使用 REPLACE INTO,请使用 INSERT ... ON DUPLICATE KEY UPDATE 替代", -)) -def check_dml_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 REPLACE INTO。""" - if re.search(r"\bREPLACE\s+INTO\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="DML-008", - name="INSERT 必须指定字段列表", - category=RuleCategory.DML, - severity=Severity.ERROR, - db_type="all", - description="INSERT 语句必须显式指定字段列表,避免表结构变更导致错误", - message_template="INSERT 语句必须显式指定字段列表", -)) -def check_dml_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """INSERT 必须指定字段列表(与 DML-004 互补)。""" - if not re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE): - return - # 更严格的检查:INSERT INTO table_name (col1, col2) 必须存在 - if not re.search(r"\bINSERT\s+(?:INTO\s+)?[\w`\"]+\s*\([^)]+\)", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ═══════════════════════════════════════════════ -# DQL 规则 (至少5条) -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="DQL-001", - name="禁止 SELECT *", - category=RuleCategory.DQL, - severity=Severity.INFO, - db_type="all", - description="SELECT * 返回不必要的列,影响性能", - message_template="禁止使用 SELECT *,请明确指定需要的字段", -)) -def check_dql_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 SELECT *。""" - if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE): - return - # 检查 SELECT * 或 SELECT COUNT(*) - if re.search(r"\bSELECT\s+\*(?!\s*\))", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="DQL-002", - name="SELECT 必须有 LIMIT", - category=RuleCategory.DQL, - severity=Severity.WARNING, - db_type="all", - description="SELECT 查询应添加 LIMIT 限制返回行数", - message_template="SELECT 查询缺少 LIMIT,建议添加 LIMIT 防止返回过多数据", -)) -def check_dql_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """SELECT 必须有 LIMIT。""" - if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE): - return - # 排除子查询和 INSERT ... SELECT - if re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE): - return - # 检查 LIMIT - if "LIMIT" not in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="DQL-003", - name="禁止在 WHERE 中使用函数", - category=RuleCategory.DQL, - severity=Severity.WARNING, - db_type="all", - description="WHERE 中使用函数会导致索引失效", - message_template="WHERE 子句中使用函数会导致索引失效,请将函数移出 WHERE", -)) -def check_dql_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止在 WHERE 中使用函数。""" - if "WHERE" not in sql_upper: - return - if not re.search(r"\bSELECT\b|\bUPDATE\b|\bDELETE\b", sql_upper, re.IGNORECASE): - return - if _has_where_with_function(sql_upper): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="DQL-004", - name="禁止隐式类型转换", - category=RuleCategory.DQL, - severity=Severity.WARNING, - db_type="all", - description="不同类型比较会导致索引失效", - message_template="WHERE 条件中存在隐式类型转换风险,请确保比较双方类型一致", -)) -def check_dql_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止隐式类型转换。""" - if "WHERE" not in sql_upper: - return - # 检测字符串列与数字比较的模式(简化) - # 例如 WHERE id = '123' 且 id 是数字列 - # 或 WHERE name = 123 且 name 是字符串列 - patterns = [ - r"\bWHERE\b.*=\s*'\d+'", # 字符串数字比较 - r"\bWHERE\b.*\w+\s*=\s*\d+\b.*AND.*\w+\s*=\s*'[^']*'", # 混合类型 - ] - # 简化实现:检测 WHERE 条件中数字和字符串比较的混合 - where_clause = "" - where_match = re.search(r"\bWHERE\b(.+)$", sql_upper, re.IGNORECASE | re.DOTALL) - if where_match: - where_clause = where_match.group(1) - # 检测数字列与字符串字面量比较 - has_numeric_cmp = bool(re.search(r"=\s*\d+\b", where_clause)) - has_string_cmp = bool(re.search(r"=\s*'[^']*'", where_clause)) - # 如果同时存在数字和字符串比较,可能存在类型转换问题 - if has_numeric_cmp and has_string_cmp: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="DQL-005", - name="ORDER BY 必须使用索引字段", - category=RuleCategory.DQL, - severity=Severity.INFO, - db_type="all", - description="ORDER BY 非索引字段会导致文件排序(filesort)", - message_template="ORDER BY 字段建议添加索引,避免 filesort 性能问题", -)) -def check_dql_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """ORDER BY 索引检查(静态检查,提示级别)。""" - if "ORDER BY" not in sql_upper: - return - order_cols = _extract_order_by_columns(sql_upper) - if order_cols: - # 静态检查无法确定是否有索引,给出提示 - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ═══════════════════════════════════════════════ -# 命名规范规则 (至少5条) -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="NAME-001", - name="表名必须小写下划线命名", - category=RuleCategory.NAMING, - severity=Severity.WARNING, - db_type="all", - description="表名应使用小写下划线格式 (snake_case)", - message_template="表名 '{name}' 不符合小写下划线命名规范", -)) -def check_name_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """表名必须小写下划线命名。""" - table_name = _extract_table_name(sql_upper, sql_clean) - if not table_name: - return - if not _is_snake_case(table_name): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(name=table_name), - )) - - -@register_rule(AuditRuleV2( - code="NAME-002", - name="字段名必须小写下划线命名", - category=RuleCategory.NAMING, - severity=Severity.WARNING, - db_type="all", - description="字段名应使用小写下划线格式 (snake_case)", - message_template="字段名 '{name}' 不符合小写下划线命名规范", -)) -def check_name_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """字段名必须小写下划线命名。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - columns = _extract_column_definitions(sql_upper) - for col in columns: - if not _is_snake_case(col["name"]): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(name=col["name"]), - )) - - -@register_rule(AuditRuleV2( - code="NAME-003", - name="索引名必须以 idx_ 或 uk_ 开头", - category=RuleCategory.NAMING, - severity=Severity.INFO, - db_type="all", - description="索引命名规范:普通索引 idx_,唯一索引 uk_", - message_template="索引名 '{name}' 不符合命名规范,应以 idx_ 或 uk_ 开头", -)) -def check_name_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """索引名规范检查。""" - if not re.search(r"\bCREATE\s+TABLE\b|\bCREATE\s+INDEX\b|\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - return - indexes = _extract_index_definitions(sql_upper) - for idx in indexes: - if idx["type"] == "PRIMARY": - continue # 主键跳过 - name = idx["name"] - if name and not (name.startswith("idx_") or name.startswith("uk_")): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(name=name), - )) - - -@register_rule(AuditRuleV2( - code="NAME-004", - name="主键名必须为 id 或 pk_ 开头", - category=RuleCategory.NAMING, - severity=Severity.INFO, - db_type="all", - description="主键字段命名规范", - message_template="主键名 '{name}' 不符合规范,建议使用 'id' 或 'pk_' 前缀", -)) -def check_name_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """主键名规范检查。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - # 查找 PRIMARY KEY 定义的字段 - pk_match = re.search(r"PRIMARY\s+KEY\s*\(([^)]+)\)", sql_upper, re.IGNORECASE) - if pk_match: - pk_cols = [c.strip().strip("`'\"") for c in pk_match.group(1).split(",")] - for col in pk_cols: - if col.lower() != "id" and not col.lower().startswith("pk_"): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(name=col), - )) - # 也检查行内 PRIMARY KEY 定义(如 id INT PRIMARY KEY) - columns = _extract_column_definitions(sql_upper) - for col in columns: - if "PRIMARY KEY" in col["full"].upper(): - if col["name"].lower() != "id" and not col["name"].lower().startswith("pk_"): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(name=col["name"]), - )) - - -@register_rule(AuditRuleV2( - code="NAME-005", - name="布尔字段以 is_/has_ 开头", - category=RuleCategory.NAMING, - severity=Severity.INFO, - db_type="all", - description="布尔类型字段应以 is_ 或 has_ 前缀命名", - message_template="布尔字段 '{name}' 建议以 is_ 或 has_ 开头命名", -)) -def check_name_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """布尔字段命名规范。""" - if not re.search(r"\bCREATE\s+TABLE\b|\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - return - columns = _extract_column_definitions(sql_upper) - bool_types = ("BOOLEAN", "BOOL", "TINYINT(1)", "BIT(1)") - for col in columns: - if any(bt in col["type"] for bt in bool_types): - if not (col["name"].startswith("is_") or col["name"].startswith("has_")): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(name=col["name"]), - )) - - -# ═══════════════════════════════════════════════ -# 性能规则 (至少8条) -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="PERF-001", - name="禁止大表全表扫描", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="all", - config={"max_rows_for_full_scan": 10000}, - description="大表(>10000行)查询应避免全表扫描", - message_template="SELECT 查询缺少 WHERE 条件,大表下会导致全表扫描", -)) -def check_perf_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止大表全表扫描。""" - if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE): - return - # 检查是否缺少 WHERE(简化:静态检查无法获取实际行数,检测缺少 WHERE 的 SELECT) - if "WHERE" not in sql_upper and "LIMIT" not in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="PERF-002", - name="IN 子句值数量不得超过100个", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="all", - config={"max_in_values": 100}, - description="IN 子句值过多会导致性能下降", - message_template="IN 子句包含 {count} 个值,超过最大限制 {max},建议使用临时表或 JOIN 替代", -)) -def check_perf_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """IN 子句值数量检查。""" - max_vals = rule.config.get("max_in_values", 100) - in_counts = _count_in_values(sql_upper) - for count in in_counts: - if count > max_vals: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(count=count, max=max_vals), - )) - - -@register_rule(AuditRuleV2( - code="PERF-003", - name="禁止 LIKE '%xxx%' 前缀模糊匹配", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="all", - description="前缀模糊匹配无法使用索引", - message_template="LIKE '%xxx%' 无法使用索引,建议改用全文检索或后缀索引", -)) -def check_perf_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 LIKE '%xxx%'。""" - if re.search(r"\bLIKE\s+'%[^']+'", sql_clean, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="PERF-004", - name="JOIN 不得超过5个表", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="all", - config={"max_join_tables": 5}, - message_template="JOIN 涉及 {count} 个表,超过最大限制 {max}", -)) -def check_perf_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """JOIN 数量检查。""" - join_count = _count_joins(sql_upper) - max_joins = rule.config.get("max_join_tables", 5) - # JOIN 数量 = JOIN 关键字数 + 1(主表) - total_tables = join_count + 1 if join_count > 0 else 0 - if total_tables > max_joins: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(count=total_tables, max=max_joins), - )) - - -@register_rule(AuditRuleV2( - code="PERF-005", - name="禁止 ORDER BY RAND()", - category=RuleCategory.PERFORMANCE, - severity=Severity.ERROR, - db_type="mysql", - description="ORDER BY RAND() 性能极差,会导致全表扫描", - message_template="禁止 ORDER BY RAND(),性能极差,建议在应用层随机采样", -)) -def check_perf_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 ORDER BY RAND()。""" - if re.search(r"\bORDER\s+BY\s+RAND\s*\(", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="PERF-006", - name="子查询建议改为 JOIN", - category=RuleCategory.PERFORMANCE, - severity=Severity.INFO, - db_type="all", - description="相关子查询性能较差,建议改为 JOIN", - message_template="WHERE 中的子查询建议改为 JOIN 以提升性能", -)) -def check_perf_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """子查询检测。""" - # 检测 WHERE ... (SELECT ...) 模式 - if re.search(r"\bWHERE\b.*\(\s*\bSELECT\b", sql_upper, re.IGNORECASE | re.DOTALL): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="PERF-007", - name="大表禁止 OFFSET 分页", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="all", - description="大表使用 OFFSET 分页性能差,建议使用游标分页", - message_template="使用 OFFSET 分页在大表上性能较差,建议改用游标分页 (WHERE id > ? LIMIT N)", -)) -def check_perf_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """大表 OFFSET 分页检测。""" - if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE): - return - if re.search(r"\bOFFSET\s+\d+\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="PERF-008", - name="禁止 SELECT DISTINCT(除非必要)", - category=RuleCategory.PERFORMANCE, - severity=Severity.INFO, - db_type="all", - description="DISTINCT 需要去重操作,影响性能", - message_template="SELECT DISTINCT 影响性能,请确认是否必要", -)) -def check_perf_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 SELECT DISTINCT。""" - if re.search(r"\bSELECT\s+DISTINCT\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ═══════════════════════════════════════════════ -# 安全规则 (至少5条) -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="SEC-001", - name="禁止查询 password/secret/token 字段", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="all", - description="禁止在 SELECT 中查询敏感字段", - config={"sensitive_fields": ["password", "secret", "token", "passwd", "pwd", "auth_key", "api_key"]}, - message_template="禁止在查询中返回敏感字段: {fields}", -)) -def check_sec_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止查询敏感字段。""" - if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE): - return - sensitive = rule.config.get("sensitive_fields", []) - cols = _extract_select_columns(sql_upper) - found: List[str] = [] - for col in cols: - col_lower = col.lower() - for sf in sensitive: - if sf in col_lower: - found.append(col) - break - if found: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(fields=", ".join(found)), - )) - - -@register_rule(AuditRuleV2( - code="SEC-002", - name="禁止 SQL 中硬编码密码", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="all", - description="SQL 中不应包含硬编码密码", - message_template="SQL 中发现可能的硬编码密码,禁止在 SQL 中直接写入密码", -)) -def check_sec_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 SQL 中硬编码密码。""" - patterns = [ - r"\bPASSWORD\s*[=\s]+['\"][^'\"]+['\"]", - r"\bSET\s+PASSWORD\s*=", - r"\bpassword\s*[=:]\s*['\"][^'\"]+['\"]", - ] - for p in patterns: - if re.search(p, sql_clean, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - return # 只报告一次 - - -@register_rule(AuditRuleV2( - code="SEC-003", - name="禁止 UNION(SQL 注入风险)", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="all", - description="UNION 语句存在 SQL 注入风险", - message_template="禁止 UNION 操作,存在 SQL 注入风险", -)) -def check_sec_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 UNION。""" - if re.search(r"\bUNION\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="SEC-004", - name="禁止注释符", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="all", - description="SQL 中不应包含 -- 或 /* */ 注释", - message_template="SQL 中禁止包含注释符,可能存在注入风险", -)) -def check_sec_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止注释符。""" - if "--" in sql_clean or "/*" in sql_clean or "*/" in sql_clean: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="SEC-005", - name="禁止多语句(分号分隔)", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="all", - description="禁止单条 SQL 中包含多条语句", - message_template="禁止提交多条 SQL 语句(分号分隔),请拆分为多个工单", -)) -def check_sec_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止多语句。""" - stripped = sql_clean.rstrip(";").strip() - if ";" in stripped: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ────────────────────────────────────────────── -# 主引擎类 -# ────────────────────────────────────────────── - - -class SQLAuditEngineV2: - """SQL 审核引擎 V2 — 可配置规则引擎。 - - 从数据库加载规则配置,支持动态启用/禁用/配置。 - 向后兼容旧版 AuditReport 格式。 - - Attributes: - db_session: 数据库会话,用于加载规则 - env: 运行环境 (development/production) - _rules_cache: 缓存加载的规则列表 - """ - - def __init__(self, db_session: Optional[AsyncSession] = None, env: str = "development") -> None: - """初始化审核引擎。 - - Args: - db_session: 数据库会话,用于加载规则配置 - env: 运行环境,影响部分规则的严格程度 - """ - self.db_session = db_session - self.env = env - self._rules_cache: Optional[List[Tuple[AuditRuleV2, RuleCheckFunc]]] = None - - async def _load_rules(self, db_type: str = "all") -> List[Tuple[AuditRuleV2, RuleCheckFunc]]: - """从数据库加载启用的规则,并与注册表合并。 - - 如果数据库中不存在某条规则,则使用注册表中的默认配置。 - 如果提供了 db_session,优先从数据库加载;否则直接使用注册表的规则。 - - Args: - db_type: 数据库类型过滤 - Returns: - 规则定义和检查函数的元组列表 - """ - # 如果有缓存,直接返回 - if self._rules_cache is not None: - return self._filter_by_db_type(self._rules_cache, db_type) - - results: List[Tuple[AuditRuleV2, RuleCheckFunc]] = [] - - if self.db_session is not None: - try: - from app.models.audit_rule import AuditRule # 延迟导入避免循环依赖 - stmt = select(AuditRule).where(AuditRule.is_enabled == True) - db_result = await self.db_session.execute(stmt) - db_rules = db_result.scalars().all() - - # 将数据库规则映射到 code - db_rules_by_name: Dict[str, AuditRule] = {} - for dr in db_rules: - db_rules_by_name[dr.name] = dr - - for code, (default_rule, func) in _RULE_REGISTRY.items(): - # 优先按 code 匹配(精确),回退按 name 匹配(兼容旧数据) - db_rule = None - for dr in db_rules: - if dr.code and dr.code == code: - db_rule = dr - break - if db_rule is None: - db_rule = db_rules_by_name.get(default_rule.name) - - if db_rule is not None: - # 从 config_json 解析 DB 持久化的配置,覆盖默认 config - db_config = default_rule.config.copy() - if db_rule.config_json: - try: - import json as _json - overrides = _json.loads(db_rule.config_json) - if isinstance(overrides, dict): - db_config.update(overrides) - except (ValueError, TypeError): - pass - - # 使用数据库中的配置(但保留 code 和检查函数) - merged_rule = AuditRuleV2( - code=code, - name=db_rule.name, - category=RuleCategory(db_rule.rule_type) if db_rule.rule_type in {e.value for e in RuleCategory} else default_rule.category, - severity=Severity(db_rule.severity) if db_rule.severity in {e.value for e in Severity} else default_rule.severity, - db_type=db_rule.db_type or default_rule.db_type, - description=db_rule.description or default_rule.description, - is_enabled=db_rule.is_enabled, - config=db_config, - pattern=db_rule.pattern or default_rule.pattern, - message_template=db_rule.message or default_rule.message_template, - ) - if merged_rule.is_enabled: - results.append((merged_rule, func)) - else: - # 数据库中无此规则,使用默认 - if default_rule.is_enabled: - results.append((default_rule, func)) - except Exception: - # 数据库加载失败,回退到注册表 - for code, (rule, func) in _RULE_REGISTRY.items(): - if rule.is_enabled: - results.append((rule, func)) - else: - # 无数据库会话,使用注册表 - for code, (rule, func) in _RULE_REGISTRY.items(): - if rule.is_enabled: - results.append((rule, func)) - - self._rules_cache = results - return self._filter_by_db_type(results, db_type) - - def _filter_by_db_type( - self, rules: List[Tuple[AuditRuleV2, RuleCheckFunc]], db_type: str - ) -> List[Tuple[AuditRuleV2, RuleCheckFunc]]: - """按数据库类型过滤规则。""" - if db_type == "all": - return rules - return [(r, f) for r, f in rules if r.db_type in ("all", db_type)] - - async def audit( - self, sql: str, db_type: str = "mysql", instance_id: Optional[int] = None - ) -> AuditReportV2: - """执行 SQL 审核。 - - 根据 SQL 语句类型,匹配对应的审核规则进行检查。 - - Args: - sql: 待审核的 SQL 语句 - db_type: 目标数据库类型 (mysql/postgres/redis) - instance_id: 目标实例 ID(用于 EXPLAIN 预检、上下文感知规则等) - - Returns: - AuditReportV2 审核报告 - """ - sql_clean = sql.strip() - sql_upper = sql_clean.upper() - - report = AuditReportV2(sql=sql_clean, db_type=db_type) - report.extra_info["instance_id"] = instance_id - - # 加载规则 - rules = await self._load_rules(db_type) - - # 构建上下文(用于上下文感知规则) - context: Dict[str, Any] = {"instance_id": instance_id, "db_session": self.db_session} - - # 执行每条规则的检查 - for rule, check_func in rules: - try: - check_func(sql_clean, sql_upper, report, rule, context=context) - except TypeError: - # 兼容旧签名 (sql, sql_upper, report, rule) - try: - check_func(sql_clean, sql_upper, report, rule) - except Exception: - continue - except Exception: - # 规则检查失败不影响整体审核 - continue - - return report - - def audit_sync(self, sql: str, db_type: str = "mysql") -> AuditReportV2: - """同步版本的审核(用于不需要数据库加载规则的场景)。 - - 直接从注册表加载规则,不访问数据库。 - - Args: - sql: 待审核的 SQL 语句 - db_type: 目标数据库类型 - - Returns: - AuditReportV2 审核报告 - """ - sql_clean = sql.strip() - sql_upper = sql_clean.upper() - report = AuditReportV2(sql=sql_clean, db_type=db_type) - - # 直接使用注册表的规则(不访问数据库) - for code, (rule, func) in _RULE_REGISTRY.items(): - if not rule.is_enabled: - continue - if rule.db_type not in ("all", db_type): - continue - try: - func(sql_clean, sql_upper, report, rule) - except Exception: - continue - - return report - - async def get_rule_list(self) -> List[AuditRuleV2]: - """获取当前引擎中所有已注册规则的列表。 - - Returns: - 规则定义列表 - """ - rules = await self._load_rules("all") - return [r for r, _ in rules] - - def invalidate_cache(self) -> None: - """清除规则缓存,下次审核重新加载。""" - self._rules_cache = None - - -# ── 延迟导入各数据库专用规则(副作用:注册到 _RULE_REGISTRY)──── -# 放在文件末尾以避免循环导入(规则模块需要 import register_rule 等) -# MySQL 规则 (62+ 条) -from app.services.mysql_audit_rules import * # noqa: F401,F403 -# PostgreSQL 规则 (45+ 条) -from app.services.postgres_audit_rules import * # noqa: F401,F403 -# Redis 规则 (43+ 条) -from app.services.redis_audit_rules import * # noqa: F401,F403 -# MongoDB 规则 (42+ 条) -from app.services.mongodb_audit_rules import * # noqa: F401,F403 diff --git a/backend/app/services/audit_log_service.py b/backend/app/services/audit_log_service.py deleted file mode 100644 index 0e949f2..0000000 --- a/backend/app/services/audit_log_service.py +++ /dev/null @@ -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 - ], - } diff --git a/backend/app/services/audit_logger.py b/backend/app/services/audit_logger.py deleted file mode 100644 index a08ec33..0000000 --- a/backend/app/services/audit_logger.py +++ /dev/null @@ -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 diff --git a/backend/app/services/audit_rule_seeds.py b/backend/app/services/audit_rule_seeds.py deleted file mode 100644 index 5687690..0000000 --- a/backend/app/services/audit_rule_seeds.py +++ /dev/null @@ -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, -} diff --git a/backend/app/services/audit_rule_seeds_v2.py b/backend/app/services/audit_rule_seeds_v2.py deleted file mode 100644 index 93580a8..0000000 --- a/backend/app/services/audit_rule_seeds_v2.py +++ /dev/null @@ -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 diff --git a/backend/app/services/audit_rule_sync.py b/backend/app/services/audit_rule_sync.py deleted file mode 100644 index 2b854c9..0000000 --- a/backend/app/services/audit_rule_sync.py +++ /dev/null @@ -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 diff --git a/backend/app/services/backup_executor.py b/backend/app/services/backup_executor.py deleted file mode 100644 index 656c7bc..0000000 --- a/backend/app/services/backup_executor.py +++ /dev/null @@ -1,351 +0,0 @@ -"""数据库备份执行器 — 使用 mysqldump/pg_dump 执行真实备份。 - -支持: -- MySQL: mysqldump + gzip 压缩 -- PostgreSQL: pg_dump custom format - -备份文件存储在 /app/backups 目录。 -""" -import os -import asyncio -from datetime import datetime -from typing import Optional, Tuple - -from app.models.instance import Instance -from app.models.backup import Backup -from app.core.crypto import decrypt_password - - -# 备份存储目录 -BACKUP_DIR = os.environ.get("BACKUP_DIR", "/app/backups") - - -class BackupExecutor: - """数据库备份执行器。""" - - async def execute(self, backup: Backup, instance: Instance) -> Tuple[str, int]: - """执行备份,返回 (文件路径, 文件大小字节)。 - - Raises: - RuntimeError: 备份工具未安装或备份失败 - """ - os.makedirs(BACKUP_DIR, exist_ok=True) - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - password = decrypt_password(instance.password) - - if instance.db_type == "mysql": - filepath = await self._backup_mysql( - instance, backup.database, password, timestamp - ) - elif instance.db_type in ("postgres", "postgresql"): - filepath = await self._backup_postgres( - instance, backup.database, password, timestamp - ) - else: - raise ValueError(f"不支持的备份类型: {instance.db_type}") - - size_bytes = os.path.getsize(filepath) if os.path.exists(filepath) else 0 - return filepath, size_bytes - - async def _backup_mysql(self, instance: Instance, database: str, - password: str, timestamp: str) -> str: - """使用 mysqldump 备份 MySQL 数据库。 - - 输出通过 gzip 压缩存储。 - """ - filename = f"{database}_mysql_{timestamp}.sql.gz" - filepath = os.path.join(BACKUP_DIR, filename) - - # 检查 mysqldump 是否可用 - try: - proc = await asyncio.create_subprocess_exec( - "mysqldump", "--version", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - await proc.communicate() - if proc.returncode != 0: - raise RuntimeError("mysqldump 未安装") - except FileNotFoundError: - raise RuntimeError( - "mysqldump 未安装,请安装 MySQL 客户端工具:\\n" - " apt-get install mysql-client 或\\n" - " yum install mysql" - ) - - cmd = [ - "mysqldump", - "-h", instance.host, - "-P", str(instance.port), - "-u", instance.username, - f"-p{password}", - "--single-transaction", - "--routines", - "--triggers", - "--events", - "--hex-blob", - "--skip-lock-tables", - database, - ] - - # 创建 mysqldump 子进程,stdout 通过 gzip 压缩 - dump_proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - # 创建 gzip 子进程 - gzip_proc = await asyncio.create_subprocess_exec( - "gzip", "-c", - stdin=dump_proc.stdout, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - # 关闭 dump_proc 的 stdout 在本进程的引用 - if dump_proc.stdout: - dump_proc.stdout.close() - - # 读取 gzip 输出写入文件 - try: - with open(filepath, "wb") as f: - while True: - chunk = await gzip_proc.stdout.read(65536) if gzip_proc.stdout else b"" - if not chunk: - break - f.write(chunk) - - # 等待进程完成 - dump_stderr = await dump_proc.stderr.read() if dump_proc.stderr else b"" - gzip_stderr = await gzip_proc.stderr.read() if gzip_proc.stderr else b"" - - await dump_proc.wait() - await gzip_proc.wait() - - if dump_proc.returncode != 0: - error_msg = dump_stderr.decode("utf-8", errors="ignore")[-500:] - raise RuntimeError(f"mysqldump 执行失败: {error_msg}") - - if gzip_proc.returncode != 0: - error_msg = gzip_stderr.decode("utf-8", errors="ignore") - raise RuntimeError(f"gzip 压缩失败: {error_msg}") - - except Exception: - # 清理失败的文件 - if os.path.exists(filepath): - os.remove(filepath) - raise - - return filepath - - async def _backup_postgres(self, instance: Instance, database: str, - password: str, timestamp: str) -> str: - """使用 pg_dump 备份 PostgreSQL 数据库。 - - 使用 custom format(已内置压缩)。 - """ - filename = f"{database}_postgres_{timestamp}.dump" - filepath = os.path.join(BACKUP_DIR, filename) - - # 检查 pg_dump 是否可用 - try: - proc = await asyncio.create_subprocess_exec( - "pg_dump", "--version", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - await proc.communicate() - if proc.returncode != 0: - raise RuntimeError("pg_dump 未安装") - except FileNotFoundError: - raise RuntimeError( - "pg_dump 未安装,请安装 PostgreSQL 客户端工具:\\n" - " apt-get install postgresql-client 或\\n" - " yum install postgresql" - ) - - env = os.environ.copy() - env["PGPASSWORD"] = password - - cmd = [ - "pg_dump", - "-h", instance.host, - "-p", str(instance.port), - "-U", instance.username, - "-d", database, - "-F", "c", # custom format (compressed) - "-Z", "5", # compression level 5 - "--no-owner", # don't add ownership commands - "--no-privileges", # don't add privilege commands - "-f", filepath, - ] - - proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, - ) - - stdout, stderr = await proc.communicate() - - if proc.returncode != 0: - error_msg = stderr.decode("utf-8", errors="ignore")[-500:] - # 清理失败的文件 - if os.path.exists(filepath): - os.remove(filepath) - raise RuntimeError(f"pg_dump 执行失败: {error_msg}") - - return filepath - - async def restore_mysql(self, instance: Instance, database: str, - filepath: str) -> str: - """从备份恢复 MySQL 数据库。 - - Args: - instance: 目标实例 - database: 目标数据库 - filepath: 备份文件路径(.sql.gz) - """ - password = decrypt_password(instance.password) - - if filepath.endswith(".gz"): - # gunzip 解压后通过 mysql 导入 - gunzip_proc = await asyncio.create_subprocess_exec( - "gunzip", "-c", filepath, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - mysql_cmd = [ - "mysql", - "-h", instance.host, - "-P", str(instance.port), - "-u", instance.username, - f"-p{password}", - database, - ] - - mysql_proc = await asyncio.create_subprocess_exec( - *mysql_cmd, - stdin=gunzip_proc.stdout, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - - if gunzip_proc.stdout: - gunzip_proc.stdout.close() - - await mysql_proc.wait() - await gunzip_proc.wait() - - if mysql_proc.returncode != 0: - raise RuntimeError("MySQL 恢复失败") - else: - # 直接导入 .sql 文件 - with open(filepath, "rb") as f: - mysql_cmd = [ - "mysql", - "-h", instance.host, - "-P", str(instance.port), - "-u", instance.username, - f"-p{password}", - database, - ] - proc = await asyncio.create_subprocess_exec( - *mysql_cmd, - stdin=f, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - _, stderr = await proc.communicate() - if proc.returncode != 0: - raise RuntimeError(f"MySQL 恢复失败: {stderr.decode()[-500:]}") - - return f"数据库 {database} 恢复成功" - - async def restore_postgres(self, instance: Instance, database: str, - filepath: str) -> str: - """从备份恢复 PostgreSQL 数据库。 - - Args: - instance: 目标实例 - database: 目标数据库 - filepath: 备份文件路径(.dump) - """ - password = decrypt_password(instance.password) - env = os.environ.copy() - env["PGPASSWORD"] = password - - cmd = [ - "pg_restore", - "-h", instance.host, - "-p", str(instance.port), - "-U", instance.username, - "-d", database, - "--no-owner", - "--no-privileges", - "-c", # clean (drop) database objects before recreating - filepath, - ] - - proc = await asyncio.create_subprocess_exec( - *cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env=env, - ) - - _, stderr = await proc.communicate() - - # pg_restore 即使有一些警告也可能成功 - if proc.returncode not in (0, 1): # 1 = warnings - error_msg = stderr.decode("utf-8", errors="ignore")[-500:] - raise RuntimeError(f"pg_restore 失败: {error_msg}") - - return f"数据库 {database} 恢复成功" - - @staticmethod - def validate_backup_file(filepath: str) -> bool: - """验证备份文件是否有效。 - - - MySQL: 检查 gzip 文件完整性 - - PostgreSQL: 检查 custom format 文件 - """ - if not os.path.exists(filepath): - return False - - if filepath.endswith(".gz"): - # 使用 gzip -t 测试 - import subprocess - try: - result = subprocess.run( - ["gzip", "-t", filepath], - capture_output=True, timeout=30, - ) - return result.returncode == 0 - except (subprocess.TimeoutExpired, FileNotFoundError): - # 简单检查文件大小和魔数 - with open(filepath, "rb") as f: - magic = f.read(2) - return magic == b"\\x1f\\x8b" # gzip magic number - - elif filepath.endswith(".dump"): - # PostgreSQL custom format: 检查文件大小和魔数 - if os.path.getsize(filepath) < 100: - return False - with open(filepath, "rb") as f: - header = f.read(5) - # PostgreSQL custom format starts with "PGDMP" - return header == b"PGDMP" - - return os.path.getsize(filepath) > 0 - - @staticmethod - def get_backup_size(filepath: str) -> int: - """获取备份文件大小(字节)。""" - if os.path.exists(filepath): - return os.path.getsize(filepath) - return 0 diff --git a/backend/app/services/connection_pool.py b/backend/app/services/connection_pool.py deleted file mode 100644 index 7c21704..0000000 --- a/backend/app/services/connection_pool.py +++ /dev/null @@ -1,228 +0,0 @@ -"""数据库连接池管理 — 复用数据库连接,提高性能。 - -使用异步连接池: -- MySQL: aiomysql.Pool -- PostgreSQL: asyncpg.Pool - -连接池按实例 ID 管理,每个实例维护一个连接池。 -""" -import asyncio -import time -from dataclasses import dataclass, field -from typing import Dict, Optional - -from app.models.instance import Instance -from app.core.crypto import decrypt_password - - -@dataclass -class PoolStats: - """连接池统计信息。""" - instance_id: int - db_type: str - active_connections: int = 0 - idle_connections: int = 0 - max_connections: int = 5 - total_queries: int = 0 - failed_queries: int = 0 - avg_response_ms: int = 0 - pool_hit_rate: float = 1.0 - created_at: float = field(default_factory=time.time) - - def to_dict(self) -> dict: - return { - "instance_id": self.instance_id, - "db_type": self.db_type, - "active_connections": self.active_connections, - "idle_connections": self.idle_connections, - "max_connections": self.max_connections, - "total_queries": self.total_queries, - "failed_queries": self.failed_queries, - "avg_response_ms": self.avg_response_ms, - "pool_hit_rate": round(self.pool_hit_rate, 4), - } - - -class ConnectionPoolManager: - """数据库连接池管理器。""" - - def __init__(self): - # 连接池缓存: {instance_id: pool} - self._pools: Dict[int, Any] = {} - # 连接池统计 - self._stats: Dict[int, PoolStats] = {} - # 创建锁,防止并发创建重复连接池 - self._locks: Dict[int, asyncio.Lock] = {} - # 总体锁 - self._global_lock = asyncio.Lock() - - async def get_pool(self, instance: Instance): - """获取或创建连接池。""" - instance_id = instance.id - - # 检查现有连接池 - if instance_id in self._pools: - pool = self._pools[instance_id] - # 简单的健康检查 - try: - if instance.db_type == "mysql": - pool._free # 检查 aiomysql pool 是否活跃 - return pool - except Exception: - # 连接池已失效,重新创建 - await self.close_pool(instance_id) - - # 获取或创建实例锁 - if instance_id not in self._locks: - self._locks[instance_id] = asyncio.Lock() - - async with self._locks[instance_id]: - # 双重检查 - if instance_id in self._pools: - return self._pools[instance_id] - - pool = await self._create_pool(instance) - self._pools[instance_id] = pool - self._stats[instance_id] = PoolStats( - instance_id=instance_id, - db_type=instance.db_type, - max_connections=instance.connection_pool_size or 5, - ) - return pool - - async def _create_pool(self, instance: Instance): - """创建连接池。""" - password = decrypt_password(instance.password) - pool_size = instance.connection_pool_size or 5 - - if instance.db_type == "mysql": - import aiomysql - return await aiomysql.create_pool( - host=instance.host, - port=instance.port, - user=instance.username, - password=password, - db=instance.database, - minsize=1, - maxsize=pool_size, - pool_recycle=3600, # 1小时回收连接 - connect_timeout=10, - autocommit=True, - ) - elif instance.db_type in ("postgres", "postgresql"): - import asyncpg - dsn = ( - f"postgresql://{instance.username}:{password}" - f"@{instance.host}:{instance.port}" - f"/{instance.database or 'postgres'}" - ) - return await asyncpg.create_pool( - dsn=dsn, - min_size=1, - max_size=pool_size, - command_timeout=60, - max_inactive_connection_lifetime=3600, - ) - else: - raise ValueError(f"不支持的连接池类型: {instance.db_type}") - - async def close_pool(self, instance_id: int) -> None: - """关闭指定实例的连接池。""" - pool = self._pools.pop(instance_id, None) - if pool: - try: - pool.close() - await pool.wait_closed() - except Exception: - pass - self._stats.pop(instance_id, None) - - async def close_all(self) -> None: - """关闭所有连接池。""" - for instance_id in list(self._pools.keys()): - await self.close_pool(instance_id) - self._pools.clear() - self._stats.clear() - - async def get_connection(self, instance: Instance): - """从连接池获取一个连接(上下文管理器)。""" - pool = await self.get_pool(instance) - return pool.acquire() - - def get_pool_stats(self, instance_id: int) -> Optional[PoolStats]: - """获取连接池统计。""" - return self._stats.get(instance_id) - - def get_all_stats(self) -> Dict[int, PoolStats]: - """获取所有连接池统计。""" - return dict(self._stats) - - async def health_check(self, instance_id: int) -> bool: - """检查连接池健康状态。""" - pool = self._pools.get(instance_id) - if not pool: - return False - - try: - if hasattr(pool, '_free'): - # aiomysql - conn = await pool.acquire() - try: - async with conn.cursor() as cur: - await cur.execute("SELECT 1") - await cur.fetchone() - return True - finally: - pool.release(conn) - else: - # asyncpg - conn = await pool.acquire() - try: - await conn.fetchval("SELECT 1") - return True - finally: - await pool.release(conn) - except Exception: - return False - - async def reset_pool(self, instance_id: int) -> None: - """重置连接池(关闭并重新创建)。""" - await self.close_pool(instance_id) - - def update_stats(self, instance_id: int, query_success: bool = True, - response_ms: int = 0) -> None: - """更新查询统计。""" - stats = self._stats.get(instance_id) - if not stats: - return - - stats.total_queries += 1 - if not query_success: - stats.failed_queries += 1 - - # 移动平均响应时间 - if response_ms > 0: - if stats.avg_response_ms == 0: - stats.avg_response_ms = response_ms - else: - stats.avg_response_ms = (stats.avg_response_ms * 9 + response_ms) // 10 - - -# 全局连接池管理器实例 -_pool_manager: Optional[ConnectionPoolManager] = None - - -def get_pool_manager() -> ConnectionPoolManager: - """获取全局连接池管理器。""" - global _pool_manager - if _pool_manager is None: - _pool_manager = ConnectionPoolManager() - return _pool_manager - - -async def close_all_pools(): - """关闭所有连接池(应用关闭时调用)。""" - global _pool_manager - if _pool_manager: - await _pool_manager.close_all() - _pool_manager = None diff --git a/backend/app/services/data_masker.py b/backend/app/services/data_masker.py deleted file mode 100644 index 1b338be..0000000 --- a/backend/app/services/data_masker.py +++ /dev/null @@ -1,401 +0,0 @@ -"""数据脱敏引擎 — 对查询结果进行动态脱敏处理。 - -支持脱敏类型: -- full: 完全遮挡 (****) -- partial: 部分遮挡 (138****8888, 李**) -- hash: SHA256 哈希前16位 -- truncate: 截断显示 (abc...) -- regex: 正则表达式替换 - -使用方式: - rules = await load_masking_rules(instance_id, database) - masker = DataMasker(rules) - masked_rows = [masker.mask_row(table_name, row) for row in rows] -""" -import hashlib -import json -import re -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, Dict, List, Optional, Callable - - -class MaskType(str, Enum): - """脱敏类型枚举。""" - FULL = "full" - PARTIAL = "partial" - HASH = "hash" - TRUNCATE = "truncate" - REGEX = "regex" - - -@dataclass -class MaskingRule: - """脱敏规则数据类,用于运行时脱敏引擎。 - - Attributes: - instance_id: 数据源实例ID - database: 数据库名称 - table_name: 表名(支持通配符 * 表示所有表) - column_name: 列名(支持通配符 * 表示所有列) - mask_type: 脱敏类型 - mask_config: 类型特定的配置字典 - is_enabled: 是否启用 - """ - instance_id: int - database: str - table_name: str - column_name: str - mask_type: MaskType - mask_config: Dict[str, Any] = field(default_factory=dict) - is_enabled: bool = True - - @classmethod - def from_db_model(cls, db_rule: Any) -> "MaskingRule": - """从数据库模型创建 MaskingRule 实例。 - - Args: - db_rule: SQLAlchemy MaskingRule 模型实例 - - Returns: - MaskingRule 数据类实例 - """ - config: Dict[str, Any] = {} - if db_rule.mask_config: - try: - config = json.loads(db_rule.mask_config) - except json.JSONDecodeError: - config = {} - - mask_type = MaskType(db_rule.mask_type) if db_rule.mask_type in { - m.value for m in MaskType - } else MaskType.FULL - - return cls( - instance_id=db_rule.instance_id, - database=db_rule.database, - table_name=db_rule.table_name, - column_name=db_rule.column_name, - mask_type=mask_type, - mask_config=config, - is_enabled=db_rule.is_enabled, - ) - - -class DataMasker: - """数据脱敏引擎,对查询结果进行动态脱敏。 - - 根据配置的脱敏规则,对行数据进行后处理脱敏。 - 脱敏不改变原始查询语句,仅对返回结果进行处理。 - - Attributes: - _rules: 脱敏规则列表 - _rule_map: 规则查找映射 {(table, column): MaskingRule} - _table_wildcards: 表级通配规则 {table: [rules]} - _global_columns: 全局列规则 {column: rule} - """ - - def __init__(self, rules: List[MaskingRule]) -> None: - """初始化脱敏引擎。 - - Args: - rules: 脱敏规则列表 - """ - self._rules: List[MaskingRule] = [r for r in rules if r.is_enabled] - self._rule_map: Dict[tuple, MaskingRule] = {} - self._table_wildcards: Dict[str, List[MaskingRule]] = {} - self._global_columns: Dict[str, MaskingRule] = {} - - for rule in self._rules: - if rule.table_name == "*" and rule.column_name == "*": - # 全局通配规则 — 暂不支持 - continue - elif rule.table_name == "*": - # 全局列规则:适用于所有表的指定列 - self._global_columns[rule.column_name.lower()] = rule - elif rule.column_name == "*": - # 表级通配规则:适用于指定表的所有列 - self._table_wildcards.setdefault(rule.table_name.lower(), []).append(rule) - else: - # 精确匹配规则 - key = (rule.table_name.lower(), rule.column_name.lower()) - self._rule_map[key] = rule - - # ===== 公共接口 ===== - - def mask(self, table: str, column: str, value: Any) -> Any: - """对单个值进行脱敏。 - - Args: - table: 表名 - column: 列名 - value: 原始值 - - Returns: - 脱敏后的值。如果值为 None 或规则不存在,返回原值。 - """ - if value is None: - return None - - rule = self._find_rule(table, column) - if not rule: - return value - - return self._apply_mask(rule, value) - - def mask_row(self, table: str, row: Dict[str, Any]) -> Dict[str, Any]: - """对整行数据进行脱敏。 - - Args: - table: 表名 - row: 行数据字典 {column: value} - - Returns: - 脱敏后的行数据字典 - """ - if not row: - return row - - result: Dict[str, Any] = {} - for col, val in row.items(): - result[col] = self.mask(table, col, val) - return result - - def mask_rows(self, table: str, rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """对多行数据进行脱敏。 - - Args: - table: 表名 - rows: 行数据列表 - - Returns: - 脱敏后的行数据列表 - """ - if not rows: - return rows - return [self.mask_row(table, row) for row in rows] - - def has_rules_for_table(self, table: str) -> bool: - """检查是否有规则适用于指定表。 - - Args: - table: 表名 - - Returns: - 如果存在适用的脱敏规则返回 True - """ - table_lower = table.lower() - # 检查精确匹配 - if any(k[0] == table_lower for k in self._rule_map): - return True - # 检查表级通配 - if table_lower in self._table_wildcards: - return True - # 检查全局列规则 - if self._global_columns: - return True - return False - - # ===== 规则查找 ===== - - def _find_rule(self, table: str, column: str) -> Optional[MaskingRule]: - """查找适用于指定表和列的脱敏规则。 - - 优先级: 精确匹配 > 表级通配 > 全局列规则 - - Args: - table: 表名 - column: 列名 - - Returns: - 匹配的脱敏规则,未找到返回 None - """ - table_lower = table.lower() - col_lower = column.lower() - - # 1. 精确匹配 - key = (table_lower, col_lower) - if key in self._rule_map: - return self._rule_map[key] - - # 2. 表级通配 (table.*) - if table_lower in self._table_wildcards: - for rule in self._table_wildcards[table_lower]: - if rule.column_name == "*": - return rule - - # 3. 全局列规则 (*.column) - if col_lower in self._global_columns: - return self._global_columns[col_lower] - - return None - - # ===== 脱敏实现 ===== - - def _apply_mask(self, rule: MaskingRule, value: Any) -> Any: - """根据规则类型应用对应的脱敏算法。 - - Args: - rule: 脱敏规则 - value: 原始值 - - Returns: - 脱敏后的值 - - Raises: - ValueError: 不支持的脱敏类型 - """ - # 非字符串值转换为字符串处理 - str_value = str(value) if value is not None else "" - - mask_methods: Dict[MaskType, Callable[[str, Dict[str, Any]], str]] = { - MaskType.FULL: self._mask_full, - MaskType.PARTIAL: self._mask_partial, - MaskType.HASH: self._mask_hash, - MaskType.TRUNCATE: self._mask_truncate, - MaskType.REGEX: self._mask_regex, - } - - method = mask_methods.get(rule.mask_type) - if not method: - raise ValueError(f"不支持的脱敏类型: {rule.mask_type}") - - return method(str_value, rule.mask_config) - - @staticmethod - def _mask_full(value: str, config: Dict[str, Any]) -> str: - """完全遮挡 — 将值替换为固定数量的星号。 - - Args: - value: 原始值 - config: 配置项 {length: 遮挡长度(默认4), char: 遮挡字符(默认*)} - - Returns: - 完全遮挡后的字符串 - """ - length = config.get("length", 4) - char = config.get("char", "*") - return char * length - - @staticmethod - def _mask_partial(value: str, config: Dict[str, Any]) -> str: - """部分遮挡 — 保留前后若干字符,中间用星号替换。 - - Args: - value: 原始值 - config: 配置项 { - keep_prefix: 保留前缀字符数(默认2), - keep_suffix: 保留后缀字符数(默认2), - mask_char: 遮挡字符(默认*) - } - - Returns: - 部分遮挡后的字符串 - - Examples: - >>> DataMasker._mask_partial("13812345678", {"keep_prefix": 3, "keep_suffix": 4}) - '138****5678' - >>> DataMasker._mask_partial("张三丰", {"keep_prefix": 1, "keep_suffix": 0}) - '张**' - """ - keep_prefix = config.get("keep_prefix", 2) - keep_suffix = config.get("keep_suffix", 2) - mask_char = config.get("mask_char", "*") - - if len(value) <= keep_prefix + keep_suffix: - # 值太短,全部遮挡 - return mask_char * len(value) - - prefix = value[:keep_prefix] - suffix = value[-keep_suffix:] if keep_suffix > 0 else "" - mask_len = len(value) - keep_prefix - keep_suffix - return prefix + mask_char * mask_len + suffix - - @staticmethod - def _mask_hash(value: str, config: Dict[str, Any]) -> str: - """SHA256 哈希 — 取哈希值的前N位。 - - Args: - value: 原始值 - config: 配置项 {length: 截取长度(默认16)} - - Returns: - 哈希前缀字符串 - """ - length = config.get("length", 16) - hash_val = hashlib.sha256(value.encode("utf-8")).hexdigest() - return hash_val[:length] - - @staticmethod - def _mask_truncate(value: str, config: Dict[str, Any]) -> str: - """截断显示 — 只保留前N个字符,后面加省略号。 - - Args: - value: 原始值 - config: 配置项 {length: 保留长度(默认3), ellipsis: 省略号(默认...) - - Returns: - 截断后的字符串 - """ - length = config.get("length", 3) - ellipsis = config.get("ellipsis", "...") - - if len(value) <= length: - return value - return value[:length] + ellipsis - - @staticmethod - def _mask_regex(value: str, config: Dict[str, Any]) -> str: - """正则表达式替换 — 使用正则匹配并替换。 - - Args: - value: 原始值 - config: 配置项 { - pattern: 正则表达式, - replacement: 替换字符串(默认 ***) - } - - Returns: - 替换后的字符串 - - Raises: - ValueError: 正则表达式无效 - """ - pattern = config.get("pattern", ".*") - replacement = config.get("replacement", "***") - - try: - return re.sub(pattern, replacement, value) - except re.error as e: - raise ValueError(f"无效的正则表达式: {pattern}, 错误: {e}") - - -# ===== 便捷函数 ===== - -async def load_masking_rules( - db: Any, instance_id: int, database: str -) -> List[MaskingRule]: - """从数据库加载指定实例和数据库的脱敏规则。 - - Args: - db: 异步数据库会话 (AsyncSession) - instance_id: 数据源实例ID - database: 数据库名称 - - Returns: - MaskingRule 列表 - """ - from sqlalchemy import select - from app.models.masking_rule import MaskingRule as MaskingRuleModel - - stmt = ( - select(MaskingRuleModel) - .where( - MaskingRuleModel.instance_id == instance_id, - MaskingRuleModel.database == database, - MaskingRuleModel.is_enabled == True, - ) - ) - result = await db.execute(stmt) - db_rules = result.scalars().all() - return [MaskingRule.from_db_model(r) for r in db_rules] diff --git a/backend/app/services/db_connector.py b/backend/app/services/db_connector.py deleted file mode 100644 index 258a41e..0000000 --- a/backend/app/services/db_connector.py +++ /dev/null @@ -1,345 +0,0 @@ -"""Database connector — unified interface for MySQL/PostgreSQL/SQLite. - -Provides real database connections for query execution and connection testing. -Replaces mock aiomysql calls with actual driver connections. -""" -import asyncio -import time -import asyncio -import sqlite3 -from typing import Optional, List, Dict, Any, Tuple - -from app.models.instance import Instance -from app.services.sql_analyzer import SQLAnalyzer - -MAX_ROWS = 10000 -QUERY_TIMEOUT = 30 -DML_TIMEOUT = 60 - - -class ConnectionResult: - """Result of a connection test.""" - def __init__(self, success: bool, message: str, version: str = None, latency_ms: int = 0): - self.success = success - self.message = message - self.version = version - self.latency_ms = latency_ms - - def to_dict(self): - return { - "success": self.success, - "message": self.message, - "version": self.version, - "latency_ms": self.latency_ms, - } - - -class QueryResult: - """Result of a SQL query execution.""" - def __init__(self, columns: List[str], rows: List[Dict[str, Any]], - total: int, elapsed_ms: int, truncated: bool = False): - self.columns = columns - self.rows = rows - self.total = total - self.elapsed_ms = elapsed_ms - self.truncated = truncated - - -class DBConnector: - """Unified database connector supporting MySQL, PostgreSQL, SQLite.""" - - _analyzer = SQLAnalyzer() - - @staticmethod - async def test_connection(instance: Instance) -> ConnectionResult: - """Test database connectivity and return version info.""" - start = time.time() - - try: - if instance.db_type == "mysql": - return await DBConnector._test_mysql(instance, start) - elif instance.db_type in ("postgres", "postgresql"): - return await DBConnector._test_postgres(instance, start) - elif instance.db_type == "sqlite": - return await DBConnector._test_sqlite(instance, start) - elif instance.db_type == "redis": - return await DBConnector._test_redis(instance, start) - elif instance.db_type == "mongodb": - return await DBConnector._test_mongodb(instance, start) - else: - return ConnectionResult(False, f"暂不支持 {instance.db_type} 类型数据源") - except Exception as e: - elapsed = int((time.time() - start) * 1000) - return ConnectionResult(False, f"连接失败: {str(e)}", latency_ms=elapsed) - - - @staticmethod - async def _test_redis(instance: 'Instance', start: float) -> ConnectionResult: - """Test Redis connectivity using synchronous redis client in thread.""" - try: - import redis as redis_lib - - def _connect(): - client = redis_lib.Redis( - host=instance.host, - port=instance.port, - password=instance.password if instance.password and instance.password != "default" else None, - db=0, - socket_connect_timeout=5, - socket_timeout=5, - ) - info = client.info("server") - version = info.get("redis_version", "unknown") - client.close() - return version - - version = await asyncio.to_thread(_connect) - elapsed = int((time.time() - start) * 1000) - return ConnectionResult(True, f"Redis {version} 连接成功", version=version, latency_ms=elapsed) - except Exception as e: - elapsed = int((time.time() - start) * 1000) - return ConnectionResult(False, f"Redis 连接失败: {str(e)}", latency_ms=elapsed) - - @staticmethod - async def _test_mongodb(instance: 'Instance', start: float) -> ConnectionResult: - """Test MongoDB connectivity using pymongo in thread.""" - try: - from pymongo import MongoClient - - def _connect(): - if instance.username and instance.username != "default": - uri = f"mongodb://{instance.username}:{instance.password}@{instance.host}:{instance.port}/" - else: - uri = f"mongodb://{instance.host}:{instance.port}/" - client = MongoClient(uri, serverSelectionTimeoutMS=5000, connectTimeoutMS=5000) - info = client.server_info() - version = info.get("version", "unknown") - client.close() - return version - - version = await asyncio.to_thread(_connect) - elapsed = int((time.time() - start) * 1000) - return ConnectionResult(True, f"MongoDB {version} 连接成功", version=version, latency_ms=elapsed) - except Exception as e: - elapsed = int((time.time() - start) * 1000) - return ConnectionResult(False, f"MongoDB 连接失败: {str(e)}", latency_ms=elapsed) - - @staticmethod - async def execute_query( - instance: Instance, database: Optional[str], sql: str, user_id: int - ) -> 'QueryResult': - """Execute a read-only SQL query on the target database.""" - # Validate SQL is read-only - is_safe, error_msg = DBConnector._analyzer.validate_readonly(sql) - if not is_safe: - raise ValueError(error_msg) - - # Auto-add LIMIT - upper_sql = sql.strip().upper() - import re - if not re.search(r"LIMIT\s+\d+", upper_sql, re.IGNORECASE): - sql = sql.rstrip(";") + f" LIMIT {MAX_ROWS};" - - start = time.time() - - if instance.db_type == "mysql": - rows, columns = await DBConnector._query_mysql(instance, database, sql) - elif instance.db_type in ("postgres", "postgresql"): - rows, columns = await DBConnector._query_postgres(instance, database, sql) - elif instance.db_type == "sqlite": - rows, columns = await DBConnector._query_sqlite(instance, sql) - else: - raise ValueError(f"暂不支持 {instance.db_type} 查询") - - elapsed = int((time.time() - start) * 1000) - return QueryResult( - columns=columns, - rows=rows, - total=len(rows), - elapsed_ms=elapsed, - truncated=len(rows) >= MAX_ROWS, - ) - - @staticmethod - async def execute_dml(instance: Instance, database: str, sql: str) -> str: - """Execute a DML statement (INSERT/UPDATE/DELETE).""" - if instance.db_type == "mysql": - return await DBConnector._dml_mysql(instance, database, sql) - elif instance.db_type in ("postgres", "postgresql"): - return await DBConnector._dml_postgres(instance, database, sql) - elif instance.db_type == "sqlite": - return await DBConnector._dml_sqlite(instance, sql) - else: - raise ValueError(f"暂不支持 {instance.db_type} 执行") - - # ===== MySQL ===== - - @staticmethod - async def _test_mysql(instance: Instance, start: float) -> ConnectionResult: - import aiomysql - conn = await asyncio.wait_for( - aiomysql.connect( - host=instance.host, port=instance.port, - user=instance.username, password=instance.password, - db=instance.database, - ), - timeout=10, - ) - try: - async with conn.cursor() as cur: - await cur.execute("SELECT VERSION()") - version = (await cur.fetchone())[0] - elapsed = int((time.time() - start) * 1000) - return ConnectionResult(True, "连接成功", version=version, latency_ms=elapsed) - finally: - conn.close() - - @staticmethod - async def _query_mysql(instance: Instance, database: Optional[str], sql: str) -> Tuple[List[Dict], List[str]]: - 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 asyncio.wait_for(cur.execute(sql), timeout=QUERY_TIMEOUT) - rows = await cur.fetchall() - columns = [desc[0] for desc in cur.description] if cur.description else [] - return rows, columns - except asyncio.TimeoutError: - raise ValueError("查询超时(30秒)") - finally: - conn.close() - - @staticmethod - async def _dml_mysql(instance: Instance, database: str, sql: str) -> str: - import aiomysql - conn = await aiomysql.connect( - host=instance.host, port=instance.port, - user=instance.username, password=instance.password, - db=database, autocommit=True, - ) - try: - async with conn.cursor() as cur: - await asyncio.wait_for(cur.execute(sql), timeout=DML_TIMEOUT) - affected = cur.rowcount - return f"执行成功,影响 {affected} 行" - except asyncio.TimeoutError: - raise ValueError("执行超时(60秒)") - finally: - conn.close() - - # ===== PostgreSQL ===== - - @staticmethod - async def _test_postgres(instance: Instance, start: float) -> ConnectionResult: - import asyncpg - dsn = f"postgresql://{instance.username}:{instance.password}@{instance.host}:{instance.port}/{instance.database or 'postgres'}" - conn = await asyncio.wait_for(asyncpg.connect(dsn), timeout=10) - try: - version = await conn.fetchval("SELECT version()") - elapsed = int((time.time() - start) * 1000) - # version string is long, take first part - ver_short = version.split(",")[0] if version else "unknown" - return ConnectionResult(True, "连接成功", version=ver_short, latency_ms=elapsed) - finally: - await conn.close() - - @staticmethod - async def _query_postgres(instance: Instance, database: Optional[str], sql: str) -> Tuple[List[Dict], List[str]]: - import asyncpg - dsn = f"postgresql://{instance.username}:{instance.password}@{instance.host}:{instance.port}/{database or instance.database or 'postgres'}" - conn = await asyncpg.connect(dsn) - try: - # asyncpg returns Records, convert to dicts - stmt = await asyncio.wait_for(conn.fetch(sql), timeout=QUERY_TIMEOUT) - rows = [dict(r) for r in stmt] - columns = list(stmt[0].keys()) if stmt else [] - return rows, columns - except asyncio.TimeoutError: - raise ValueError("查询超时(30秒)") - finally: - await conn.close() - - @staticmethod - async def _dml_postgres(instance: Instance, database: str, sql: str) -> str: - import asyncpg - dsn = f"postgresql://{instance.username}:{instance.password}@{instance.host}:{instance.port}/{database}" - conn = await asyncpg.connect(dsn) - try: - status = await asyncio.wait_for(conn.execute(sql), timeout=DML_TIMEOUT) - # status like "UPDATE 3" - parts = status.split() - affected = int(parts[1]) if len(parts) >= 2 else 0 - return f"执行成功,影响 {affected} 行" - except asyncio.TimeoutError: - raise ValueError("执行超时(60秒)") - finally: - await conn.close() - - # ===== SQLite (remote instance) ===== - - @staticmethod - async def _test_sqlite(instance: Instance, start: float) -> ConnectionResult: - # SQLite uses host as file path - db_path = instance.host - loop = asyncio.get_event_loop() - try: - def _connect(): - conn = sqlite3.connect(db_path) - cur = conn.execute("SELECT sqlite_version()") - ver = cur.fetchone()[0] - conn.close() - return ver - version = await loop.run_in_executor(None, _connect) - elapsed = int((time.time() - start) * 1000) - return ConnectionResult(True, "连接成功", version=f"SQLite {version}", latency_ms=elapsed) - except Exception as e: - elapsed = int((time.time() - start) * 1000) - return ConnectionResult(False, f"连接失败: {e}", latency_ms=elapsed) - - @staticmethod - async def _query_sqlite(instance: Instance, sql: str) -> Tuple[List[Dict], List[str]]: - db_path = instance.host - loop = asyncio.get_event_loop() - - def _query(): - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row - cur = conn.execute(sql) - rows = [dict(r) for r in cur.fetchall()] - columns = [desc[0] for desc in cur.description] if cur.description else [] - conn.close() - return rows, columns - - try: - return await asyncio.wait_for( - loop.run_in_executor(None, _query), - timeout=QUERY_TIMEOUT, - ) - except asyncio.TimeoutError: - raise ValueError("查询超时(30秒)") - - @staticmethod - async def _dml_sqlite(instance: Instance, sql: str) -> str: - db_path = instance.host - loop = asyncio.get_event_loop() - - def _execute(): - conn = sqlite3.connect(db_path) - cur = conn.execute(sql) - affected = cur.rowcount - conn.commit() - conn.close() - return affected - - try: - affected = await asyncio.wait_for( - loop.run_in_executor(None, _execute), - timeout=DML_TIMEOUT, - ) - return f"执行成功,影响 {affected} 行" - except asyncio.TimeoutError: - raise ValueError("执行超时(60秒)") diff --git a/backend/app/services/dictionary_service.py b/backend/app/services/dictionary_service.py deleted file mode 100644 index 99a6b14..0000000 --- a/backend/app/services/dictionary_service.py +++ /dev/null @@ -1,240 +0,0 @@ -"""数据字典服务 — 通过 DBConnector 连接数据源获取元数据。""" -import re -from typing import List, Dict, Any, Optional - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.instance import Instance -from app.services.db_connector import DBConnector -from app.core.crypto import decrypt_password - - -async def _get_instance(session: AsyncSession, instance_id: int) -> Instance: - """根据 ID 查询实例,解密密码后返回。""" - result = await session.execute(select(Instance).where(Instance.id == instance_id)) - instance = result.scalar_one_or_none() - if not instance: - return None - instance.password = decrypt_password(instance.password) - return instance - - -async def list_databases(session: AsyncSession, instance_id: int) -> Optional[List[str]]: - """获取实例的数据库列表。""" - instance = await _get_instance(session, instance_id) - if not instance: - return None - - if instance.db_type == "mysql": - qr = await DBConnector.execute_query( - instance, None, "SHOW DATABASES", user_id=0 - ) - system_dbs = {"information_schema", "mysql", "performance_schema", "sys"} - return [r.get("Database", r.get("database", "")) for r in qr.rows - if r.get("Database", r.get("database", "")) not in system_dbs] - elif instance.db_type in ("postgres", "postgresql"): - qr = await DBConnector.execute_query( - instance, "postgres", - "SELECT datname FROM pg_database WHERE datistemplate = false", - user_id=0, - ) - return [r.get("datname", "") for r in qr.rows] - elif instance.db_type == "sqlite": - return [instance.database or "main"] - else: - return [] - - -async def list_tables( - session: AsyncSession, instance_id: int, database: str -) -> List[str]: - """获取指定数据库的表名列表。""" - instance = await _get_instance(session, instance_id) - if not instance: - return [] - - if instance.db_type == "mysql": - sql = ( - "SELECT TABLE_NAME FROM information_schema.TABLES " - "WHERE TABLE_SCHEMA = '" + database + "' AND TABLE_TYPE = 'BASE TABLE'" - ) - qr = await DBConnector.execute_query(instance, database, sql, user_id=0) - return [r.get("TABLE_NAME", "") for r in qr.rows] - elif instance.db_type in ("postgres", "postgresql"): - sql = "SELECT tablename FROM pg_tables WHERE schemaname = 'public'" - qr = await DBConnector.execute_query(instance, database, sql, user_id=0) - return [r.get("tablename", "") for r in qr.rows] - elif instance.db_type == "sqlite": - sql = "SELECT name FROM sqlite_master WHERE type='table'" - qr = await DBConnector.execute_query(instance, None, sql, user_id=0) - return [r.get("name", "") for r in qr.rows] - else: - return [] - - -async def get_table_detail( - session: AsyncSession, instance_id: int, database: str, table_name: str -) -> Dict[str, Any]: - """获取表详情: columns / indexes / create_ddl / row_count。""" - instance = await _get_instance(session, instance_id) - if not instance: - return {} - - columns = [] - indexes = [] - create_ddl = "" - row_count = 0 - - if instance.db_type == "mysql": - # Columns - col_sql = ( - "SELECT COLUMN_NAME as Field, COLUMN_TYPE as Type, " - "IS_NULLABLE as `Null`, COLUMN_KEY as `Key`, " - "COLUMN_DEFAULT as `Default`, EXTRA as Extra, " - "COLUMN_COMMENT as Comment " - "FROM information_schema.COLUMNS " - "WHERE TABLE_SCHEMA = '" + database + "' " - "AND TABLE_NAME = '" + table_name + "' " - "ORDER BY ORDINAL_POSITION" - ) - col_qr = await DBConnector.execute_query(instance, database, col_sql, user_id=0) - columns = col_qr.rows - - # Indexes - idx_sql = "SHOW INDEX FROM `" + table_name + "`" - idx_qr = await DBConnector.execute_query(instance, database, idx_sql, user_id=0) - # Group by index name - idx_map = {} - for r in idx_qr.rows: - idx_name = r.get("Key_name", "") - if idx_name not in idx_map: - idx_map[idx_name] = { - "name": idx_name, - "columns": [], - "non_unique": r.get("Non_unique", 1), - "index_type": r.get("Index_type", "BTREE"), - } - idx_map[idx_name]["columns"].append(r.get("Column_name", "")) - indexes = list(idx_map.values()) - - # Create DDL - ddl_sql = "SHOW CREATE TABLE `" + table_name + "`" - ddl_qr = await DBConnector.execute_query(instance, database, ddl_sql, user_id=0) - if ddl_qr.rows: - create_ddl = ddl_qr.rows[0].get("Create Table", "") - - # Row count - cnt_sql = ( - "SELECT TABLE_ROWS FROM information_schema.TABLES " - "WHERE TABLE_SCHEMA = '" + database + "' " - "AND TABLE_NAME = '" + table_name + "'" - ) - cnt_qr = await DBConnector.execute_query(instance, database, cnt_sql, user_id=0) - if cnt_qr.rows: - row_count = cnt_qr.rows[0].get("TABLE_ROWS", 0) - - elif instance.db_type in ("postgres", "postgresql"): - # Columns - col_sql = ( - "SELECT column_name as Field, data_type as Type, " - "is_nullable as \"Null\", column_default as \"Default\", " - "'' as \"Key\", '' as Extra " - "FROM information_schema.columns " - "WHERE table_schema = 'public' " - "AND table_name = '" + table_name + "' " - "ORDER BY ordinal_position" - ) - col_qr = await DBConnector.execute_query(instance, database, col_sql, user_id=0) - columns = col_qr.rows - - # Indexes - idx_sql = ( - "SELECT indexname as name, indexdef as definition " - "FROM pg_indexes " - "WHERE schemaname = 'public' AND tablename = '" + table_name + "'" - ) - idx_qr = await DBConnector.execute_query(instance, database, idx_sql, user_id=0) - indexes = idx_qr.rows - - # Create DDL — best effort via pg_dump not available; use a placeholder - create_ddl = "-- PostgreSQL DDL via pg_indexes:\n" - for idx in idx_qr.rows: - create_ddl += idx.get("definition", "") + ";\n" - - # Row count - cnt_sql = "SELECT count(*) as cnt FROM \"" + table_name + "\"" - try: - cnt_qr = await DBConnector.execute_query(instance, database, cnt_sql, user_id=0) - if cnt_qr.rows: - row_count = cnt_qr.rows[0].get("cnt", 0) - except Exception: - row_count = -1 - - elif instance.db_type == "sqlite": - # Columns - col_sql = "PRAGMA table_info('" + table_name + "')" - col_qr = await DBConnector.execute_query(instance, None, col_sql, user_id=0) - columns = [] - for r in col_qr.rows: - columns.append({ - "Field": r.get("name", ""), - "Type": r.get("type", ""), - "Null": "YES" if r.get("notnull", 0) == 0 else "NO", - "Key": "PRI" if r.get("pk", 0) == 1 else "", - "Default": r.get("dflt_value", ""), - "Extra": "", - }) - - # Indexes - idx_sql = "PRAGMA index_list('" + table_name + "')" - idx_qr = await DBConnector.execute_query(instance, None, idx_sql, user_id=0) - indexes = [] - for r in idx_qr.rows: - indexes.append({ - "name": r.get("name", ""), - "unique": r.get("unique", 0), - }) - - # Create DDL - ddl_sql = ( - "SELECT sql FROM sqlite_master WHERE type='table' AND name='" - + table_name + "'" - ) - ddl_qr = await DBConnector.execute_query(instance, None, ddl_sql, user_id=0) - if ddl_qr.rows: - create_ddl = ddl_qr.rows[0].get("sql", "") - - # Row count - cnt_sql = "SELECT count(*) as cnt FROM '" + table_name + "'" - try: - cnt_qr = await DBConnector.execute_query(instance, None, cnt_sql, user_id=0) - if cnt_qr.rows: - row_count = cnt_qr.rows[0].get("cnt", 0) - except Exception: - row_count = -1 - - return { - "columns": columns, - "indexes": indexes, - "create_ddl": create_ddl, - "row_count": row_count, - } - - -async def export_dictionary( - session: AsyncSession, instance_id: int, database: str -) -> List[Dict[str, Any]]: - """导出整个数据库的表结构信息。""" - tables = await list_tables(session, instance_id, database) - result = [] - for tbl in tables: - detail = await get_table_detail(session, instance_id, database, tbl) - result.append({ - "table_name": tbl, - "columns": detail.get("columns", []), - "indexes": detail.get("indexes", []), - "create_ddl": detail.get("create_ddl", ""), - "row_count": detail.get("row_count", 0), - }) - return result diff --git a/backend/app/services/explain_preview.py b/backend/app/services/explain_preview.py deleted file mode 100644 index d3ea7b1..0000000 --- a/backend/app/services/explain_preview.py +++ /dev/null @@ -1,335 +0,0 @@ -"""EXPLAIN 预检服务 — 工单创建时跑 EXPLAIN 评估 SQL 影响。 - -支持: -- MySQL: EXPLAIN / EXPLAIN FORMAT=JSON -- PostgreSQL: EXPLAIN (FORMAT JSON, COSTS, BUFFERS) -- 影响行数预估 -- 全表扫描检测 -- 大表 ALTER 风险提示 -""" - -import json -import logging -import re -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional - -from app.models.instance import Instance -from app.core.crypto import decrypt_password - -logger = logging.getLogger(__name__) - - -@dataclass -class ExplainResult: - """EXPLAIN 预检结果。""" - sql: str = "" - sql_type: str = "" # DQL/DML/DDL - estimated_rows: int = 0 - full_table_scan: bool = False - using_index: bool = True - warnings: List[str] = field(default_factory=list) - explain_raw: Optional[str] = None - table_sizes: Dict[str, int] = field(default_factory=dict) # table_name -> row_count - error: Optional[str] = None - - def to_dict(self) -> Dict[str, Any]: - return { - "sql": self.sql[:100], - "sql_type": self.sql_type, - "estimated_rows": self.estimated_rows, - "full_table_scan": self.full_table_scan, - "using_index": self.using_index, - "warnings": self.warnings, - "table_sizes": self.table_sizes, - "error": self.error, - } - - -# 大表阈值(行数) -LARGE_TABLE_THRESHOLD = 100_000 -# 影响行数警告阈值 -AFFECTED_ROWS_WARNING = 10_000 -AFFECTED_ROWS_CRITICAL = 100_000 - - -async def run_explain_preview( - sql: str, - instance: Instance, - target_database: str, -) -> ExplainResult: - """对 SQL 执行 EXPLAIN 预检。 - - Args: - sql: SQL 语句 - instance: 数据源实例 - target_database: 目标数据库 - - Returns: - ExplainResult - """ - result = ExplainResult(sql=sql) - - sql_upper = sql.strip().upper() - - # 判断 SQL 类型 - if sql_upper.startswith("SELECT"): - result.sql_type = "DQL" - elif sql_upper.startswith(("INSERT", "UPDATE", "DELETE")): - result.sql_type = "DML" - elif sql_upper.startswith(("CREATE", "ALTER", "DROP", "RENAME", "TRUNCATE")): - result.sql_type = "DDL" - else: - result.sql_type = "OTHER" - - db_type = instance.db_type - - try: - if db_type in ("mysql",): - result = await _explain_mysql(sql, instance, target_database, result) - elif db_type in ("postgres", "postgresql"): - result = await _explain_postgres(sql, instance, target_database, result) - else: - # Redis/MongoDB 不支持 EXPLAIN - result.warnings.append(f"{db_type} 不支持 EXPLAIN 预检") - return result - except Exception as e: - logger.warning("EXPLAIN 预检失败: %s", e) - result.error = str(e)[:200] - - return result - - -async def _explain_mysql( - sql: str, instance: Instance, database: str, result: ExplainResult -) -> ExplainResult: - """MySQL EXPLAIN 预检。""" - import aiomysql - - password = decrypt_password(instance.password) if instance.password else "" - - conn = await aiomysql.connect( - host=instance.host, - port=instance.port, - user=instance.username, - password=password, - db=database, - connect_timeout=5, - ) - - try: - sql_upper = sql.strip().upper() - - # DML/DDL 类型:查询涉及的表大小 - if result.sql_type in ("DML", "DDL"): - table_names = _extract_table_names(sql) - if table_names: - result.table_sizes = await _query_table_sizes_mysql(conn, database, table_names) - for table_name, row_count in result.table_sizes.items(): - if row_count > LARGE_TABLE_THRESHOLD: - result.warnings.append(f"大表操作: {table_name} 约 {row_count:,} 行") - - # DQL/DML: 跑 EXPLAIN - if result.sql_type in ("DQL", "DML") and not sql_upper.startswith("INSERT"): - explain_sql = f"EXPLAIN {sql}" - async with conn.cursor() as cur: - await cur.execute(explain_sql) - rows = await cur.fetchall() - columns = [desc[0] for desc in cur.description] - - total_rows = 0 - for row in rows: - row_dict = dict(zip(columns, row)) - # MySQL EXPLAIN rows 列是预估扫描行数 - scan_rows = row_dict.get("rows", 0) or 0 - total_rows += scan_rows - - # 检查是否全表扫描 - access_type = row_dict.get("type", "") - if access_type == "ALL": - result.full_table_scan = True - result.using_index = False - - # 检查 Extra 信息 - extra = str(row_dict.get("Extra", "")) - if "Using filesort" in extra: - result.warnings.append("Using filesort — 需要额外排序操作") - if "Using temporary" in extra: - result.warnings.append("Using temporary — 使用临时表") - - result.estimated_rows = total_rows - - if total_rows > AFFECTED_ROWS_CRITICAL: - result.warnings.append(f"预估影响 {total_rows:,} 行,属于大批量操作") - elif total_rows > AFFECTED_ROWS_WARNING: - result.warnings.append(f"预估影响 {total_rows:,} 行") - - # DDL ALTER: 检查是否在线 DDL - if sql_upper.startswith("ALTER TABLE"): - for table_name, row_count in result.table_sizes.items(): - if row_count > LARGE_TABLE_THRESHOLD: - # MySQL 5.6+ 支持 Online DDL,但仍建议在低峰执行 - result.warnings.append( - f"ALTER TABLE 在大表 {table_name}({row_count:,} 行)上执行,建议在维护窗口操作" - ) - - finally: - conn.close() - - return result - - -async def _explain_postgres( - sql: str, instance: Instance, database: str, result: ExplainResult -) -> ExplainResult: - """PostgreSQL EXPLAIN 预检。""" - import asyncpg - - password = decrypt_password(instance.password) if instance.password else "" - dsn = f"postgresql://{instance.username}:{password}@{instance.host}:{instance.port}/{database}" - - conn = await asyncpg.connect(dsn, timeout=5) - - try: - sql_upper = sql.strip().upper() - - # DML/DDL: 查表大小 - if result.sql_type in ("DML", "DDL"): - table_names = _extract_table_names(sql) - if table_names: - result.table_sizes = await _query_table_sizes_postgres(conn, table_names) - for table_name, row_count in result.table_sizes.items(): - if row_count > LARGE_TABLE_THRESHOLD: - result.warnings.append(f"大表操作: {table_name} 约 {row_count:,} 行") - - # DQL/DML: EXPLAIN (不带 ANALYZE,不实际执行) - if result.sql_type in ("DQL", "DML") and not sql_upper.startswith("INSERT"): - explain_sql = f"EXPLAIN (FORMAT JSON) {sql}" - rows = await conn.fetch(explain_sql) - if rows: - plan = json.loads(rows[0][0]) - _parse_pg_plan(plan, result) - - finally: - await conn.close() - - return result - - -def _parse_pg_plan(plan_data: Any, result: ExplainResult) -> None: - """递归解析 PostgreSQL EXPLAIN JSON。""" - if isinstance(plan_data, list) and plan_data: - plan_data = plan_data[0] - if isinstance(plan_data, dict): - plan = plan_data.get("Plan", plan_data) - result.estimated_rows = plan.get("Plan Rows", 0) or 0 - - node_type = plan.get("Node Type", "") - if "Seq Scan" in node_type: - result.full_table_scan = True - result.using_index = False - - if result.estimated_rows > AFFECTED_ROWS_CRITICAL: - result.warnings.append(f"预估影响 {result.estimated_rows:,} 行,属于大批量操作") - elif result.estimated_rows > AFFECTED_ROWS_WARNING: - result.warnings.append(f"预估影响 {result.estimated_rows:,} 行") - - # 递归子计划 - if "Plans" in plan: - for sub_plan in plan["Plans"]: - _parse_pg_plan(sub_plan, result) - - -def _extract_table_names(sql: str) -> List[str]: - """从 SQL 中提取表名(简单正则)。""" - tables = [] - sql_upper = sql.upper().strip() - - # UPDATE table SET ... - m = re.search(r'\bUPDATE\s+(\w+)', sql_upper) - if m: - tables.append(m.group(1).lower()) - - # DELETE FROM table ... - m = re.search(r'\bDELETE\s+FROM\s+(\w+)', sql_upper) - if m: - tables.append(m.group(1).lower()) - - # ALTER TABLE table ... - m = re.search(r'\bALTER\s+TABLE\s+(\w+)', sql_upper) - if m: - tables.append(m.group(1).lower()) - - # DROP TABLE table ... - m = re.search(r'\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?(\w+)', sql_upper) - if m: - tables.append(m.group(1).lower()) - - # CREATE TABLE table ... - m = re.search(r'\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)', sql_upper) - if m: - tables.append(m.group(1).lower()) - - # INSERT INTO table ... - m = re.search(r'\bINSERT\s+INTO\s+(\w+)', sql_upper) - if m: - tables.append(m.group(1).lower()) - - # SELECT ... FROM table (simple) - for m in re.finditer(r'\bFROM\s+(\w+)', sql_upper): - t = m.group(1).lower() - if t not in ('select', 'where', 'dual', 'information_schema'): - tables.append(t) - - # JOIN table - for m in re.finditer(r'\bJOIN\s+(\w+)', sql_upper): - tables.append(m.group(1).lower()) - - return list(dict.fromkeys(tables)) # 去重保序 - - -async def _query_table_sizes_mysql(conn, database: str, table_names: List[str]) -> Dict[str, int]: - """查询 MySQL 表行数。""" - sizes = {} - if not table_names: - return sizes - - placeholders = ", ".join(["%s"] * len(table_names)) - query = f""" - SELECT TABLE_NAME, TABLE_ROWS - FROM information_schema.TABLES - WHERE TABLE_SCHEMA = %s AND TABLE_NAME IN ({placeholders}) - """ - try: - import aiomysql - async with conn.cursor(aiomysql.DictCursor) as cur: - await cur.execute(query, [database] + [t for t in table_names]) - rows = await cur.fetchall() - for row in rows: - sizes[row["TABLE_NAME"].lower()] = row.get("TABLE_ROWS", 0) or 0 - except Exception as e: - logger.warning("查询表大小失败: %s", e) - - return sizes - - -async def _query_table_sizes_postgres(conn, table_names: List[str]) -> Dict[str, int]: - """查询 PostgreSQL 表行数。""" - sizes = {} - if not table_names: - return sizes - - placeholders = ", ".join([f"'{t}'" for t in table_names]) - query = f""" - SELECT relname, n_live_tup - FROM pg_stat_user_tables - WHERE relname IN ({placeholders}) - """ - try: - rows = await conn.fetch(query) - for row in rows: - sizes[row["relname"].lower()] = row["n_live_tup"] or 0 - except Exception as e: - logger.warning("查询PG表大小失败: %s", e) - - return sizes diff --git a/backend/app/services/explain_service.py b/backend/app/services/explain_service.py deleted file mode 100644 index 375f28b..0000000 --- a/backend/app/services/explain_service.py +++ /dev/null @@ -1,789 +0,0 @@ -"""EXPLAIN 执行服务 — 连接数据源执行 EXPLAIN 并解析为统一树结构。 - -支持: -- MySQL: EXPLAIN FORMAT=JSON -- PostgreSQL: EXPLAIN (FORMAT JSON, ANALYZE false) -- SQLite: EXPLAIN QUERY PLAN - -返回统一树结构: -{ - "execution_plan": [...], # 树形执行计划节点 - "warnings": [...], # 优化警告 - "summary": {...}, # 汇总统计 - "db_type": "mysql", - "raw_result": {...}, # 原始结果 -} -""" -import asyncio -import json -import logging -from typing import Any, Dict, List, Optional, Tuple - -# 顶层 import 以便测试 mock(实际运行时为可选依赖) -try: - import aiomysql # noqa: F401 -except ImportError: - aiomysql = None # type: ignore[assignment] - -try: - import asyncpg # noqa: F401 -except ImportError: - asyncpg = None # type: ignore[assignment] - -from app.models.instance import Instance - -logger = logging.getLogger(__name__) - - -# ===== 统一节点结构 ===== - -def make_plan_node( - id: int, - operation: str, - table: Optional[str] = None, - rows: Optional[int] = None, - cost: Optional[float] = None, - key: Optional[str] = None, - extra: Optional[str] = None, - children: Optional[List[Dict]] = None, - **kwargs, -) -> Dict[str, Any]: - """构建统一执行计划节点。""" - node = { - "id": id, - "operation": operation, - "table": table, - "rows": rows, - "cost": cost, - "key": key, - "extra": extra, - "children": children or [], - } - # 附加额外字段 - for k, v in kwargs.items(): - if v is not None: - node[k] = v - return node - - -# ===== MySQL EXPLAIN FORMAT=JSON 解析 ===== - -def parse_mysql_explain_json(raw_json: Any) -> Dict[str, Any]: - """解析 MySQL EXPLAIN FORMAT=JSON 的结果。 - - MySQL EXPLAIN FORMAT=JSON 结构示例: - { - "query_block": { - "select_id": 1, - "cost_info": {"query_cost": "0.35"}, - "table": { - "table_name": "users", - "access_type": "ALL", - "rows_examined_per_scan": 1000, - "rows_produced_per_join": 1000, - "filtered": "100.00", - "cost_info": {...}, - "used_columns": [...] - }, - "nested_loop": [...] - } - } - - Returns: - 统一结构的 Dict - """ - if isinstance(raw_json, str): - try: - raw_json = json.loads(raw_json) - except (json.JSONDecodeError, TypeError): - return _empty_result("mysql", raw_json, error="EXPLAIN JSON 解析失败") - - nodes: List[Dict] = [] - warnings: List[str] = [] - node_id_counter = [0] - - def next_id() -> int: - node_id_counter[0] += 1 - return node_id_counter[0] - - def parse_query_block(qb: Dict) -> Dict[str, Any]: - """递归解析 query_block。""" - node_id = next_id() - children = [] - operation = "Query Block" - table_name = None - access_type = None - key_used = None - rows_est = None - cost_val = None - extra_parts = [] - - # 表信息 - table_info = qb.get("table") - if table_info: - table_name = table_info.get("table_name") - access_type = table_info.get("access_type", "") - key_used = table_info.get("key") - possible_keys = table_info.get("possible_keys") - rows_est = table_info.get("rows_examined_per_scan") - filtered = table_info.get("filtered", "100.00") - - # 成本 - cost_info = table_info.get("cost_info", {}) - cost_str = cost_info.get("read_cost") or cost_info.get("eval_cost") or cost_info.get("prefix_cost") - if cost_str: - try: - cost_val = float(cost_str) - except (ValueError, TypeError): - pass - - # 操作类型映射 - operation = _mysql_access_type_to_operation(access_type) - - # Extra 信息 - if key_used: - extra_parts.append(f"使用索引: {key_used}") - if possible_keys and not key_used: - extra_parts.append(f"可选索引: {possible_keys}") - if filtered and filtered != "100.00": - extra_parts.append(f"过滤率: {filtered}%") - - used_key_parts = table_info.get("used_key_parts") - if used_key_parts: - extra_parts.append(f"索引列: {', '.join(str(p) for p in used_key_parts)}") - - # 警告检测 - if access_type == "ALL": - warnings.append(f"表 '{table_name}' 全表扫描,建议添加索引") - if access_type == "index": - warnings.append(f"表 '{table_name}' 全索引扫描") - - # 嵌套循环 / join - nested_loop = qb.get("nested_loop") - if nested_loop: - for nl_item in nested_loop: - child = parse_query_block(nl_item) - children.append(child) - - # 排序 - order_by = qb.get("ordering_operation") - if order_by: - sort_node = _parse_mysql_ordering(order_by, next_id()) - children.append(sort_node) - extra_parts.append("Using filesort") - warnings.append("使用了文件排序 (filesort)") - - # 分组 - grouping = qb.get("grouping_operation") - if grouping: - group_node = _parse_mysql_grouping(grouping, next_id()) - children.append(group_node) - extra_parts.append("Using temporary") - warnings.append("使用了临时表 (temporary)") - - # 子查询 - subqueries = qb.get("subqueries") or qb.get("optimized_away_subqueries") - if subqueries: - for sq in subqueries: - if "query_block" in sq: - child = parse_query_block(sq["query_block"]) - children.append(child) - - # 成本信息 - qb_cost = qb.get("cost_info", {}) - if not cost_val and "query_cost" in qb_cost: - try: - cost_val = float(qb_cost["query_cost"]) - except (ValueError, TypeError): - pass - - return make_plan_node( - id=node_id, - operation=operation, - table=table_name, - rows=int(rows_est) if rows_est else None, - cost=cost_val, - key=key_used, - extra="; ".join(extra_parts) if extra_parts else None, - children=children, - access_type=access_type, - ) - - # 解析顶层 query_block - query_block = raw_json.get("query_block") if isinstance(raw_json, dict) else None - if query_block: - root = parse_query_block(query_block) - nodes = [root] - else: - warnings.append("无法识别的 EXPLAIN JSON 结构") - - # 汇总 - summary = _build_summary(nodes, warnings, "mysql") - - return { - "execution_plan": nodes, - "warnings": warnings, - "summary": summary, - "db_type": "mysql", - "raw_result": raw_json, - } - - -def _parse_mysql_ordering(ordering: Dict, node_id: int) -> Dict: - """解析 MySQL 排序操作。""" - table_info = ordering.get("table", {}) - return make_plan_node( - id=node_id, - operation="Filesort", - table=table_info.get("table_name"), - extra="排序操作", - ) - - -def _parse_mysql_grouping(grouping: Dict, node_id: int) -> Dict: - """解析 MySQL 分组操作。""" - table_info = grouping.get("table", {}) - return make_plan_node( - id=node_id, - operation="Temporary Table", - table=table_info.get("table_name"), - extra="分组操作使用临时表", - ) - - -def _mysql_access_type_to_operation(access_type: Optional[str]) -> str: - """将 MySQL access_type 映射为统一操作名。""" - mapping = { - "system": "System Scan", - "const": "Const Row", - "eq_ref": "Unique Lookup", - "ref": "Index Lookup", - "fulltext": "Fulltext Search", - "ref_or_null": "Index Lookup (nullable)", - "index_merge": "Index Merge", - "unique_subquery": "Unique Subquery", - "index_subquery": "Index Subquery", - "range": "Index Range Scan", - "index": "Full Index Scan", - "ALL": "Table Scan", - } - return mapping.get(access_type or "", f"Unknown ({access_type})") - - -# ===== PostgreSQL EXPLAIN (FORMAT JSON) 解析 ===== - -def parse_postgres_explain_json(raw_json: Any) -> Dict[str, Any]: - """解析 PostgreSQL EXPLAIN (FORMAT JSON) 的结果。 - - PostgreSQL EXPLAIN JSON 结构示例: - [ - { - "Plan": { - "Node Type": "Seq Scan", - "Relation Name": "users", - "Alias": "users", - "Startup Cost": 0.00, - "Total Cost": 15.50, - "Plan Rows": 1000, - "Plan Width": 68, - "Plans": [ - { - "Node Type": "Index Scan", - ... - "Plans": [] - } - ] - } - } - ] - - Returns: - 统一结构的 Dict - """ - if isinstance(raw_json, str): - try: - raw_json = json.loads(raw_json) - except (json.JSONDecodeError, TypeError): - return _empty_result("postgres", raw_json, error="EXPLAIN JSON 解析失败") - - # PG 返回的是数组 - if isinstance(raw_json, list) and len(raw_json) > 0: - plan_root = raw_json[0].get("Plan", raw_json[0]) - elif isinstance(raw_json, dict): - plan_root = raw_json.get("Plan", raw_json) - else: - return _empty_result("postgres", raw_json, error="无法识别的 EXPLAIN JSON 结构") - - warnings: List[str] = [] - node_id_counter = [0] - - def next_id() -> int: - node_id_counter[0] += 1 - return node_id_counter[0] - - def parse_pg_plan(plan: Dict) -> Dict[str, Any]: - """递归解析 PostgreSQL Plan 节点。""" - node_id = next_id() - children = [] - - node_type = plan.get("Node Type", "Unknown") - relation_name = plan.get("Relation Name") or plan.get("Table Name") - alias = plan.get("Alias") - startup_cost = plan.get("Startup Cost") - total_cost = plan.get("Total Cost") - plan_rows = plan.get("Plan Rows") - plan_width = plan.get("Plan Width") - index_name = plan.get("Index Name") - index_cond = plan.get("Index Cond") - filter_str = plan.get("Filter") - hash_cond = plan.get("Hash Cond") - join_type = plan.get("Join Type") - sort_key = plan.get("Sort Key") - sort_method = plan.get("Sort Method") - group_key = plan.get("Group Key") - actual_rows = plan.get("Actual Rows") - actual_time = plan.get("Actual Total Time") - - # 操作类型映射 - operation = _pg_node_type_to_operation(node_type) - if join_type: - operation = f"{join_type} {_pg_node_type_to_operation(node_type)}" - - # Extra 信息 - extra_parts = [] - if index_cond: - extra_parts.append(f"索引条件: {index_cond}") - if filter_str: - extra_parts.append(f"过滤条件: {filter_str}") - if hash_cond: - extra_parts.append(f"Hash 条件: {hash_cond}") - if sort_key: - extra_parts.append(f"排序键: {sort_key}") - if sort_method: - extra_parts.append(f"排序方法: {sort_method}") - if group_key: - extra_parts.append(f"分组键: {group_key}") - if actual_rows is not None: - extra_parts.append(f"实际行数: {actual_rows}") - if actual_time is not None: - extra_parts.append(f"实际耗时: {actual_time}ms") - - # 警告检测 - if node_type == "Seq Scan": - warnings.append(f"表 '{relation_name or '?'}' 使用了顺序扫描 (Seq Scan),建议检查索引") - if node_type == "Sort" and sort_method == "external" or plan.get("Sort Space Used"): - warnings.append(f"排序使用了外部排序 (磁盘),建议增加 work_mem") - - # 子计划 - sub_plans = plan.get("Plans", []) - for sp in sub_plans: - children.append(parse_pg_plan(sp)) - - # 子查询/SubPlan - subplan_name = plan.get("Subplan Name") - if subplan_name: - extra_parts.append(f"子计划: {subplan_name}") - - kwargs = {} - if alias and alias != relation_name: - kwargs["alias"] = alias - if plan_width: - kwargs["plan_width"] = plan_width - if startup_cost is not None: - kwargs["startup_cost"] = startup_cost - if actual_time is not None: - kwargs["actual_time_ms"] = actual_time - if actual_rows is not None: - kwargs["actual_rows"] = actual_rows - - return make_plan_node( - id=node_id, - operation=operation, - table=relation_name, - rows=int(plan_rows) if plan_rows else None, - cost=float(total_cost) if total_cost is not None else None, - key=index_name, - extra="; ".join(extra_parts) if extra_parts else None, - children=children, - node_type=node_type, - **kwargs, - ) - - root = parse_pg_plan(plan_root) - nodes = [root] - - # 汇总 - summary = _build_summary(nodes, warnings, "postgres") - - return { - "execution_plan": nodes, - "warnings": warnings, - "summary": summary, - "db_type": "postgres", - "raw_result": raw_json, - } - - -def _pg_node_type_to_operation(node_type: str) -> str: - """将 PostgreSQL Node Type 映射为统一操作名。""" - mapping = { - "Seq Scan": "Table Scan", - "Index Scan": "Index Scan", - "Index Only Scan": "Index Only Scan", - "Bitmap Heap Scan": "Bitmap Heap Scan", - "Bitmap Index Scan": "Bitmap Index Scan", - "Tid Scan": "TID Scan", - "Subquery Scan": "Subquery Scan", - "Function Scan": "Function Scan", - "Table Function Scan": "Table Function Scan", - "Values Scan": "Values Scan", - "CTE Scan": "CTE Scan", - "Named Tuplestore Scan": "Named Tuplestore Scan", - "WorkTable Scan": "WorkTable Scan", - "Nested Loop": "Nested Loop Join", - "Merge Join": "Merge Join", - "Hash Join": "Hash Join", - "Sort": "Sort", - "Hash": "Hash", - "Aggregate": "Aggregate", - "Group": "Group", - "Append": "Append", - "Merge Append": "Merge Append", - "Limit": "Limit", - "Unique": "Unique", - "Gather": "Gather", - "Gather Merge": "Gather Merge", - "Materialize": "Materialize", - "Result": "Result", - "ProjectSet": "ProjectSet", - "WindowAgg": "Window Aggregate", - "Recursive Union": "Recursive Union", - "LockRows": "Lock Rows", - "ModifyTable": "Modify Table", - "SetOp": "Set Operation", - } - return mapping.get(node_type, node_type) - - -# ===== SQLite EXPLAIN QUERY PLAN 解析 ===== - -def parse_sqlite_explain(rows: List[Dict[str, Any]]) -> Dict[str, Any]: - """解析 SQLite EXPLAIN QUERY PLAN 的文本/行结果。 - - SQLite 返回格式: - id|parent|notused|detail - 3|0|0|SCAN TABLE users - 或 - id|parent|notused|detail - 2|0|0|SEARCH TABLE users USING INDEX idx_name (name=?) - - Returns: - 统一结构的 Dict - """ - warnings: List[str] = [] - node_id_counter = [0] - node_map: Dict[int, Dict] = {} - - def next_id() -> int: - node_id_counter[0] += 1 - return node_id_counter[0] - - for row in rows: - # SQLite EXPLAIN QUERY PLAN 返回: id, parent, notused, detail - detail = row.get("detail") or row.get("DETAIL") or "" - parent_id = row.get("parent") or row.get("PARENT") or 0 - sqlite_id = row.get("id") or row.get("ID") or next_id() - - table_name = None - operation = "Unknown" - key = None - extra = None - - detail_upper = detail.upper() - - # 解析 detail 文本 - if "SCAN TABLE" in detail_upper: - operation = "Table Scan" - # 提取表名: "SCAN TABLE users" - parts = detail.split("TABLE", 1) - if len(parts) > 1: - table_part = parts[1].strip().split()[0] if parts[1].strip() else None - table_name = table_part - warnings.append(f"表 '{table_name or '?'}' 全表扫描,建议添加索引") - elif "SEARCH TABLE" in detail_upper: - operation = "Index Lookup" - parts = detail.split("TABLE", 1) - if len(parts) > 1: - rest = parts[1].strip() - tokens = rest.split() - table_name = tokens[0] if tokens else None - # 提取索引名: "USING INDEX idx_name (col=?)" - if "USING INDEX" in detail_upper: - idx_parts = detail.split("USING INDEX", 1) - if len(idx_parts) > 1: - idx_rest = idx_parts[1].strip().split("(")[0].strip() - key = idx_rest - elif "USING COVERING INDEX" in detail_upper: - idx_parts = detail.split("USING COVERING INDEX", 1) - if len(idx_parts) > 1: - idx_rest = idx_parts[1].strip().split("(")[0].strip() - key = idx_rest - extra = "Covering Index" - elif "EXECUTE" in detail_upper: - operation = "Execute" - extra = detail - elif "CORRELATED SCALAR SUBQUERY" in detail_upper: - operation = "Correlated Subquery" - extra = detail - elif "CO-ROUTINE" in detail_upper: - operation = "Co-routine" - extra = detail - elif "SCAN" in detail_upper and "TABLE" not in detail_upper: - operation = "Scan" - extra = detail - else: - operation = detail if detail else "Unknown" - - node = make_plan_node( - id=next_id(), - operation=operation, - table=table_name, - key=key, - extra=extra, - ) - node_map[sqlite_id] = node - - # 构建树(基于 parent) - root_nodes = [] - for sqlite_id, node in node_map.items(): - parent = None - for row in rows: - rid = row.get("id") or row.get("ID") - if rid == sqlite_id: - parent = row.get("parent") or row.get("PARENT") or 0 - break - if parent == 0 or parent not in node_map: - root_nodes.append(node) - else: - parent_node = node_map[parent] - parent_node["children"].append(node) - - if not root_nodes and node_map: - root_nodes = list(node_map.values()) - - summary = _build_summary(root_nodes, warnings, "sqlite") - - return { - "execution_plan": root_nodes, - "warnings": warnings, - "summary": summary, - "db_type": "sqlite", - "raw_result": rows, - } - - -# ===== 辅助函数 ===== - -def _empty_result(db_type: str, raw: Any, error: str = "") -> Dict[str, Any]: - """返回空结果(解析失败时)。""" - return { - "execution_plan": [], - "warnings": [error] if error else ["EXPLAIN 结果为空"], - "summary": { - "total_cost": 0, - "estimated_rows": 0, - "has_full_scan": False, - "has_temporary": False, - "has_filesort": False, - "index_usage_ratio": 0.0, - }, - "db_type": db_type, - "raw_result": raw, - } - - -def _build_summary( - nodes: List[Dict], - warnings: List[str], - db_type: str, -) -> Dict[str, Any]: - """从执行计划树构建汇总信息。""" - total_cost = 0.0 - total_rows = 0 - has_full_scan = False - has_temporary = False - has_filesort = False - index_used_count = 0 - total_table_count = 0 - - def walk(node: Dict): - nonlocal total_cost, total_rows, has_full_scan, has_temporary, has_filesort - nonlocal index_used_count, total_table_count - - if node.get("cost") is not None: - total_cost = max(total_cost, float(node["cost"])) - - if node.get("rows") is not None: - total_rows += int(node["rows"]) - - op = (node.get("operation") or "").lower() - extra = str(node.get("extra") or "").lower() - - if "table scan" in op or "seq scan" in op or op == "system scan": - has_full_scan = True - total_table_count += 1 - elif "index" in op or "lookup" in op: - index_used_count += 1 - total_table_count += 1 - elif "scan" in op: - total_table_count += 1 - - if "temporary" in op or "temporary" in extra: - has_temporary = True - if "filesort" in op or "sort" in op or "filesort" in extra: - has_filesort = True - - for child in node.get("children", []): - walk(child) - - for n in nodes: - walk(n) - - index_usage_ratio = 0.0 - if total_table_count > 0: - index_usage_ratio = round(index_used_count / total_table_count, 2) - - return { - "total_cost": round(total_cost, 4), - "estimated_rows": total_rows, - "has_full_scan": has_full_scan, - "has_temporary": has_temporary, - "has_filesort": has_filesort, - "index_usage_ratio": index_usage_ratio, - } - - -# ===== EXPLAIN 执行入口 ===== - -class ExplainService: - """EXPLAIN 执行服务 — 统一入口。""" - - @staticmethod - async def execute_explain( - instance: Instance, - database: Optional[str], - query: str, - ) -> Dict[str, Any]: - """根据 db_type 构造并执行 EXPLAIN,返回统一树结构。 - - Args: - instance: 数据源实例 - database: 目标数据库名 - query: 要解释的 SQL 查询 - - Returns: - 统一 EXPLAIN 结果 Dict - """ - db_type = instance.db_type - - # 验证查询以 SELECT 或 WITH 开头 - stripped = query.strip() - upper = stripped.upper() - if not upper.startswith(("SELECT", "WITH")): - raise ValueError("EXPLAIN 仅支持 SELECT 或 WITH 查询") - - if db_type == "mysql": - return await ExplainService._explain_mysql(instance, database, query) - elif db_type in ("postgres", "postgresql"): - return await ExplainService._explain_postgres(instance, database, query) - elif db_type == "sqlite": - return await ExplainService._explain_sqlite(instance, query) - else: - raise ValueError(f"暂不支持 {db_type} 类型的 EXPLAIN") - - @staticmethod - async def _explain_mysql( - instance: Instance, database: Optional[str], query: str - ) -> Dict[str, Any]: - """执行 MySQL EXPLAIN FORMAT=JSON。""" - explain_sql = f"EXPLAIN FORMAT=JSON {query}" - - 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 asyncio.wait_for(cur.execute(explain_sql), timeout=30) - rows = await cur.fetchall() - # MySQL EXPLAIN FORMAT=JSON 返回一行一列 JSON - if rows: - raw = rows[0].get("EXPLAIN") or rows[0].get("JSON") or rows[0] - # raw 可能是字符串或已经解析的 dict - if isinstance(raw, str): - raw = json.loads(raw) - else: - raw = {} - except asyncio.TimeoutError: - raise ValueError("EXPLAIN 执行超时(30秒)") - finally: - conn.close() - - return parse_mysql_explain_json(raw) - - @staticmethod - async def _explain_postgres( - instance: Instance, database: Optional[str], query: str - ) -> Dict[str, Any]: - """执行 PostgreSQL EXPLAIN (FORMAT JSON)。""" - explain_sql = f"EXPLAIN (FORMAT JSON, ANALYZE false) {query}" - - dsn = ( - f"postgresql://{instance.username}:{instance.password}" - f"@{instance.host}:{instance.port}/{database or instance.database or 'postgres'}" - ) - conn = await asyncpg.connect(dsn) - try: - result = await asyncio.wait_for(conn.fetchval(explain_sql), timeout=30) - # asyncpg fetchval 返回 JSON 字符串 - if isinstance(result, str): - raw = json.loads(result) - elif isinstance(result, list): - raw = result - else: - raw = result - except asyncio.TimeoutError: - raise ValueError("EXPLAIN 执行超时(30秒)") - finally: - await conn.close() - - return parse_postgres_explain_json(raw) - - @staticmethod - async def _explain_sqlite(instance: Instance, query: str) -> Dict[str, Any]: - """执行 SQLite EXPLAIN QUERY PLAN。""" - import sqlite3 - - explain_sql = f"EXPLAIN QUERY PLAN {query}" - db_path = instance.host - - def _exec(): - conn = sqlite3.connect(db_path) - conn.row_factory = sqlite3.Row - cur = conn.execute(explain_sql) - rows = [dict(r) for r in cur.fetchall()] - conn.close() - return rows - - try: - rows = await asyncio.wait_for( - asyncio.get_event_loop().run_in_executor(None, _exec), - timeout=30, - ) - except asyncio.TimeoutError: - raise ValueError("EXPLAIN 执行超时(30秒)") - - return parse_sqlite_explain(rows) diff --git a/backend/app/services/impact_analyzer.py b/backend/app/services/impact_analyzer.py deleted file mode 100644 index 875709c..0000000 --- a/backend/app/services/impact_analyzer.py +++ /dev/null @@ -1,315 +0,0 @@ -"""SQL 执行影响分析 — 预估影响行数、执行时间、风险等级。 - -使用数据库 EXPLAIN 输出分析执行计划,结合表大小统计预估影响。 -支持 MySQL 和 PostgreSQL。 - -风险等级定义: -- low: 影响 < 1000 行,执行 < 1秒 -- medium: 影响 1000-10000 行,执行 1-10秒 -- high: 影响 10000-100000 行,执行 10-60秒 -- critical: 影响 > 100000 行 或 ALTER > 1GB 表 -""" -import re -from dataclasses import dataclass, field -from typing import List, Optional, Dict, Any -from enum import Enum - -from app.models.instance import Instance - - -class RiskLevel(str, Enum): - LOW = "low" - MEDIUM = "medium" - HIGH = "high" - CRITICAL = "critical" - - -@dataclass -class ImpactAnalysis: - """影响分析结果。""" - sql_type: str = "UNKNOWN" - estimated_rows: int = 0 - estimated_duration_ms: int = 0 - table_size_mb: float = 0.0 - table_row_count: int = 0 - index_usage: List[str] = field(default_factory=list) - has_full_scan: bool = False - risk_level: str = RiskLevel.LOW - warnings: List[str] = field(default_factory=list) - explain_result: Optional[Dict[str, Any]] = None - - def to_dict(self) -> dict: - return { - "sql_type": self.sql_type, - "estimated_rows": self.estimated_rows, - "estimated_duration_ms": self.estimated_duration_ms, - "table_size_mb": round(self.table_size_mb, 2), - "table_row_count": self.table_row_count, - "index_usage": self.index_usage, - "has_full_scan": self.has_full_scan, - "risk_level": self.risk_level, - "warnings": self.warnings, - } - - -class ImpactAnalyzer: - """SQL 执行影响分析器。""" - - RISK_THRESHOLDS = { - RiskLevel.LOW: {"rows": 1000, "duration_ms": 1000, "size_mb": 10}, - RiskLevel.MEDIUM: {"rows": 10000, "duration_ms": 10000, "size_mb": 100}, - RiskLevel.HIGH: {"rows": 100000, "duration_ms": 60000, "size_mb": 1024}, - } - - def detect_sql_type(self, sql: str) -> str: - """检测 SQL 语句类型。""" - upper = sql.strip().upper() - if upper.startswith("SELECT"): - return "SELECT" - elif upper.startswith("INSERT"): - return "INSERT" - elif upper.startswith("UPDATE"): - return "UPDATE" - elif upper.startswith("DELETE"): - return "DELETE" - elif upper.startswith("ALTER"): - return "ALTER" - elif upper.startswith("CREATE"): - return "CREATE" - elif upper.startswith("DROP"): - return "DROP" - elif upper.startswith("TRUNCATE"): - return "TRUNCATE" - return "UNKNOWN" - - async def analyze(self, instance: Instance, database: str, sql: str) -> ImpactAnalysis: - """分析 SQL 执行影响。""" - sql_type = self.detect_sql_type(sql) - analysis = ImpactAnalysis(sql_type=sql_type) - - if sql_type in ("UPDATE", "DELETE"): - return await self._analyze_dml(instance, database, sql, analysis) - elif sql_type == "ALTER": - return await self._analyze_alter(instance, database, sql, analysis) - elif sql_type == "INSERT": - return await self._analyze_insert(instance, database, sql, analysis) - elif sql_type in ("CREATE", "DROP", "TRUNCATE"): - return self._analyze_ddl(sql, analysis) - else: - return analysis - - async def _analyze_dml(self, instance: Instance, database: str, - sql: str, analysis: ImpactAnalysis) -> ImpactAnalysis: - """分析 UPDATE/DELETE 影响。""" - table = self._extract_table(sql) - analysis.table_row_count = await self._get_table_row_count(instance, database, table) - analysis.table_size_mb = await self._get_table_size(instance, database, table) - - # 通过 WHERE 条件粗略估计影响行数 - where_clause = self._extract_where(sql) - if where_clause: - # 如果有 WHERE 且包含主键条件,影响较小 - if self._has_pk_condition(where_clause): - analysis.estimated_rows = 1 - # 如果有 WHERE 且包含索引列条件 - elif self._has_index_condition(where_clause): - analysis.estimated_rows = max(1, analysis.table_row_count // 100) - else: - # 非索引条件,影响较大 - analysis.estimated_rows = max(1, analysis.table_row_count // 10) - analysis.has_full_scan = True - else: - # 无 WHERE,全表影响 - analysis.estimated_rows = analysis.table_row_count - analysis.has_full_scan = True - analysis.warnings.append(f"{analysis.sql_type} 缺少 WHERE 条件,将影响全表 {analysis.table_row_count} 行") - - # 预估执行时间 - analysis.estimated_duration_ms = self._estimate_duration( - analysis.estimated_rows, analysis.table_size_mb, analysis.sql_type - ) - - # 检查索引使用 - if analysis.has_full_scan: - analysis.warnings.append("可能触发全表扫描,建议添加索引条件") - - analysis.risk_level = self._calculate_risk_level( - analysis.estimated_rows, analysis.estimated_duration_ms, analysis.table_size_mb - ) - - if analysis.risk_level in (RiskLevel.HIGH, RiskLevel.CRITICAL): - analysis.warnings.append( - f"风险等级: {analysis.risk_level.value},建议分批次执行或在维护窗口操作" - ) - - return analysis - - async def _analyze_alter(self, instance: Instance, database: str, - sql: str, analysis: ImpactAnalysis) -> ImpactAnalysis: - """分析 ALTER TABLE 影响。""" - table = self._extract_table(sql) - analysis.table_size_mb = await self._get_table_size(instance, database, table) - analysis.table_row_count = await self._get_table_row_count(instance, database, table) - - # ALTER TABLE 预估时间: 大约每 10MB 需要 1 秒 - analysis.estimated_duration_ms = int(analysis.table_size_mb * 100) - analysis.estimated_rows = analysis.table_row_count - - if analysis.table_size_mb > 1024: - analysis.risk_level = RiskLevel.CRITICAL - analysis.warnings.append( - f"ALTER TABLE 操作表大小为 {analysis.table_size_mb:.1f}MB," - "建议使用 pt-online-schema-change 或 gh-ost 进行在线变更" - ) - elif analysis.table_size_mb > 100: - analysis.risk_level = RiskLevel.HIGH - analysis.warnings.append( - f"ALTER TABLE 操作表大小为 {analysis.table_size_mb:.1f}MB,预计耗时 {analysis.estimated_duration_ms / 1000:.1f} 秒" - ) - elif analysis.table_size_mb > 10: - analysis.risk_level = RiskLevel.MEDIUM - else: - analysis.risk_level = RiskLevel.LOW - - return analysis - - async def _analyze_insert(self, instance: Instance, database: str, - sql: str, analysis: ImpactAnalysis) -> ImpactAnalysis: - """分析 INSERT 影响。""" - # 估算插入行数 - values_count = sql.count(",") if "VALUES" in sql.upper() else 1 - # 更精确的估算:数 VALUES 子句中的行 - row_match = re.findall(r"\)\s*,?\s*\(", sql) - analysis.estimated_rows = len(row_match) + 1 if row_match else 1 - - table = self._extract_table(sql) - analysis.table_size_mb = await self._get_table_size(instance, database, table) - - # INSERT 通常很快 - analysis.estimated_duration_ms = analysis.estimated_rows * 2 - - if analysis.estimated_rows > 10000: - analysis.risk_level = RiskLevel.HIGH - analysis.warnings.append(f"单次插入 {analysis.estimated_rows} 行数据量较大,建议分批插入") - elif analysis.estimated_rows > 1000: - analysis.risk_level = RiskLevel.MEDIUM - else: - analysis.risk_level = RiskLevel.LOW - - return analysis - - def _analyze_ddl(self, sql: str, analysis: ImpactAnalysis) -> ImpactAnalysis: - """分析 DDL 影响。""" - analysis.risk_level = RiskLevel.MEDIUM - analysis.warnings.append("DDL 操作会锁表,建议在低峰期执行") - - if "DROP" in sql.upper(): - analysis.risk_level = RiskLevel.CRITICAL - analysis.warnings.append("DROP 操作不可逆,请确认已备份数据") - elif "TRUNCATE" in sql.upper(): - analysis.risk_level = RiskLevel.CRITICAL - analysis.warnings.append("TRUNCATE 会清空所有数据且不可回滚") - - return analysis - - def _estimate_duration(self, rows: int, table_size_mb: float, sql_type: str) -> int: - """预估执行时间(毫秒)。""" - base_time = 50 # 基础时间 50ms - - if sql_type == "UPDATE": - # UPDATE: 每 1000 行约 100ms,加上表大小因素 - row_time = (rows / 1000) * 100 - size_factor = table_size_mb * 0.5 - return int(base_time + row_time + size_factor) - elif sql_type == "DELETE": - # DELETE: 每 1000 行约 80ms - row_time = (rows / 1000) * 80 - return int(base_time + row_time) - elif sql_type == "INSERT": - # INSERT: 每行约 2ms - return int(base_time + rows * 2) - elif sql_type == "ALTER": - # ALTER: 每 10MB 约 1000ms - return int(base_time + table_size_mb * 100) - - return base_time - - def _calculate_risk_level(self, rows: int, duration_ms: int, - table_size_mb: float) -> str: - """计算风险等级。""" - if (rows > self.RISK_THRESHOLDS[RiskLevel.HIGH]["rows"] or - duration_ms > self.RISK_THRESHOLDS[RiskLevel.HIGH]["duration_ms"] or - table_size_mb > self.RISK_THRESHOLDS[RiskLevel.HIGH]["size_mb"]): - return RiskLevel.CRITICAL - - if (rows > self.RISK_THRESHOLDS[RiskLevel.MEDIUM]["rows"] or - duration_ms > self.RISK_THRESHOLDS[RiskLevel.MEDIUM]["duration_ms"] or - table_size_mb > self.RISK_THRESHOLDS[RiskLevel.MEDIUM]["size_mb"]): - return RiskLevel.HIGH - - if (rows > self.RISK_THRESHOLDS[RiskLevel.LOW]["rows"] or - duration_ms > self.RISK_THRESHOLDS[RiskLevel.LOW]["duration_ms"] or - table_size_mb > self.RISK_THRESHOLDS[RiskLevel.LOW]["size_mb"]): - return RiskLevel.MEDIUM - - return RiskLevel.LOW - - def _extract_table(self, sql: str) -> str: - """从 SQL 中提取表名(简化版)。""" - upper = sql.upper() - - # UPDATE table_name SET ... - update_match = re.search(r"UPDATE\s+(?:`?)(\w+)(?:`?)", upper) - if update_match: - return update_match.group(1).lower() - - # DELETE FROM table_name ... - delete_match = re.search(r"DELETE\s+FROM\s+(?:`?)(\w+)(?:`?)", upper) - if delete_match: - return delete_match.group(1).lower() - - # INSERT INTO table_name ... - insert_match = re.search(r"INSERT\s+INTO\s+(?:`?)(\w+)(?:`?)", upper) - if insert_match: - return insert_match.group(1).lower() - - # ALTER TABLE table_name ... - alter_match = re.search(r"ALTER\s+TABLE\s+(?:`?)(\w+)(?:`?)", upper) - if alter_match: - return alter_match.group(1).lower() - - # CREATE TABLE table_name ... - create_match = re.search(r"CREATE\s+TABLE\s+(?:`?)(\w+)(?:`?)", upper) - if create_match: - return create_match.group(1).lower() - - return "unknown" - - def _extract_where(self, sql: str) -> str: - """提取 WHERE 子句。""" - where_match = re.search(r"WHERE\s+(.+?)(?:\s+ORDER\s+BY|\s+LIMIT\s+|$)", - sql, re.IGNORECASE) - return where_match.group(1).strip() if where_match else "" - - def _has_pk_condition(self, where_clause: str) -> bool: - """检查 WHERE 是否包含主键条件。""" - pk_patterns = [r"\bid\s*[=<>]", r"\bpk_\w+\s*[=<>]"] - return any(re.search(p, where_clause, re.IGNORECASE) for p in pk_patterns) - - def _has_index_condition(self, where_clause: str) -> bool: - """检查 WHERE 是否可能使用索引。""" - # 包含等值条件通常可以使用索引 - return "=" in where_clause - - async def _get_table_size(self, instance: Instance, database: str, - table: str) -> float: - """获取表大小(MB)。简化实现,实际应查询数据库。""" - # 在实际实现中,应该查询 INFORMATION_SCHEMA - # 这里返回一个保守估计 - return 50.0 # 默认假设 50MB - - async def _get_table_row_count(self, instance: Instance, database: str, - table: str) -> int: - """获取表行数。简化实现。""" - # 实际应执行 SELECT COUNT(*) - return 10000 # 默认假设 10000 行 diff --git a/backend/app/services/instance_service.py b/backend/app/services/instance_service.py deleted file mode 100644 index 516fe6d..0000000 --- a/backend/app/services/instance_service.py +++ /dev/null @@ -1,134 +0,0 @@ -from typing import List -import aiomysql - -from app.models.instance import Instance -from app.services.db_connector import DBConnector - - -class InstanceService: - - @staticmethod - async def test_connection(instance: Instance) -> dict: - """Test database connection using DBConnector.""" - result = await DBConnector.test_connection(instance) - - # Update instance status based on result - if result.success: - instance.status = "connected" - instance.version = result.version - else: - instance.status = "error" - - return result.to_dict() - - @staticmethod - async def list_databases(instance: Instance) -> List[str]: - """List databases for the given instance type.""" - if instance.db_type == "mysql": - conn = await aiomysql.connect( - host=instance.host, port=instance.port, - user=instance.username, password=instance.password, - ) - try: - async with conn.cursor() as cur: - await cur.execute("SHOW DATABASES") - rows = await cur.fetchall() - return [row[0] for row in rows if row[0] not in ("information_schema", "mysql", "performance_schema", "sys")] - finally: - conn.close() - elif instance.db_type in ("postgres", "postgresql"): - import asyncpg - dsn = f"postgresql://{instance.username}:{instance.password}@{instance.host}:{instance.port}/postgres" - conn = await asyncpg.connect(dsn) - try: - rows = await conn.fetch("SELECT datname FROM pg_database WHERE datistemplate = false") - return [r["datname"] for r in rows] - finally: - await conn.close() - elif instance.db_type == "redis": - from app.services.redis_connector import RedisConnector - conn = await RedisConnector.get_connection(instance) - try: - db_count = await conn.config_get("databases") - num = int(db_count.get("databases", 16)) - return [f"db{i}" for i in range(num)] - finally: - await conn.close() - elif instance.db_type == "mongo": - from pymongo import MongoClient - pwd = instance.password - if pwd and not pwd.startswith("$"): - from app.core.crypto import decrypt_password - try: - pwd = decrypt_password(pwd) - except Exception: - pass - uri = f"mongodb://{instance.username}:{pwd}@{instance.host}:{instance.port}/admin" - client = MongoClient(uri, serverSelectionTimeoutMS=5000) - try: - dbs = client.list_database_names() - return [d for d in dbs if d not in ("admin", "config", "local")] - finally: - client.close() - elif instance.db_type == "sqlite": - return [instance.database or "main"] - else: - return [] - - @staticmethod - async def list_tables(instance: Instance, database: str) -> List[dict]: - """List tables in a database.""" - if instance.db_type == "mysql": - conn = await aiomysql.connect( - host=instance.host, port=instance.port, - user=instance.username, password=instance.password, - db=database, - ) - try: - async with conn.cursor(aiomysql.DictCursor) as cur: - await cur.execute( - "SELECT TABLE_NAME as name, TABLE_ROWS as rows, TABLE_TYPE as type " - "FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s", - (database,) - ) - return await cur.fetchall() - finally: - conn.close() - elif instance.db_type in ("postgres", "postgresql"): - import asyncpg - dsn = f"postgresql://{instance.username}:{instance.password}@{instance.host}:{instance.port}/{database}" - conn = await asyncpg.connect(dsn) - try: - rows = await conn.fetch( - "SELECT tablename as name FROM pg_tables WHERE schemaname = 'public'" - ) - return [dict(r) for r in rows] - finally: - await conn.close() - elif instance.db_type == "mongo": - from pymongo import MongoClient - pwd = instance.password - if pwd and not pwd.startswith("$"): - from app.core.crypto import decrypt_password - try: - pwd = decrypt_password(pwd) - except Exception: - pass - uri = f"mongodb://{instance.username}:{pwd}@{instance.host}:{instance.port}/{database}" - client = MongoClient(uri, serverSelectionTimeoutMS=5000) - try: - db_obj = client[database] - colls = db_obj.list_collection_names() - return [{"name": c} for c in colls] - finally: - client.close() - elif instance.db_type == "sqlite": - import sqlite3 - db_path = instance.host - conn = sqlite3.connect(db_path) - cur = conn.execute("SELECT name FROM sqlite_master WHERE type='table'") - tables = [{"name": row[0]} for row in cur.fetchall()] - conn.close() - return tables - else: - return [] diff --git a/backend/app/services/log_cleanup.py b/backend/app/services/log_cleanup.py deleted file mode 100644 index 7636b2f..0000000 --- a/backend/app/services/log_cleanup.py +++ /dev/null @@ -1,131 +0,0 @@ -"""日志自动清理服务 — 基于 APScheduler cron job。 - -每天定时清理过期记录: -- 数据变更工单 (tickets + ticket_approvals) -- 审计日志 (audit_logs) -- 查询历史 (query_histories) -- 定时任务日志 (scheduled_task_logs) - -清理规则: -- 只清理已完成/已拒绝的工单(status in ['success', 'failed', 'rejected']) -- 每项保留天数由 system_settings 中对应 key 控制 -- 0 天 = 不清理 -- 清理结果记录到 audit_logs -""" -import logging -from datetime import datetime, timedelta, timezone -from typing import Optional - -from sqlalchemy import select, delete, func - -from app.core.database import async_session -from app.models.system_setting import SystemSetting -from app.models.ticket import Ticket -from app.models.audit_log import AuditLog -from app.models.query_history import QueryHistory -from app.models.scheduled_task import ScheduledTaskLog - -logger = logging.getLogger(__name__) - - -async def _get_setting(db, key: str) -> Optional[str]: - """获取系统设置值。""" - result = await db.execute(select(SystemSetting).where(SystemSetting.key == key)) - s = result.scalar_one_or_none() - return s.value if s else None - - -async def _cleanup_tickets(db, days: int) -> int: - """清理过期工单及其审批记录。""" - if days <= 0: - return 0 - cutoff = datetime.now(timezone.utc) - timedelta(days=days) - - # 先找要删除的工单 ID - result = await db.execute( - select(Ticket.id).where( - Ticket.status.in_(['success', 'failed', 'rejected']), - Ticket.create_time < cutoff, - ) - ) - ticket_ids = [row[0] for row in result.all()] - if not ticket_ids: - return 0 - - # 删除审批记录 - from app.models.ticket import TicketApproval - for tid in ticket_ids: - await db.execute(delete(TicketApproval).where(TicketApproval.ticket_id == tid)) - - # 删除工单 - await db.execute(delete(Ticket).where(Ticket.id.in_(ticket_ids))) - await db.commit() - return len(ticket_ids) - - -async def _cleanup_audit_logs(db, days: int) -> int: - """清理过期审计日志。""" - if days <= 0: - return 0 - cutoff = datetime.now(timezone.utc) - timedelta(days=days) - result = await db.execute(delete(AuditLog).where(AuditLog.created_at < cutoff)) - await db.commit() - return result.rowcount - - -async def _cleanup_query_histories(db, days: int) -> int: - """清理过期查询历史。""" - if days <= 0: - return 0 - cutoff = datetime.now(timezone.utc) - timedelta(days=days) - result = await db.execute(delete(QueryHistory).where(QueryHistory.created_at < cutoff)) - await db.commit() - return result.rowcount - - -async def _cleanup_scheduled_task_logs(db, days: int) -> int: - """清理过期定时任务日志。""" - if days <= 0: - return 0 - cutoff = datetime.now(timezone.utc) - timedelta(days=days) - result = await db.execute(delete(ScheduledTaskLog).where(ScheduledTaskLog.started_at < cutoff)) - await db.commit() - return result.rowcount - - -async def run_cleanup() -> dict: - """执行一次完整清理,返回各项统计。""" - stats = { - 'tickets': 0, - 'audit_logs': 0, - 'query_histories': 0, - 'scheduled_task_logs': 0, - } - - try: - async with async_session() as db: - enabled = await _get_setting(db, 'cleanup_enabled') - if enabled != 'true': - logger.info('自动清理已禁用,跳过') - return stats - - ticket_days = int(await _get_setting(db, 'cleanup_ticket_days') or '0') - audit_days = int(await _get_setting(db, 'cleanup_audit_log_days') or '0') - query_days = int(await _get_setting(db, 'cleanup_query_history_days') or '0') - task_log_days = int(await _get_setting(db, 'cleanup_scheduled_task_log_days') or '0') - - stats['tickets'] = await _cleanup_tickets(db, ticket_days) - stats['audit_logs'] = await _cleanup_audit_logs(db, audit_days) - stats['query_histories'] = await _cleanup_query_histories(db, query_days) - stats['scheduled_task_logs'] = await _cleanup_scheduled_task_logs(db, task_log_days) - - logger.info(f'自动清理完成: {stats}') - except Exception as e: - logger.error(f'自动清理失败: {e}') - - return stats - - -async def run_cleanup_sync(): - """sync wrapper for APScheduler job.""" - return await run_cleanup() diff --git a/backend/app/services/mongo_connector.py b/backend/app/services/mongo_connector.py deleted file mode 100644 index b9f47b2..0000000 --- a/backend/app/services/mongo_connector.py +++ /dev/null @@ -1,780 +0,0 @@ -"""MongoDB 异步连接器 — 使用 motor 驱动。 - -支持数据库变更工单的全生命周期: -- 连接管理(支持认证、TLS) -- 只读查询: find, findOne, countDocuments, distinct, aggregate -- 变更操作: insertOne/Many, updateOne/Many, replaceOne, deleteOne/Many -- 连接测试、库/collection 列表 -- 安全验证:只读操作白名单 - -查询格式(JSON): -{ - "collection": "users", - "operation": "find", - "filter": {"age": {"$gt": 18}}, - "projection": {"name": 1, "age": 1}, - "sort": {"age": -1}, - "limit": 100, - "skip": 0 -} - -变更格式(JSON): -{ - "collection": "users", - "operation": "insertMany", - "documents": [{"name": "Alice", "age": 25}] -} -""" - -import json -import time -from typing import Any, Dict, List, Optional, Tuple - -from bson import json_util -from motor.motor_asyncio import AsyncIOMotorClient - -from app.core.crypto import decrypt_password - -# ── 常量定义 ───────────────────────────────────────────────────── - -MONGODB_READONLY_OPS: set[str] = { - "find", - "findOne", - "countDocuments", - "estimatedDocumentCount", - "distinct", - "aggregate", -} - -MONGODB_WRITE_OPS: set[str] = { - "insertOne", - "insertMany", - "updateOne", - "updateMany", - "replaceOne", - "deleteOne", - "deleteMany", - "drop", - "createIndex", - "dropIndex", - "renameCollection", - "findOneAndUpdate", - "findOneAndReplace", - "findOneAndDelete", - "bulkWrite", -} - -MAX_QUERY_LIMIT = 10000 - - -# ── 安全验证 ───────────────────────────────────────────────────── - - -def validate_operation(operation: str) -> Tuple[bool, str]: - """验证操作是否为只读。 - - Args: - operation: MongoDB 操作名称。 - - Returns: - (is_readonly, error_message): 是否只读,若否附带错误原因。 - """ - if operation in MONGODB_READONLY_OPS: - return True, "" - if operation in MONGODB_WRITE_OPS: - return False, f"MongoDB 操作 '{operation}' 是写操作,查询模式下禁止" - return False, f"未知的 MongoDB 操作: {operation}" - - -# ── 客户端缓存 ─────────────────────────────────────────────────── - -_mongo_clients: Dict[int, AsyncIOMotorClient] = {} - - -class ConnectionResult: - """MongoDB 连接测试结果。""" - - def __init__( - self, - success: bool, - version: str = "", - latency_ms: int = 0, - message: str = "", - ): - self.success = success - self.version = version - self.latency_ms = latency_ms - self.message = message - - def to_dict(self) -> dict: - return { - "success": self.success, - "version": self.version, - "latency_ms": self.latency_ms, - "message": self.message, - } - - -class MongoConnector: - """MongoDB 异步连接器。 - - 提供连接池管理、只读查询、变更操作、元数据获取及安全验证。 - """ - - # ── 连接管理 ──────────────────────────────────────────────── - - @staticmethod - async def get_client(instance: Any) -> AsyncIOMotorClient: - """获取(或创建)motor 客户端实例。 - - 使用内部缓存避免重复创建连接池。密码通过 decrypt_password 解密。 - - Args: - instance: Instance 模型对象,需包含 host, port, username, password 等字段。 - - Returns: - AsyncIOMotorClient: 异步 MongoDB 客户端。 - """ - instance_id: int = int(instance.id) - if instance_id in _mongo_clients: - return _mongo_clients[instance_id] - - password = decrypt_password(instance.password) - conn_str = MongoConnector._build_connection_string( - host=instance.host, - port=instance.port, - username=instance.username or None, - password=password or None, - database=getattr(instance, "database", None), - ) - client = AsyncIOMotorClient(conn_str, serverSelectionTimeoutMS=5000) - _mongo_clients[instance_id] = client - return client - - @staticmethod - def _build_connection_string( - host: str, - port: int, - username: Optional[str] = None, - password: Optional[str] = None, - database: Optional[str] = None, - ) -> str: - """构建 MongoDB 连接字符串。 - - Args: - host: 主机地址。 - port: 端口号。 - username: 用户名(可选)。 - password: 密码(可选)。 - database: 认证数据库(可选)。 - - Returns: - MongoDB URI 字符串。 - """ - if username and password: - auth_db = database or "admin" - return ( - f"mongodb://{username}:{password}@{host}:{port}/{auth_db}" - f"?authSource={auth_db}" - ) - return f"mongodb://{host}:{port}" - - @staticmethod - async def test_connection(instance: Any) -> ConnectionResult: - """测试 MongoDB 连接并返回版本与延迟。 - - Args: - instance: Instance 模型对象。 - - Returns: - ConnectionResult: 包含 success, version, latency_ms, message。 - """ - start = time.time() - client: AsyncIOMotorClient = await MongoConnector.get_client(instance) - try: - build_info = await client.admin.command("buildInfo") - elapsed = int((time.time() - start) * 1000) - version = build_info.get("version", "unknown") - return ConnectionResult( - success=True, version=version, latency_ms=elapsed, message="连接成功" - ) - except Exception as exc: - elapsed = int((time.time() - start) * 1000) - return ConnectionResult( - success=False, - version="", - latency_ms=elapsed, - message=f"连接失败: {exc}", - ) - - @staticmethod - async def close_client(instance_id: int) -> None: - """关闭指定实例的 MongoDB 客户端并从缓存中移除。 - - Args: - instance_id: 数据库实例 ID。 - """ - client = _mongo_clients.pop(instance_id, None) - if client: - client.close() - - # ── 只读查询 ──────────────────────────────────────────────── - - @staticmethod - async def execute_find( - instance: Any, - database: str, - collection: str, - filter_doc: Optional[Dict[str, Any]] = None, - projection: Optional[Dict[str, Any]] = None, - sort: Optional[List[tuple]] = None, - limit: int = 1000, - skip: int = 0, - ) -> List[Dict[str, Any]]: - """执行 find 查询。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - collection: 集合名称。 - filter_doc: 查询过滤器(MongoDB 查询文档)。 - projection: 字段投影。 - sort: 排序规则,可以是字典 {"field": 1/-1} 或列表 [("field", 1/-1)]。 - limit: 最大返回文档数(上限 MAX_QUERY_LIMIT)。 - skip: 跳过的文档数。 - - Returns: - 文档列表,每个文档为字典。_id 被序列化为字符串。 - - Raises: - ValueError: 当 limit 超过 MAX_QUERY_LIMIT 时。 - """ - if limit > MAX_QUERY_LIMIT: - raise ValueError(f"limit 不能超过 {MAX_QUERY_LIMIT}") - - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - - cursor = coll.find(filter_doc or {}, projection=projection) - if sort: - if isinstance(sort, dict): - sort_list = [(k, v) for k, v in sort.items()] - cursor = cursor.sort(sort_list) - else: - cursor = cursor.sort(sort) - if skip: - cursor = cursor.skip(skip) - if limit: - cursor = cursor.limit(limit) - - docs = await cursor.to_list(length=limit or MAX_QUERY_LIMIT) - return [MongoConnector._serialize_doc(doc) for doc in docs] - - @staticmethod - async def execute_find_one( - instance: Any, - database: str, - collection: str, - filter_doc: Optional[Dict[str, Any]] = None, - projection: Optional[Dict[str, Any]] = None, - ) -> Optional[Dict[str, Any]]: - """执行 findOne 查询,返回单个文档或 None。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - collection: 集合名称。 - filter_doc: 查询过滤器。 - projection: 字段投影。 - - Returns: - 单个文档字典,或 None。_id 被序列化为字符串。 - """ - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - doc = await coll.find_one(filter_doc or {}, projection=projection) - if doc is None: - return None - return MongoConnector._serialize_doc(doc) - - @staticmethod - async def execute_count( - instance: Any, - database: str, - collection: str, - filter_doc: Optional[Dict[str, Any]] = None, - ) -> int: - """执行 countDocuments 计数。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - collection: 集合名称。 - filter_doc: 查询过滤器。 - - Returns: - 符合条件的文档数量。 - """ - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - return await coll.count_documents(filter_doc or {}) - - @staticmethod - async def execute_distinct( - instance: Any, - database: str, - collection: str, - field: str, - filter_doc: Optional[Dict[str, Any]] = None, - ) -> List[Any]: - """获取指定字段的去重值列表。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - collection: 集合名称。 - field: 字段名。 - filter_doc: 前置过滤器。 - - Returns: - 去重后的值列表。 - """ - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - return await coll.distinct(field, filter_doc or {}) - - @staticmethod - async def execute_aggregate( - instance: Any, - database: str, - collection: str, - pipeline: List[Dict[str, Any]], - ) -> List[Dict[str, Any]]: - """执行聚合管道查询。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - collection: 集合名称。 - pipeline: 聚合管道阶段列表。 - - Returns: - 聚合结果文档列表。_id 被序列化为字符串。 - """ - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - cursor = coll.aggregate(pipeline, allowDiskUse=True) - docs = await cursor.to_list(length=MAX_QUERY_LIMIT) - return [MongoConnector._serialize_doc(doc) for doc in docs] - - # ── 变更操作(工单执行用)─────────────────────────────────── - - @staticmethod - async def execute_insert_one( - instance: Any, - database: str, - collection: str, - document: Dict[str, Any], - ) -> str: - """插入单个文档。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - collection: 集合名称。 - document: 待插入的文档。 - - Returns: - 插入文档的 ID 字符串。 - """ - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - result = await coll.insert_one(document) - return str(result.inserted_id) - - @staticmethod - async def execute_insert_many( - instance: Any, - database: str, - collection: str, - documents: List[Dict[str, Any]], - ) -> str: - """批量插入文档。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - collection: 集合名称。 - documents: 待插入的文档列表。 - - Returns: - 插入数量的描述字符串。 - - Raises: - ValueError: 文档列表为空时。 - """ - if not documents: - raise ValueError("documents 不能为空列表") - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - result = await coll.insert_many(documents) - return f"成功插入 {len(result.inserted_ids)} 条文档" - - @staticmethod - async def execute_update_one( - instance: Any, - database: str, - collection: str, - filter_doc: Dict[str, Any], - update_doc: Dict[str, Any], - ) -> str: - """更新单个文档。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - collection: 集合名称。 - filter_doc: 查询过滤器。 - update_doc: 更新操作文档(如 {"$set": {...}})。 - - Returns: - 影响行数描述字符串。 - """ - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - result = await coll.update_one(filter_doc, update_doc) - return f"匹配 {result.matched_count} 条,修改 {result.modified_count} 条" - - @staticmethod - async def execute_update_many( - instance: Any, - database: str, - collection: str, - filter_doc: Dict[str, Any], - update_doc: Dict[str, Any], - ) -> str: - """批量更新文档。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - collection: 集合名称。 - filter_doc: 查询过滤器。 - update_doc: 更新操作文档。 - - Returns: - 影响行数描述字符串。 - """ - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - result = await coll.update_many(filter_doc, update_doc) - return f"匹配 {result.matched_count} 条,修改 {result.modified_count} 条" - - @staticmethod - async def execute_replace_one( - instance: Any, - database: str, - collection: str, - filter_doc: Dict[str, Any], - replacement: Dict[str, Any], - ) -> str: - """替换单个文档。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - collection: 集合名称。 - filter_doc: 查询过滤器。 - replacement: 替换用的新文档。 - - Returns: - 影响行数描述字符串。 - """ - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - result = await coll.replace_one(filter_doc, replacement) - return f"匹配 {result.matched_count} 条,替换 {result.modified_count} 条" - - @staticmethod - async def execute_delete_one( - instance: Any, - database: str, - collection: str, - filter_doc: Dict[str, Any], - ) -> str: - """删除单个文档。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - collection: 集合名称。 - filter_doc: 查询过滤器。 - - Returns: - 删除数量描述字符串。 - """ - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - result = await coll.delete_one(filter_doc) - return f"删除 {result.deleted_count} 条文档" - - @staticmethod - async def execute_delete_many( - instance: Any, - database: str, - collection: str, - filter_doc: Dict[str, Any], - ) -> str: - """批量删除文档。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - collection: 集合名称。 - filter_doc: 查询过滤器。 - - Returns: - 删除数量描述字符串。 - """ - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - result = await coll.delete_many(filter_doc) - return f"删除 {result.deleted_count} 条文档" - - # ── 元数据 ────────────────────────────────────────────────── - - @staticmethod - async def list_databases(instance: Any) -> List[str]: - """列出 MongoDB 实例中所有数据库名称。 - - Args: - instance: Instance 模型对象。 - - Returns: - 数据库名称列表(排除系统库 admin, local, config)。 - """ - client = await MongoConnector.get_client(instance) - db_info_list = await client.list_database_names() - system_dbs = {"admin", "local", "config"} - return [name for name in db_info_list if name not in system_dbs] - - @staticmethod - async def list_collections(instance: Any, database: str) -> List[str]: - """列出指定数据库中所有集合名称。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - - Returns: - 集合名称列表。 - """ - client = await MongoConnector.get_client(instance) - db = client[database] - return await db.list_collection_names() - - @staticmethod - async def get_collection_stats( - instance: Any, database: str, collection: str - ) -> Dict[str, Any]: - """获取集合统计信息。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - collection: 集合名称。 - - Returns: - 包含 count, size, avgObjSize, storageSize, indexes, totalIndexSize 等字段的字典。 - """ - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - stats = await coll.database.command("collStats", collection) - return { - "namespace": stats.get("ns", f"{database}.{collection}"), - "document_count": stats.get("count", 0), - "size_bytes": stats.get("size", 0), - "avg_document_size_bytes": stats.get("avgObjSize", 0), - "storage_size_bytes": stats.get("storageSize", 0), - "index_count": stats.get("nindexes", 0), - "index_size_bytes": stats.get("totalIndexSize", 0), - "capped": stats.get("capped", False), - } - - # ── 通用查询执行 ──────────────────────────────────────────── - - @staticmethod - async def execute_query_json( - instance: Any, - database: str, - query_json: Dict[str, Any], - ) -> Any: - """通过 JSON 格式执行 MongoDB 查询。 - - 自动根据 query_json["operation"] 分发到对应方法。 - 仅支持只读操作,写操作会被拒绝。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - query_json: 查询 JSON 字典,必须包含 collection 和 operation 字段。 - - Returns: - 查询结果,类型取决于具体操作。 - - Raises: - ValueError: 操作不合法或参数缺失时。 - """ - collection: str = query_json.get("collection") - operation: str = query_json.get("operation") - - if not collection: - raise ValueError("查询 JSON 缺少 'collection' 字段") - if not operation: - raise ValueError("查询 JSON 缺少 'operation' 字段") - - is_readonly, error_msg = validate_operation(operation) - if not is_readonly: - raise ValueError(error_msg) - - filter_doc = query_json.get("filter") or query_json.get("query") or {} - projection = query_json.get("projection") - sort = query_json.get("sort") - limit = query_json.get("limit", 1000) - skip = query_json.get("skip", 0) - pipeline = query_json.get("pipeline", []) - field = query_json.get("field", "") - - if operation == "find": - return await MongoConnector.execute_find( - instance, database, collection, filter_doc, projection, sort, limit, skip - ) - if operation == "findOne": - return await MongoConnector.execute_find_one( - instance, database, collection, filter_doc, projection - ) - if operation == "countDocuments": - return await MongoConnector.execute_count( - instance, database, collection, filter_doc - ) - if operation == "estimatedDocumentCount": - client = await MongoConnector.get_client(instance) - coll = client[database][collection] - return await coll.estimated_document_count() - if operation == "distinct": - if not field: - raise ValueError("distinct 操作需要提供 'field' 字段") - return await MongoConnector.execute_distinct( - instance, database, collection, field, filter_doc - ) - if operation == "aggregate": - if not pipeline: - raise ValueError("aggregate 操作需要提供 'pipeline' 字段") - return await MongoConnector.execute_aggregate( - instance, database, collection, pipeline - ) - - raise ValueError(f"不支持的只读操作: {operation}") - - @staticmethod - async def execute_change_json( - instance: Any, - database: str, - change_json: Dict[str, Any], - ) -> str: - """通过 JSON 格式执行 MongoDB 变更操作。 - - 自动根据 change_json["operation"] 分发到对应方法。 - 用于工单变更执行。 - - Args: - instance: Instance 模型对象。 - database: 数据库名称。 - change_json: 变更 JSON 字典,必须包含 collection 和 operation 字段。 - - Returns: - 操作结果描述字符串。 - - Raises: - ValueError: 操作不合法或参数缺失时。 - """ - collection: str = change_json.get("collection") - operation: str = change_json.get("operation") - - if not collection: - raise ValueError("变更 JSON 缺少 'collection' 字段") - if not operation: - raise ValueError("变更 JSON 缺少 'operation' 字段") - - if operation not in MONGODB_WRITE_OPS: - raise ValueError(f"不支持的变更操作: {operation}") - - filter_doc = change_json.get("filter") or change_json.get("query") or {} - - if operation == "insertOne": - document = change_json.get("document") - if document is None: - raise ValueError("insertOne 需要提供 'document' 字段") - return await MongoConnector.execute_insert_one( - instance, database, collection, document - ) - - if operation == "insertMany": - documents = change_json.get("documents") - if not documents: - raise ValueError("insertMany 需要提供 'documents' 字段") - return await MongoConnector.execute_insert_many( - instance, database, collection, documents - ) - - if operation == "updateOne": - update_doc = change_json.get("update") - if update_doc is None: - raise ValueError("updateOne 需要提供 'update' 字段") - return await MongoConnector.execute_update_one( - instance, database, collection, filter_doc, update_doc - ) - - if operation == "updateMany": - update_doc = change_json.get("update") - if update_doc is None: - raise ValueError("updateMany 需要提供 'update' 字段") - return await MongoConnector.execute_update_many( - instance, database, collection, filter_doc, update_doc - ) - - if operation == "replaceOne": - replacement = change_json.get("replacement") - if replacement is None: - raise ValueError("replaceOne 需要提供 'replacement' 字段") - return await MongoConnector.execute_replace_one( - instance, database, collection, filter_doc, replacement - ) - - if operation == "deleteOne": - return await MongoConnector.execute_delete_one( - instance, database, collection, filter_doc - ) - - if operation == "deleteMany": - return await MongoConnector.execute_delete_many( - instance, database, collection, filter_doc - ) - - raise ValueError(f"暂未实现的变更操作: {operation}") - - # ── 序列化工具 ────────────────────────────────────────────── - - @staticmethod - def _serialize_doc(doc: Dict[str, Any]) -> Dict[str, Any]: - """将 MongoDB 文档序列化为 JSON 可序列化字典。 - - 使用 bson.json_util 处理 ObjectId、DateTime 等特殊类型。 - - Args: - doc: 原始 MongoDB 文档。 - - Returns: - JSON 可序列化的字典。 - """ - return json.loads(json_util.dumps(doc)) diff --git a/backend/app/services/mongodb_audit_rules.py b/backend/app/services/mongodb_audit_rules.py deleted file mode 100644 index ba32bc4..0000000 --- a/backend/app/services/mongodb_audit_rules.py +++ /dev/null @@ -1,1638 +0,0 @@ -"""MongoDB 审核规则引擎 — 42+ 条 MongoDB 特有审核规则。 - -支持: -- 集合管理规则 (Collection Management) -- 查询安全规则 (Query Safety) -- 聚合管道规则 (Aggregation Pipeline) -- 写入安全规则 (Write Safety) -- 安全规则 (Security) -- 性能规则 (Performance) - -使用方式: - engine = SQLAuditEngineV2(db_session) - report = await engine.audit( - '{"find": "users", "filter": {"age": {"$gt": 18}}}', - db_type="mongodb" - ) -""" - -from __future__ import annotations - -import json -import re -from typing import Any, Dict, List, Optional, Tuple - -from app.services.audit_engine_v2 import ( - register_rule, - AuditRuleV2, - AuditFindingV2, - AuditReportV2, - Severity, - RuleCategory, -) - - -# ────────────────────────────────────────────── -# JSON / BSON 辅助解析函数 -# ────────────────────────────────────────────── - - -def _try_parse_json(sql_clean: str) -> Tuple[bool, Dict[str, Any]]: - """尝试解析 MongoDB 查询为 JSON 对象。 - - Returns: - (success, parsed_dict) — 解析成功返回 (True, dict),失败返回 (False, {}) - """ - # 去除首尾空白 - text = sql_clean.strip() - if not text: - return False, {} - # 如果文本被单引号包裹,去除外层引号 - if text.startswith("'") and text.endswith("'"): - text = text[1:-1] - if text.startswith('"') and text.endswith('"') and len(text) > 1: - inner = text[1:-1] - # 检查内部是否是有效的 JSON(避免普通字符串被误剥) - if inner.strip().startswith("{"): - text = inner - # 去除末尾可能存在的分号 - text = text.rstrip(";").strip() - if not text: - return False, {} - try: - parsed = json.loads(text) - if isinstance(parsed, dict): - return True, parsed - return False, {} - except (json.JSONDecodeError, ValueError): - return False, {} - - -def _is_json_mode(sql_clean: str) -> bool: - """检查是否为 JSON 格式的 MongoDB 查询。 - - MongoDB 查询通常是 JSON 格式(以 { 开头), - 而 shell 命令如 db.collection.find() 以 db. 或 javascript 开头。 - """ - text = sql_clean.strip() - if not text: - return False - # 标准的 JSON 查询格式 {"find": "collection", ...} - if text.startswith("{"): - return True - # 或者 shell 命令中带有 { } 参数 - if "{" in text and "}" in text: - return True - return False - - -def _is_shell_command(sql_clean: str) -> bool: - """检查是否为 MongoDB shell 命令(如 db.collection.find())。""" - text = sql_clean.strip() - return text.startswith("db.") or text.startswith("sh.") - - -def _extract_collection_name(sql_clean: str) -> Optional[str]: - """从 MongoDB 查询中提取集合名称。 - - 支持格式: - - JSON: {"find": "collection_name", ...} - - Shell: db.collection_name.find(...) - - Shell: db.getCollection("name").find(...) - """ - # 尝试 JSON 格式 - success, parsed = _try_parse_json(sql_clean) - if success: - # {"find": "users", ...} - if "find" in parsed and isinstance(parsed["find"], str): - return parsed["find"] - # {"insert": "users", ...} - for op in ("insert", "update", "delete", "aggregate", "count", "distinct", - "findOne", "insertOne", "updateOne", "deleteOne", - "insertMany", "updateMany", "deleteMany"): - if op in parsed and isinstance(parsed[op], str): - return parsed[op] - return None - - # Shell 格式: db.collection_name.operation() - text = sql_clean.strip() - # db.getCollection("name").method(...) - gc_match = re.search(r'\bgetCollection\s*\(\s*["\'](\w+)["\']\s*\)', text) - if gc_match: - return gc_match.group(1) - # db.name.method(...) - shell_match = re.search(r'\bdb\.(\w+)\.', text) - if shell_match: - return shell_match.group(1) - return None - - -def _has_where_operator(sql_clean: str) -> bool: - """检查是否使用了 $where 运算符(注入风险)。""" - return "\"$where\"" in sql_clean or "'$where'" in sql_clean - - -def _has_function_operator(sql_clean: str) -> bool: - """检查是否使用了 $function 运算符。""" - return "\"$function\"" in sql_clean or "'$function'" in sql_clean - - -def _count_json_keys(obj: Any) -> int: - """递归计算 JSON 对象中的键总数(估算文档大小)。""" - if isinstance(obj, dict): - return sum(1 + _count_json_keys(v) for k, v in obj.items()) - elif isinstance(obj, list): - return sum(_count_json_keys(item) for item in obj) - return 1 - - -def _get_nesting_depth(obj: Any, current_depth: int = 0) -> int: - """计算 JSON 对象的最大嵌套深度。""" - if isinstance(obj, dict): - if not obj: - return current_depth - return max(_get_nesting_depth(v, current_depth + 1) for v in obj.values()) - elif isinstance(obj, list): - if not obj: - return current_depth - return max(_get_nesting_depth(item, current_depth + 1) for item in obj) - return current_depth - - -def _find_all_values(obj: Any, key: str) -> List[Any]: - """递归查找 JSON 对象中所有指定 key 的值。""" - results: List[Any] = [] - if isinstance(obj, dict): - for k, v in obj.items(): - if k == key: - results.append(v) - results.extend(_find_all_values(v, key)) - elif isinstance(obj, list): - for item in obj: - results.extend(_find_all_values(item, key)) - return results - - -def _has_key_recursive(obj: Any, key: str) -> bool: - """递归检查 JSON 对象是否包含指定 key。""" - if isinstance(obj, dict): - if key in obj: - return True - return any(_has_key_recursive(v, key) for v in obj.values()) - elif isinstance(obj, list): - return any(_has_key_recursive(item, key) for item in obj) - return False - - -def _get_json_string_length(sql_clean: str) -> int: - """估算 JSON 字符串的字节长度(用于文档大小估算)。""" - return len(sql_clean.encode("utf-8")) - - -def _count_pipeline_stages(sql_clean: str) -> int: - """计算聚合管道中的阶段数量。""" - success, parsed = _try_parse_json(sql_clean) - if success and "pipeline" in parsed and isinstance(parsed["pipeline"], list): - return len(parsed["pipeline"]) - # shell 格式中粗略统计 $ 开头的 stage - stages = re.findall(r'\{\\s*"\\$\w+"', sql_clean) - return len(stages) - - -def _is_snake_case(name: str) -> bool: - """检查是否为 snake_case 命名。""" - if not name: - return False - return bool(re.match(r"^[a-z][a-z0-9_]*$", name)) - - -# ═══════════════════════════════════════════════ -# 集合管理规则 (8条) — MONGO-COL-001 ~ 008 -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="MONGO-COL-001", - name="集合命名规范(snake_case)", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mongodb", - description="MongoDB 集合名应使用小写下划线命名(snake_case)", - config={"pattern": "^[a-z][a-z0-9_]*$"}, - message_template="集合名 '{name}' 不符合 snake_case 命名规范,应使用小写下划线格式", -)) -def check_mongo_col_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """集合命名规范: snake_case。""" - if not _is_json_mode(sql_clean): - return - collection_name = _extract_collection_name(sql_clean) - if not collection_name: - return - if not _is_snake_case(collection_name): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(name=collection_name), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-COL-002", - name="集合名最大长度限制", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mongodb", - description="MongoDB 集合名最大长度为 64 字节(不含前缀)", - config={"max_name_length": 64}, - message_template="集合名 '{name}' 长度为 {length} 字节,超过最大限制 {max} 字节", -)) -def check_mongo_col_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """集合名最大长度检查: 64 字节。""" - if not _is_json_mode(sql_clean): - return - collection_name = _extract_collection_name(sql_clean) - if not collection_name: - return - max_len = rule.config.get("max_name_length", 64) - name_bytes = len(collection_name.encode("utf-8")) - if name_bytes > max_len: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(name=collection_name, length=name_bytes, max=max_len), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-COL-003", - name="禁止删除集合(DROP)", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="mongodb", - description="生产环境禁止执行删除集合操作,数据将永久丢失", - config={"dangerous_ops": ["drop"]}, - message_template="禁止执行删除集合操作: {op},数据将永久丢失", -)) -def check_mongo_col_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止删除集合。""" - if not _is_json_mode(sql_clean): - return - success, parsed = _try_parse_json(sql_clean) - if not success: - # 检查 shell 命令 - if re.search(r'\bdb\.\w+\.drop\s*\(', sql_clean): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(op="db.collection.drop()"), - )) - return - dangerous = rule.config.get("dangerous_ops", ["drop"]) - for op in dangerous: - if op in parsed: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(op=op), - )) - return - - -@register_rule(AuditRuleV2( - code="MONGO-COL-004", - name="禁止删除数据库(DROP DATABASE)", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="mongodb", - description="禁止执行 dropDatabase 操作,将删除整个数据库", - message_template="禁止执行 dropDatabase 操作,将删除整个数据库及其所有集合", -)) -def check_mongo_col_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止删除数据库。""" - text = sql_clean.strip() - # 检查 JSON 格式 {"dropDatabase": 1} - success, parsed = _try_parse_json(text) - if success and "dropDatabase" in parsed: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - return - # 检查 shell 格式 - if re.search(r'\bdb\.dropDatabase\s*\(', text): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-COL-005", - name="禁止删除索引(DROP INDEX)", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="mongodb", - description="禁止执行删除索引操作,删除后查询性能可能急剧下降", - message_template="禁止执行删除索引操作: {op},删除索引可能导致查询性能急剧下降", -)) -def check_mongo_col_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止删除索引。""" - text = sql_clean.strip() - # JSON 格式 {"dropIndexes": "collection", "index": "name"} - success, parsed = _try_parse_json(text) - if success and "dropIndexes" in parsed: - idx_name = parsed.get("index", "*") - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(op=f'dropIndexes: {idx_name}'), - )) - return - # shell 格式: db.collection.dropIndex() / dropIndexes() - if re.search(r'\b(dropIndex|dropIndexes)\s*\(', text): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(op="dropIndex(s)"), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-COL-006", - name="集合文档数上限建议", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mongodb", - description="单个集合文档数建议不超过 10 亿,超过应考虑分片", - config={"max_docs": 1_000_000_000}, - message_template="集合文档数超过 {max} 时建议进行分片(sharding)或归档", -)) -def check_mongo_col_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """集合文档数上限建议。通过 count 操作进行提示。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if success and ("count" in parsed or "estimatedDocumentCount" in parsed): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(max=rule.config["max_docs"]), - )) - return - if re.search(r'\b(countDocuments|estimatedDocumentCount)\s*\(', text): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(max=rule.config["max_docs"]), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-COL-007", - name="推荐使用 Schema Validator", - category=RuleCategory.DDL, - severity=Severity.INFO, - db_type="mongodb", - description="建议在创建集合时使用 JSON Schema Validator 进行数据校验", - message_template="建议为集合添加 JSON Schema Validator 以约束文档结构", -)) -def check_mongo_col_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """推荐使用 Schema Validator: 检查 create/createCollection 是否包含 validator。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if not success: - # shell 格式 - if re.search(r'\b(createCollection|create)\s*\(', text): - if "validator" not in text.lower() and "\"validator\"" not in text: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - return - # JSON 格式: {"create": "coll", ...} - if "create" in parsed or "createCollection" in parsed: - if "validator" not in parsed: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-COL-008", - name="Capped Collection 大小限制", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mongodb", - description="Capped Collection 应设置合理的大小限制(默认不超过 1GB)", - config={"max_capped_size": 1_073_741_824}, # 1GB - message_template="Capped Collection 大小(size)为 {size} 字节,建议不超过 {max} 字节(1GB)", -)) -def check_mongo_col_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Capped Collection 大小限制检查。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if not success: - return - create_op = parsed.get("create") or parsed.get("createCollection") - if not create_op: - return - capped = parsed.get("capped", False) - size = parsed.get("size") - max_size = rule.config.get("max_capped_size", 1_073_741_824) - if capped and size and isinstance(size, (int, float)) and size > max_size: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(size=size, max=max_size), - )) - - -# ═══════════════════════════════════════════════ -# 查询安全规则 (10条) — MONGO-QRY-001 ~ 010 -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="MONGO-QRY-001", - name="find/delete/update 必须带 filter", - category=RuleCategory.DQL, - severity=Severity.ERROR, - db_type="mongodb", - description="MongoDB 查询/更新/删除操作必须包含 filter 条件,禁止无条件操作", - message_template="{op} 操作缺少 filter/条件,将操作全集合数据,必须指定查询条件", -)) -def check_mongo_qry_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """find/delete/update 必须带 filter。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if not success: - return - # 检查各类操作 - ops_with_filter = { - "find": "filter", - "findOne": "filter", - "update": "updates", - "updateOne": "filter", - "updateMany": "filter", - "delete": "deletes", - "deleteOne": "filter", - "deleteMany": "filter", - } - for op, filter_key in ops_with_filter.items(): - if op in parsed: - # 检查 filter 是否存在且非空 - if filter_key == "filter": - filter_val = parsed.get(filter_key) - if filter_val is None or (isinstance(filter_val, dict) and not filter_val): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(op=op), - )) - return - elif filter_key == "updates": - updates = parsed.get("updates", []) - if isinstance(updates, list) and updates: - for upd in updates: - if isinstance(upd, dict) and not upd.get("q"): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(op=op), - )) - return - elif filter_key == "deletes": - deletes = parsed.get("deletes", []) - if isinstance(deletes, list) and deletes: - for d in deletes: - if isinstance(d, dict) and not d.get("q"): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(op=op), - )) - return - - -@register_rule(AuditRuleV2( - code="MONGO-QRY-002", - name="推荐指定 projection", - category=RuleCategory.DQL, - severity=Severity.INFO, - db_type="mongodb", - description="find/findOne 查询推荐指定 projection 限制返回字段,减少网络传输", - message_template="{op} 查询未指定 projection,建议添加 projection 限制返回字段", -)) -def check_mongo_qry_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """推荐指定 projection。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if not success: - return - query_ops = ("find", "findOne") - for op in query_ops: - if op in parsed: - if "projection" not in parsed: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(op=op), - )) - return - - -@register_rule(AuditRuleV2( - code="MONGO-QRY-003", - name="禁止 $where/$function JavaScript 执行", - category=RuleCategory.DQL, - severity=Severity.ERROR, - db_type="mongodb", - description="禁止使用 $where 和 $function 运算符执行 JavaScript,存在注入和安全风险", - message_template="禁止使用 {op} 运算符执行 JavaScript,存在严重的安全和注入风险", -)) -def check_mongo_qry_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 $where/$function JavaScript 执行。""" - text = sql_clean.strip() - if "\"$where\"" in text or "'$where'" in text: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(op="$where"), - )) - return - if "\"$function\"" in text or "'$function'" in text: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(op="$function"), - )) - return - # 检查函数表达式中的 $where - if _has_where_operator(text): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(op="$where"), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-QRY-004", - name="禁止前缀模糊匹配(regex)", - category=RuleCategory.DQL, - severity=Severity.WARNING, - db_type="mongodb", - description="禁止正则表达式前缀模糊匹配(如 /^abc/),无法有效使用索引", - message_template="正则表达式查询 {pattern} 为前缀匹配,无法有效使用索引,建议调整查询策略", -)) -def check_mongo_qry_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止前缀模糊匹配: 检测 /^ 开头的正则或 $regex 中以 ^ 开头的模式。""" - text = sql_clean.strip() - # 检测 $regex 中以 ^ 开头的模式 - regex_patterns = re.findall(r'"\$regex"\s*:\s*"(\^[^"]*)"', text) - for pat in regex_patterns: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(pattern=pat), - )) - return - # 检测 JS 正则字面量 /^.../ - js_regex = re.findall(r'/\^(.*?)/', text) - for pat in js_regex: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(pattern=f"^{pat}"), - )) - return - - -@register_rule(AuditRuleV2( - code="MONGO-QRY-005", - name="查询返回结果上限", - category=RuleCategory.DQL, - severity=Severity.WARNING, - db_type="mongodb", - description="查询返回结果建议不超过 10000 条,大数据量应分页", - config={"max_limit": 10000}, - message_template="查询未设置 limit 或 limit 超过 {max} 条,建议分页查询", -)) -def check_mongo_qry_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """查询返回结果上限检查。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if not success: - return - if "find" not in parsed and "findOne" not in parsed and "aggregate" not in parsed: - return - limit = parsed.get("limit") - max_limit = rule.config.get("max_limit", 10000) - if limit is None: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(max=max_limit), - )) - elif isinstance(limit, (int, float)) and limit > max_limit: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(max=max_limit), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-QRY-006", - name="explain 不应在生产环境频繁使用", - category=RuleCategory.DQL, - severity=Severity.INFO, - db_type="mongodb", - description="explain() 会额外收集执行计划信息,不应在生产环境频繁使用", - message_template="explain() 操作在生产环境频繁使用可能影响性能,建议仅在调试时使用", -)) -def check_mongo_qry_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """explain() 不应在生产环境频繁使用。""" - text = sql_clean.strip() - if "explain" in text.lower() or "\"explain\"" in text: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-QRY-007", - name="不推荐 snapshot 模式", - category=RuleCategory.DQL, - severity=Severity.INFO, - db_type="mongodb", - description="snapshot 模式($snapshot 或 readConcern snapshot)性能较差", - message_template="使用 snapshot 模式可能影响查询性能,如无强一致性需求请避免使用", -)) -def check_mongo_qry_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """不推荐 snapshot 模式。""" - text = sql_clean.strip() - has_snapshot = False - # JSON 格式 - if "\"snapshot\"" in text and "true" in text.lower(): - has_snapshot = True - if "snapshot" in text.lower(): - success, parsed = _try_parse_json(text) - if success: - snapshot_val = parsed.get("snapshot") - read_concern = parsed.get("readConcern", {}) - if snapshot_val is True: - has_snapshot = True - elif isinstance(read_concern, dict) and read_concern.get("level") == "snapshot": - has_snapshot = True - if has_snapshot: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-QRY-008", - name="不建议使用 allowDiskUse", - category=RuleCategory.DQL, - severity=Severity.WARNING, - db_type="mongodb", - description="allowDiskUse: true 允许聚合操作使用磁盘,可能表明查询设计不合理", - message_template="使用 allowDiskUse: true 表明查询可能消耗大量内存,建议优化查询或索引", -)) -def check_mongo_qry_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """不建议使用 allowDiskUse。""" - text = sql_clean.strip() - if "allowDiskUse" not in text: - return - success, parsed = _try_parse_json(text) - if success: - allow_disk = parsed.get("allowDiskUse") - if allow_disk is True: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - return - # 正则匹配 - if re.search(r'"allowDiskUse"\s*:\s*true', text, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-QRY-009", - name="text 搜索使用限制", - category=RuleCategory.DQL, - severity=Severity.WARNING, - db_type="mongodb", - description="MongoDB 的 $text 搜索功能有限,建议使用 Elasticsearch 等专业搜索引擎", - message_template="使用 $text 全文搜索功能有限,建议考虑 Elasticsearch 等专业搜索方案", -)) -def check_mongo_qry_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """text 搜索使用限制。""" - text = sql_clean.strip() - if "\"$text\"" in text or "'$text'" in text: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-QRY-010", - name="filter 必须是有效的 BSON 格式", - category=RuleCategory.DQL, - severity=Severity.ERROR, - db_type="mongodb", - description="MongoDB filter 必须是有效的 JSON/BSON 对象格式", - message_template="查询条件 filter 不是有效的 JSON/BSON 对象格式,请修正", -)) -def check_mongo_qry_010(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """filter 必须是有效的 BSON 格式。""" - text = sql_clean.strip() - if not text.startswith("{"): - return - # 尝试解析 JSON - success, parsed = _try_parse_json(text) - if not success: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - return - # 检查 filter 字段是否为对象 - for filter_key in ("filter", "q"): - if filter_key in parsed: - filter_val = parsed[filter_key] - if filter_val is not None and not isinstance(filter_val, dict): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - return - - -# ═══════════════════════════════════════════════ -# 聚合管道规则 (6条) — MONGO-AGG-001 ~ 006 -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="MONGO-AGG-001", - name="禁止 $out/$merge 写操作", - category=RuleCategory.DQL, - severity=Severity.ERROR, - db_type="mongodb", - description="聚合管道中禁止使用 $out 和 $merge 阶段,可能覆盖现有集合数据", - message_template="禁止在聚合管道中使用 {stage} 阶段,可能覆盖或修改现有集合数据", -)) -def check_mongo_agg_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 $out/$merge 写操作。""" - text = sql_clean.strip() - # 检查 JSON 中的 pipeline - success, parsed = _try_parse_json(text) - if success and "pipeline" in parsed and isinstance(parsed["pipeline"], list): - for stage in parsed["pipeline"]: - if isinstance(stage, dict): - if "$out" in stage: - out_val = stage["$out"] - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(stage=f"$out: {out_val}"), - )) - return - if "$merge" in stage: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(stage="$merge"), - )) - return - # 文本检查 - if "\"$out\"" in text or "'$out'" in text: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(stage="$out"), - )) - return - if "\"$merge\"" in text or "'$merge'" in text: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(stage="$merge"), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-AGG-002", - name="聚合管道阶段数上限", - category=RuleCategory.DQL, - severity=Severity.WARNING, - db_type="mongodb", - description="聚合管道阶段数建议不超过 20 个,过多阶段影响性能", - config={"max_stages": 20}, - message_template="聚合管道包含 {count} 个阶段,超过建议上限 {max} 个", -)) -def check_mongo_agg_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """聚合管道阶段数上限检查。""" - text = sql_clean.strip() - stage_count = 0 - success, parsed = _try_parse_json(text) - if success and "pipeline" in parsed and isinstance(parsed["pipeline"], list): - stage_count = len(parsed["pipeline"]) - else: - # 粗略统计 $ 开头的 stage 数量 - stage_count = len(re.findall(r'\{\\s*"\$(\w+)"', text)) - max_stages = rule.config.get("max_stages", 20) - if stage_count > max_stages: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(count=stage_count, max=max_stages), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-AGG-003", - name="$unwind 大数据量警告", - category=RuleCategory.DQL, - severity=Severity.WARNING, - db_type="mongodb", - description="$unwind 展开数组可能导致文档数量急剧膨胀,影响性能", - message_template="使用 $unwind 阶段可能急剧增加文档数量,请确认数组字段的平均元素数量", -)) -def check_mongo_agg_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """$unwind 大数据量警告。""" - text = sql_clean.strip() - if "\"$unwind\"" in text or "'$unwind'" in text: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-AGG-004", - name="$group 操作索引建议", - category=RuleCategory.DQL, - severity=Severity.INFO, - db_type="mongodb", - description="$group 操作的 _id 字段建议创建索引以提升性能", - message_template="$group 操作的 _id 字段建议创建索引,可显著提升聚合性能", -)) -def check_mongo_agg_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """$group 操作索引建议。""" - text = sql_clean.strip() - if "\"$group\"" in text or "'$group'" in text: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-AGG-005", - name="聚合内存限制(100MB)", - category=RuleCategory.DQL, - severity=Severity.WARNING, - db_type="mongodb", - description="单个聚合阶段内存限制为 100MB,超出需使用 allowDiskUse", - config={"memory_limit_mb": 100}, - message_template="聚合操作内存限制为 {limit}MB,大型聚合可能触发内存限制错误", -)) -def check_mongo_agg_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """聚合内存限制提示。""" - text = sql_clean.strip() - if "aggregate" in text.lower() or "\"pipeline\"" in text: - limit_mb = rule.config.get("memory_limit_mb", 100) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(limit=limit_mb), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-AGG-006", - name="$lookup 嵌套层数限制", - category=RuleCategory.DQL, - severity=Severity.WARNING, - db_type="mongodb", - description="$lookup 嵌套层数建议不超过 3 层,过多嵌套影响性能", - config={"max_lookup_nesting": 3}, - message_template="$lookup 嵌套层数可能超过 {max} 层,建议优化查询结构", -)) -def check_mongo_agg_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """$lookup 嵌套层数限制。""" - text = sql_clean.strip() - lookup_count = text.count('"$lookup"') + text.count("'$lookup'") - max_nesting = rule.config.get("max_lookup_nesting", 3) - if lookup_count > max_nesting: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(max=max_nesting), - )) - - -# ═══════════════════════════════════════════════ -# 写入安全规则 (8条) — MONGO-WR-001 ~ 008 -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="MONGO-WR-001", - name="批量写入上限(1000条)", - category=RuleCategory.DML, - severity=Severity.WARNING, - db_type="mongodb", - description="insertMany/bulkWrite 单次文档数建议不超过 1000 条", - config={"max_bulk_write": 1000}, - message_template="批量写入文档数为 {count},超过建议上限 {max} 条,请分批写入", -)) -def check_mongo_wr_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """批量写入上限检查。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - doc_count = 0 - if success: - # insertMany: {"documents": [...]} - if "insertMany" in parsed or "insert" in parsed: - docs = parsed.get("documents", []) - if isinstance(docs, list): - doc_count = len(docs) - # bulkWrite: {"operations": [...]} - if "operations" in parsed and isinstance(parsed["operations"], list): - doc_count = len(parsed["operations"]) - # updateMany: {"updates": [...]} - if "updates" in parsed and isinstance(parsed["updates"], list): - doc_count = len(parsed["updates"]) - max_docs = rule.config.get("max_bulk_write", 1000) - if doc_count > max_docs: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(count=doc_count, max=max_docs), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-WR-002", - name="updateMany 必须带条件 filter", - category=RuleCategory.DML, - severity=Severity.ERROR, - db_type="mongodb", - description="updateMany 操作必须包含 filter 条件,禁止无条件更新", - message_template="updateMany 操作缺少有效的 filter 条件,将更新全集合文档", -)) -def check_mongo_wr_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """updateMany 必须带条件 filter。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if not success: - return - if "updateMany" in parsed: - filter_val = parsed.get("filter") - if filter_val is None or (isinstance(filter_val, dict) and not filter_val): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - # 检查 bulk updates - if "updates" in parsed and isinstance(parsed.get("updates"), list): - for upd in parsed["updates"]: - if isinstance(upd, dict): - q = upd.get("q") - u = upd.get("u") - multi = upd.get("multi", False) - if multi and (q is None or (isinstance(q, dict) and not q)): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - return - - -@register_rule(AuditRuleV2( - code="MONGO-WR-003", - name="deleteMany 必须带条件 filter", - category=RuleCategory.DML, - severity=Severity.ERROR, - db_type="mongodb", - description="deleteMany 操作必须包含 filter 条件,禁止无条件删除", - message_template="deleteMany 操作缺少有效的 filter 条件,将删除全集合文档", -)) -def check_mongo_wr_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """deleteMany 必须带条件 filter。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if not success: - return - if "deleteMany" in parsed: - filter_val = parsed.get("filter") - if filter_val is None or (isinstance(filter_val, dict) and not filter_val): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - # 检查 bulk deletes - if "deletes" in parsed and isinstance(parsed.get("deletes"), list): - for d in parsed["deletes"]: - if isinstance(d, dict): - q = d.get("q") - limit = d.get("limit", 0) - if limit == 0 and (q is None or (isinstance(q, dict) and not q)): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - return - - -@register_rule(AuditRuleV2( - code="MONGO-WR-004", - name="禁止 unset 关键字段", - category=RuleCategory.DML, - severity=Severity.ERROR, - db_type="mongodb", - description="禁止 $unset 关键字段(如 _id, create_time, update_time 等)", - config={"critical_fields": ["_id", "create_time", "update_time", "created_at", "updated_at"]}, - message_template="禁止使用 $unset 删除关键字段: {fields},可能导致数据完整性问题", -)) -def check_mongo_wr_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 unset 关键字段。""" - text = sql_clean.strip() - critical = rule.config.get("critical_fields", ["_id"]) - if "\"$unset\"" not in text and "'$unset'" not in text: - return - success, parsed = _try_parse_json(text) - found_fields: List[str] = [] - if success: - # 查找 updates 中的 $unset - for update_dict in _find_all_values(parsed, "u"): - if isinstance(update_dict, dict) and "$unset" in update_dict: - unset_fields = update_dict["$unset"] - if isinstance(unset_fields, dict): - for f in unset_fields: - if f in critical: - found_fields.append(f) - elif isinstance(unset_fields, list): - for f in unset_fields: - if f in critical: - found_fields.append(f) - # 也检查顶层 update 的 $unset - if isinstance(parsed, dict): - for k, v in parsed.items(): - if isinstance(v, dict) and "$unset" in v: - unset_val = v["$unset"] - if isinstance(unset_val, dict): - for f in unset_val: - if f in critical: - found_fields.append(f) - else: - # 正则提取 - unset_match = re.findall(r'"\$unset"\s*:\s*\{([^}]+)\}', text) - for m in unset_match: - for f in critical: - if f'"{f}"' in m or f"'{f}'" in m: - found_fields.append(f) - if found_fields: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(fields=", ".join(set(found_fields))), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-WR-005", - name="禁止重命名集合", - category=RuleCategory.DML, - severity=Severity.ERROR, - db_type="mongodb", - description="禁止执行 renameCollection 操作,可能影响应用连接", - message_template="禁止执行 renameCollection 操作,重命名集合可能导致应用连接失败", -)) -def check_mongo_wr_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止重命名集合。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if success: - if "renameCollection" in parsed or "collMod" in parsed: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - return - if re.search(r'\b(renameCollection|collMod)\b', text, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-WR-006", - name="禁止绕过文档验证(bypassDocumentValidation)", - category=RuleCategory.DML, - severity=Severity.ERROR, - db_type="mongodb", - description="禁止设置 bypassDocumentValidation: true 绕过文档验证", - message_template="禁止设置 bypassDocumentValidation: true 绕过 Schema 验证", -)) -def check_mongo_wr_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止绕过文档验证。""" - text = sql_clean.strip() - if "bypassDocumentValidation" not in text: - return - success, parsed = _try_parse_json(text) - if success: - bypass = parsed.get("bypassDocumentValidation") - if bypass is True: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - return - # 正则匹配 - if re.search(r'"bypassDocumentValidation"\s*:\s*true', text, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-WR-007", - name="单文档大小限制(16MB警告阈值10MB)", - category=RuleCategory.DML, - severity=Severity.WARNING, - db_type="mongodb", - description="MongoDB 单文档最大 16MB,建议不超过 10MB 作为警告阈值", - config={"warn_threshold_bytes": 10_485_760, "max_size_bytes": 16_777_216}, # 10MB, 16MB - message_template="文档大小约为 {size_mb}MB,超过警告阈值 {warn_mb}MB,建议拆分或精简文档", -)) -def check_mongo_wr_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """单文档大小限制检查。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if not success: - return - # 检查是否为写入操作 - write_ops = ("insert", "insertOne", "insertMany", "update", "updateOne", "updateMany") - is_write = any(op in parsed for op in write_ops) - if not is_write: - return - # 估算文档大小 - doc_size = _get_json_string_length(text) - warn_threshold = rule.config.get("warn_threshold_bytes", 10_485_760) - if doc_size > warn_threshold: - size_mb = round(doc_size / (1024 * 1024), 2) - warn_mb = round(warn_threshold / (1024 * 1024), 2) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(size_mb=size_mb, warn_mb=warn_mb), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-WR-008", - name="禁止嵌套深度超过5层", - category=RuleCategory.DML, - severity=Severity.WARNING, - db_type="mongodb", - description="文档嵌套深度建议不超过 5 层,过深影响查询和更新性能", - config={"max_nesting_depth": 5}, - message_template="文档嵌套深度为 {depth} 层,超过建议上限 {max} 层", -)) -def check_mongo_wr_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止嵌套深度超过5层。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if not success: - return - # 检查 documents 或 document 字段的嵌套深度 - docs = parsed.get("documents") or parsed.get("document") - if docs is None: - # 检查 updates 中的 $set/$setOnInsert 等 - updates = parsed.get("updates", []) - if isinstance(updates, list) and updates: - docs = updates[0].get("u") if isinstance(updates[0], dict) else None - if not docs: - return - target_doc = docs[0] if isinstance(docs, list) else docs - if not isinstance(target_doc, dict): - return - depth = _get_nesting_depth(target_doc) - max_depth = rule.config.get("max_nesting_depth", 5) - if depth > max_depth: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(depth=depth, max=max_depth), - )) - - -# ═══════════════════════════════════════════════ -# 安全规则 (5条) — MONGO-SEC-001 ~ 005 -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="MONGO-SEC-001", - name="禁止 db.eval() 执行 JavaScript", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="mongodb", - description="禁止 db.eval() 执行任意 JavaScript 代码,存在严重安全风险", - message_template="禁止 db.eval() 执行 JavaScript 代码,存在严重的安全和注入风险", -)) -def check_mongo_sec_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 db.eval() 执行 JavaScript。""" - text = sql_clean.strip() - if re.search(r'\bdb\.eval\s*\(', text): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-SEC-002", - name="禁止操作系统集合", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="mongodb", - description="禁止查询/操作系统集合(以 system. 开头),可能导致安全问题", - message_template="禁止操作系统集合: '{coll}',系统集合操作可能导致安全问题", -)) -def check_mongo_sec_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止操作系统集合。""" - text = sql_clean.strip() - coll = _extract_collection_name(text) - if coll and coll.startswith("system."): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(coll=coll), - )) - return - # 也检查 JSON 中的 find 等操作 - success, parsed = _try_parse_json(text) - if success: - for op_key in ("find", "findOne", "aggregate", "insert", "update", "delete", "count"): - val = parsed.get(op_key) - if isinstance(val, str) and val.startswith("system."): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(coll=val), - )) - return - - -@register_rule(AuditRuleV2( - code="MONGO-SEC-003", - name="禁止 MapReduce(使用聚合管道)", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="mongodb", - description="禁止使用 mapReduce 命令,map 和 reduce 函数可执行任意 JavaScript", - message_template="禁止 mapReduce 操作,请使用聚合管道(Aggregation Pipeline)替代", -)) -def check_mongo_sec_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 MapReduce。""" - text = sql_clean.strip() - if re.search(r'\bmapReduce\b|\bmapReduce\s*\(', text) or "\"mapReduce\"" in text: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-SEC-004", - name="防止 $cmd 注入攻击", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="mongodb", - description="禁止直接操作 $cmd 集合,可能存在注入攻击风险", - message_template="禁止直接操作 $cmd 集合,存在注入攻击风险", -)) -def check_mongo_sec_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """防止 $cmd 注入攻击。""" - text = sql_clean.strip() - if "$cmd" in text: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-SEC-005", - name="敏感操作必须记录审计日志", - category=RuleCategory.SECURITY, - severity=Severity.INFO, - db_type="mongodb", - description="删除、更新、聚合写操作等敏感操作应记录审计日志", - message_template="敏感操作({ops})应确保已记录审计日志,便于安全追踪和合规审计", -)) -def check_mongo_sec_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """敏感操作审计日志提醒。""" - text = sql_clean.strip() - sensitive_ops = [] - success, parsed = _try_parse_json(text) - op_names = { - "delete": "delete", "deleteOne": "delete", "deleteMany": "delete", - "update": "update", "updateOne": "update", "updateMany": "update", - "drop": "drop", "dropDatabase": "dropDatabase", "dropIndexes": "dropIndexes", - "renameCollection": "rename", "createUser": "userManagement", - "dropUser": "userManagement", "grantRoles": "userManagement", - } - if success: - for op, label in op_names.items(): - if op in parsed: - sensitive_ops.append(label) - else: - text_lower = text.lower() - for op, label in op_names.items(): - if op.lower() in text_lower: - sensitive_ops.append(label) - if sensitive_ops: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(ops=", ".join(set(sensitive_ops))), - )) - - -# ═══════════════════════════════════════════════ -# 性能规则 (5条) — MONGO-PERF-001 ~ 005 -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="MONGO-PERF-001", - name="缺少索引查询警告", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="mongodb", - description="查询条件字段应创建索引,缺少索引会导致全集合扫描(COLLSCAN)", - message_template="查询条件字段 '{fields}' 缺少索引可能导致全集合扫描,建议创建索引", -)) -def check_mongo_perf_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """缺少索引查询警告: 检查查询条件中常见字段是否可能缺少索引。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if not success: - return - if "find" not in parsed and "findOne" not in parsed: - return - filter_val = parsed.get("filter") - if not isinstance(filter_val, dict) or not filter_val: - return - # 检查常见的可能需要索引的字段(排除 _id 已有默认索引) - query_fields = [k for k in filter_val.keys() if not k.startswith("$") and k != "_id"] - if query_fields: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(fields=", ".join(query_fields)), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-PERF-002", - name="推荐覆盖索引查询", - category=RuleCategory.PERFORMANCE, - severity=Severity.INFO, - db_type="mongodb", - description="推荐 projection 仅包含索引字段,实现覆盖索引查询(Covered Query)", - message_template="查询条件 '{fields}' 建议 projection 仅包含索引字段以实现覆盖索引查询", -)) -def check_mongo_perf_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """推荐覆盖索引查询。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if not success: - return - if "find" not in parsed and "findOne" not in parsed: - return - filter_val = parsed.get("filter") - projection = parsed.get("projection") - if not isinstance(filter_val, dict) or not filter_val: - return - # 如果有 projection 且设置了 _id: 0,可能是尝试覆盖索引 - if isinstance(projection, dict) and projection.get("_id") == 0: - query_fields = [k for k in filter_val.keys() if not k.startswith("$")] - if query_fields: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(fields=", ".join(query_fields)), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-PERF-003", - name="全集合扫描警告", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="mongodb", - description="缺少 filter 条件或 filter 为空对象 {} 将导致全集合扫描", - message_template="查询缺少有效过滤条件,将导致全集合扫描(COLLSCAN),严重影响性能", -)) -def check_mongo_perf_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """全集合扫描警告。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if not success: - return - if "find" not in parsed and "findOne" not in parsed: - return - filter_val = parsed.get("filter") - if filter_val is None: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - elif isinstance(filter_val, dict) and not filter_val: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="MONGO-PERF-004", - name="复合索引字段顺序建议", - category=RuleCategory.PERFORMANCE, - severity=RuleCategory.PERFORMANCE, - db_type="mongodb", - description="复合索引字段顺序应遵循 ESR 规则(Equality, Sort, Range)", - message_template="复合查询条件 '{fields}' 建议遵循 ESR 规则设计索引: 等值 > 排序 > 范围", -)) -def check_mongo_perf_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """复合索引字段顺序建议: ESR 规则。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if not success: - return - if "find" not in parsed and "findOne" not in parsed: - return - filter_val = parsed.get("filter") - if not isinstance(filter_val, dict) or not filter_val: - return - # 统计查询条件中的字段数量 - query_fields = [k for k in filter_val.keys() if not k.startswith("$")] - # 如果有多于 1 个查询字段,提示复合索引 ESR 规则 - if len(query_fields) > 1: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=rule.message_template.format(fields=", ".join(query_fields)), - )) - - -@register_rule(AuditRuleV2( - code="MONGO-PERF-005", - name="分片键选择建议", - category=RuleCategory.PERFORMANCE, - severity=Severity.INFO, - db_type="mongodb", - description="分片集合应选择高基数、均匀分布的字段作为分片键", - message_template="分片集合建议: 选择高基数均匀分布字段作为分片键,避免单调递增字段和热点", -)) -def check_mongo_perf_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """分片键选择建议: 检查 shardCollection 操作。""" - text = sql_clean.strip() - success, parsed = _try_parse_json(text) - if success and "shardCollection" in parsed: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - return - if re.search(r'\bshardCollection\b', text): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ────────────────────────────────────────────── -# 规则统计信息 -# ────────────────────────────────────────────── - - -def get_mongodb_rule_count() -> Dict[str, int]: - """获取 MongoDB 规则分类统计。 - - Returns: - {"COL": 8, "QRY": 10, "AGG": 6, "WR": 8, "SEC": 5, "PERF": 5, "TOTAL": 42} - """ - return { - "COL": 8, - "QRY": 10, - "AGG": 6, - "WR": 8, - "SEC": 5, - "PERF": 5, - "TOTAL": 42, - } diff --git a/backend/app/services/mysql_audit_rules.py b/backend/app/services/mysql_audit_rules.py deleted file mode 100644 index 16b785d..0000000 --- a/backend/app/services/mysql_audit_rules.py +++ /dev/null @@ -1,2215 +0,0 @@ -"""MySQL 审核规则引擎 — 63条 MySQL 专属审核规则。 - -本模块包含 MySQL 数据库的完整审核规则集,涵盖: -- DDL 规则 (36条): 建表规范、索引规范、字段规范、字符集规范 -- DML 规则 (9条): WHERE条件、批量限制、事务规范 -- Online-DDL 规则 (3条): 在线变更工具配置 -- 命名规则 (5条): 表名/字段名/索引名规范 -- 性能规则 (5条): 全表扫描、索引使用 -- 安全规则 (5条): 敏感字段、SQL注入风险 - -使用方式: - 规则通过 @register_rule 装饰器自动注册到审核引擎, - 由 SQLAuditEngineV2 在审核时自动调用。 - -依赖: - - audit_engine_v2.py: register_rule, AuditRuleV2, AuditFindingV2, AuditReportV2 - - Severity, RuleCategory 枚举 -""" - -from __future__ import annotations - -import re -from typing import Any, Dict, List, Optional - -from app.services.audit_engine_v2 import register_rule, AuditRuleV2, AuditFindingV2, AuditReportV2 -from app.services.audit_engine_v2 import Severity, RuleCategory -from app.services.audit_engine_v2 import ( - _extract_table_name, - _extract_column_definitions, - _extract_index_definitions, - _is_snake_case, -) - -# ────────────────────────────────────────────── -# 辅助解析函数 -# ────────────────────────────────────────────────── - - -def _extract_create_table_comment(sql_upper: str) -> Optional[str]: - """从 CREATE TABLE 语句中提取表注释。""" - comment_match = re.search(r"COMMENT\s*=\s*['\"]([^'\"]*)['\"]", sql_upper, re.IGNORECASE) - if comment_match: - return comment_match.group(1).strip() - # 也匹配 COMMENT 'xxx' 格式 - comment_match = re.search(r"COMMENT\s+['\"]([^'\"]+)['\"]", sql_upper, re.IGNORECASE) - if comment_match: - return comment_match.group(1).strip() - return None - - -def _extract_charset_info(sql_upper: str) -> Dict[str, Optional[str]]: - """从 CREATE TABLE 语句中提取字符集和排序规则信息。""" - result: Dict[str, Optional[str]] = {"charset": None, "collation": None} - cs_match = re.search(r"DEFAULT\s+CHARSET\s*=\s*(\w+)", sql_upper, re.IGNORECASE) - if not cs_match: - cs_match = re.search(r"CHARSET\s*=\s*(\w+)", sql_upper, re.IGNORECASE) - if cs_match: - result["charset"] = cs_match.group(1).lower() - - col_match = re.search(r"COLLATE\s*=\s*(\w+)", sql_upper, re.IGNORECASE) - if col_match: - result["collation"] = col_match.group(1).lower() - return result - - -def _extract_alter_operations(sql_upper: str) -> List[Dict[str, str]]: - """从 ALTER TABLE 语句中提取操作列表。""" - operations: List[Dict[str, str]] = [] - # 匹配 ADD COLUMN, DROP COLUMN, MODIFY COLUMN, CHANGE COLUMN 等 - patterns = [ - (r"ADD\s+COLUMN\s+[`\"']?(\w+)", "ADD_COLUMN"), - (r"ADD\s+(?:INDEX|KEY)\s+[`\"']?(\w*)", "ADD_INDEX"), - (r"DROP\s+COLUMN\s+[`\"']?(\w+)", "DROP_COLUMN"), - (r"MODIFY\s+COLUMN\s+[`\"']?(\w+)", "MODIFY_COLUMN"), - (r"CHANGE\s+COLUMN\s+[`\"']?(\w+)", "CHANGE_COLUMN"), - (r"DROP\s+(?:INDEX|KEY)\s+[`\"']?(\w+)", "DROP_INDEX"), - (r"ADD\s+PRIMARY\s+KEY", "ADD_PK"), - (r"DROP\s+PRIMARY\s+KEY", "DROP_PK"), - ] - for pattern, op_type in patterns: - for match in re.finditer(pattern, sql_upper, re.IGNORECASE): - name = match.group(1) if match.lastindex and match.group(1) else "" - operations.append({"type": op_type, "name": name}) - return operations - - -def _has_where_clause(sql_upper: str) -> bool: - """检查SQL语句是否包含WHERE子句。""" - return bool(re.search(r"\bWHERE\b", sql_upper, re.IGNORECASE)) - - -def _has_limit_clause(sql_upper: str) -> bool: - """检查SQL语句是否包含LIMIT子句。""" - return bool(re.search(r"\bLIMIT\s+\d+", sql_upper, re.IGNORECASE)) - - -def _has_order_by(sql_upper: str) -> bool: - """检查SQL语句是否包含ORDER BY子句。""" - return bool(re.search(r"\bORDER\s+BY\b", sql_upper, re.IGNORECASE)) - - -def _extract_insert_columns(sql_upper: str) -> List[str]: - """从 INSERT 语句中提取插入的字段名列表。""" - cols: List[str] = [] - match = re.search(r"\bINSERT\b.+?\(([^)]+)\)", sql_upper, re.IGNORECASE | re.DOTALL) - if match: - inner = match.group(1) - for col in inner.split(","): - cols.append(col.strip().strip("`'\"")) - return cols - - -def _count_insert_rows(sql_clean: str) -> int: - """统计 INSERT 语句插入的行数(通过 VALUES 子句数量)。""" - values_count = len(re.findall(r"\bVALUES\s*\(", sql_clean, re.IGNORECASE)) - if values_count == 0: - # 尝试 INSERT ... SELECT 形式 - if re.search(r"\bINSERT\b.*\bSELECT\b", sql_clean, re.IGNORECASE | re.DOTALL): - return -1 # 表示无法确定的行数 - return max(values_count, 1) - - -def _is_ddl_statement(sql_upper: str) -> bool: - """判断是否为DDL语句。""" - return bool(re.search(r"\b(CREATE|ALTER|DROP|TRUNCATE)\b", sql_upper, re.IGNORECASE)) - - -def _is_dml_statement(sql_upper: str) -> bool: - """判断是否为DML语句。""" - return bool(re.search(r"\b(INSERT|UPDATE|DELETE|REPLACE)\b", sql_upper, re.IGNORECASE)) - - -def _extract_column_defaults(sql_upper: str) -> List[Dict[str, Any]]: - """从 CREATE TABLE 中提取字段默认值信息。""" - columns = _extract_column_definitions(sql_upper) - result: List[Dict[str, Any]] = [] - for col in columns: - has_default = "DEFAULT" in col["full"].upper() - is_nullable = "NOT NULL" not in col["full"].upper() - is_timestamp = col["type"].upper() in ("TIMESTAMP", "DATETIME") - result.append({ - "name": col["name"], - "type": col["type"], - "has_default": has_default, - "is_nullable": is_nullable, - "is_timestamp": is_timestamp, - "full": col["full"], - }) - return result - - -def _extract_primary_key_info(sql_upper: str) -> Optional[Dict[str, Any]]: - """从 CREATE TABLE 中提取主键信息。""" - # 内联主键定义 - pk_match = re.search(r"PRIMARY\s+KEY\s*\(([^)]+)\)", sql_upper, re.IGNORECASE) - if pk_match: - cols = [c.strip().strip("`'\"") for c in pk_match.group(1).split(",")] - return {"type": "inline", "columns": cols} - - # 单列主键如 `id` INT PRIMARY KEY - for col in _extract_column_definitions(sql_upper): - if "PRIMARY KEY" in col["full"].upper(): - return {"type": "column", "columns": [col["name"]], "column_def": col} - return None - - -def _count_alter_statements(sql_upper: str) -> int: - """统计 ALTER TABLE 语句数量。""" - return len(re.findall(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE)) - - -def _has_auto_increment(sql_upper: str) -> bool: - """检查是否包含 AUTO_INCREMENT。""" - return bool(re.search(r"\bAUTO_INCREMENT\b", sql_upper, re.IGNORECASE)) - - -def _extract_auto_increment_init(sql_upper: str) -> Optional[int]: - """提取 AUTO_INCREMENT 初始值。""" - match = re.search(r"AUTO_INCREMENT\s*=\s*(\d+)", sql_upper, re.IGNORECASE) - if match: - return int(match.group(1)) - return None - - -# ────────────────────────────────────────────── -# ══════════════════════════════════════════════ -# DDL 规则 (36条) — MYSQL-DDL-001 ~ MYSQL-DDL-036 -# ══════════════════════════════════════════════ -# ────────────────────────────────────────────── - - -# ── DDL-001: 强制表必须拥有表注释 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-001", - name="DDLCheckTableComment", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="CREATE TABLE 语句必须包含 COMMENT 表注释", - config={"enabled": True}, - message_template="表 '{table}' 缺少表注释,请添加 COMMENT='...'", -)) -def check_mysql_ddl_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制表必须拥有表注释。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - comment = _extract_create_table_comment(sql_upper) - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - if not comment: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name), - )) - - -# ── DDL-002: 强制表字段必须拥有列注释 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-002", - name="DDLCheckColumnComment", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="CREATE TABLE 中每个字段定义必须包含 COMMENT 注释", - config={"enabled": True}, - message_template="表 '{table}' 的字段 '{column}' 缺少列注释,请添加 COMMENT '...'", -)) -def check_mysql_ddl_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制表字段必须拥有列注释。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - paren_match = re.search(r"\((.*)\)", sql_upper, re.DOTALL) - if not paren_match: - return - - inner = paren_match.group(1) - parts = [] - depth = 0 - current = "" - for char in inner: - if char == "(": - depth += 1 - current += char - elif char == ")": - depth -= 1 - current += char - elif char == "," and depth == 0: - parts.append(current.strip()) - current = "" - else: - current += char - if current.strip(): - parts.append(current.strip()) - - for part in parts: - # 跳过约束定义 - if re.match(r"^(PRIMARY\s+KEY|FOREIGN\s+KEY|INDEX|KEY|UNIQUE|CONSTRAINT|CHECK)\b", part, re.IGNORECASE): - continue - col_match = re.match(r"[`\"']?(\w+)[`\"']?\s+(\w+)", part, re.IGNORECASE) - if col_match: - col_name = col_match.group(1) - if "COMMENT" not in part.upper(): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, column=col_name), - )) - - -# ── DDL-003: 强制非timestamp类型字段必须为NOT NULL ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-003", - name="DDLCheckColumnNullable", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="非 TIMESTAMP/DATETIME 类型字段必须定义为 NOT NULL", - config={"enabled": True}, - message_template="表 '{table}' 的字段 '{column}' (类型: {ctype}) 必须定义为 NOT NULL", -)) -def check_mysql_ddl_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制非timestamp类型字段必须为NOT NULL。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - paren_match = re.search(r"\((.*)\)", sql_upper, re.DOTALL) - if not paren_match: - return - - inner = paren_match.group(1) - parts = [] - depth = 0 - current = "" - for char in inner: - if char == "(": - depth += 1 - current += char - elif char == ")": - depth -= 1 - current += char - elif char == "," and depth == 0: - parts.append(current.strip()) - current = "" - else: - current += char - if current.strip(): - parts.append(current.strip()) - - for part in parts: - if re.match(r"^(PRIMARY\s+KEY|FOREIGN\s+KEY|INDEX|KEY|UNIQUE|CONSTRAINT|CHECK)\b", part, re.IGNORECASE): - continue - col_match = re.match(r"[`\"']?(\w+)[`\"']?\s+(\w+)", part, re.IGNORECASE) - if col_match: - col_name = col_match.group(1) - col_type = col_match.group(2).upper() - if col_type in ("TIMESTAMP", "DATETIME"): - continue - if "NOT NULL" not in part.upper(): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, column=col_name, ctype=col_type), - )) - - -# ── DDL-004: 强制非text,blob,json,timestamp类型字段必须拥有默认值 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-004", - name="DDLCheckColumnDefault", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="非 TEXT/BLOB/JSON/TIMESTAMP/DATETIME 类型字段必须指定 DEFAULT 值", - config={"enabled": True}, - message_template="表 '{table}' 的字段 '{column}' (类型: {ctype}) 缺少 DEFAULT 值", -)) -def check_mysql_ddl_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制非text,blob,json,timestamp类型字段必须拥有默认值。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - excluded_types = {"TEXT", "BLOB", "JSON", "TIMESTAMP", "DATETIME", "TINYTEXT", "MEDIUMTEXT", - "LONGTEXT", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB"} - - paren_match = re.search(r"\((.*)\)", sql_upper, re.DOTALL) - if not paren_match: - return - - inner = paren_match.group(1) - parts = [] - depth = 0 - current = "" - for char in inner: - if char == "(": - depth += 1 - current += char - elif char == ")": - depth -= 1 - current += char - elif char == "," and depth == 0: - parts.append(current.strip()) - current = "" - else: - current += char - if current.strip(): - parts.append(current.strip()) - - for part in parts: - if re.match(r"^(PRIMARY\s+KEY|FOREIGN\s+KEY|INDEX|KEY|UNIQUE|CONSTRAINT|CHECK)\b", part, re.IGNORECASE): - continue - col_match = re.match(r"[`\"']?(\w+)[`\"']?\s+(\w+)", part, re.IGNORECASE) - if col_match: - col_name = col_match.group(1) - col_type = col_match.group(2).upper() - if col_type in excluded_types: - continue - if "DEFAULT" not in part.upper() and "NOT NULL" in part.upper(): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, column=col_name, ctype=col_type), - )) - - -# ── DDL-005: 强制float/double类型变更为decimal类型 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-005", - name="DDLCheckFloatDouble", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="mysql", - description="禁止使用 FLOAT/DOUBLE 类型存储金额/数值数据,必须使用 DECIMAL 保证精度", - config={"enabled": True}, - message_template="表 '{table}' 的字段 '{column}' 使用了 {ctype} 类型,存在精度丢失风险,必须使用 DECIMAL 替代(如 DECIMAL(16,4))", -)) -def check_mysql_ddl_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 FLOAT/DOUBLE 类型,强制使用 DECIMAL 保证精度。""" - # 检查 CREATE TABLE - if re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - columns = _extract_column_definitions(sql_upper) - for col in columns: - if col["type"].upper() in ("FLOAT", "DOUBLE", "REAL"): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, column=col["name"], ctype=col["type"]), - )) - - # 检查 ALTER TABLE ADD/MODIFY/CHANGE COLUMN - alter_match = re.search(r"\bALTER\s+TABLE\s+[`\"']?(\w+)", sql_upper, re.IGNORECASE) - if alter_match: - table_name = alter_match.group(1) - # 匹配 ADD/MODIFY/CHANGE COLUMN 中出现的 FLOAT/DOUBLE 类型 - float_pattern = r"(?:ADD|MODIFY|CHANGE)\s+(?:COLUMN\s+)?[`\"']?(\w+)[`\"']?\s+(?:FLOAT|DOUBLE|REAL)\b" - for m in re.finditer(float_pattern, sql_upper, re.IGNORECASE): - col_name = m.group(1) - ctype = "FLOAT" if "FLOAT" in m.group(0).upper() else ("DOUBLE" if "DOUBLE" in m.group(0).upper() else "REAL") - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, column=col_name, ctype=ctype), - )) - - -# ── DDL-006: 强制自增列初始值为1 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-006", - name="DDLEnableAutoincrementInit", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="AUTO_INCREMENT 初始值必须为 1", - config={"enabled": True, "init_value": 1}, - message_template="表 '{table}' 的 AUTO_INCREMENT 初始值不为 {init},建议设置为 AUTO_INCREMENT=1", -)) -def check_mysql_ddl_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制自增列初始值为1。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - if not _has_auto_increment(sql_upper): - return - init_val = _extract_auto_increment_init(sql_upper) - expected = rule.config.get("init_value", 1) - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - if init_val is not None and init_val != expected: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, init=expected), - )) - - -# ── DDL-007/008: 强制必须拥有主键 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-007", - name="DDLEnablePrimaryKey", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="mysql", - description="CREATE TABLE 语句必须定义主键", - config={"enabled": True}, - message_template="表 '{table}' 缺少主键定义,必须添加 PRIMARY KEY", -)) -def check_mysql_ddl_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制必须拥有主键。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - pk_info = _extract_primary_key_info(sql_upper) - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - if pk_info is None: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name), - )) - - -# ── DDL-009: 强制主键名称为ID ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-009", - name="DDLPrimaryKeyMust", - category=RuleCategory.DDL, - severity=Severity.INFO, - db_type="mysql", - description="主键列名必须为 'id'(不区分大小写)", - config={"enabled": True}, - message_template="表 '{table}' 的主键列名为 '{pk_name}',建议命名为 'id'", -)) -def check_mysql_ddl_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制主键名称为ID。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - pk_info = _extract_primary_key_info(sql_upper) - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - if pk_info and len(pk_info["columns"]) == 1: - pk_name = pk_info["columns"][0] - if pk_name.lower() != "id": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, pk_name=pk_name), - )) - - -# ── DDL-010: 强制主键为自增列 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-010", - name="DDLEnableAutoIncrement", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="主键列必须使用 AUTO_INCREMENT", - config={"enabled": True}, - message_template="表 '{table}' 的主键列 '{pk_name}' 缺少 AUTO_INCREMENT", -)) -def check_mysql_ddl_010(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制主键为自增列。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - pk_info = _extract_primary_key_info(sql_upper) - if pk_info is None: - return - if pk_info["type"] == "column": - col_def = pk_info.get("column_def", {}) - pk_name = pk_info["columns"][0] - if "AUTO_INCREMENT" not in col_def.get("full", "").upper(): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, pk_name=pk_name), - )) - - -# ── DDL-011: 强制主键必须使用无符号标志unsigned ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-011", - name="DDLEnableAutoincrementUnsigned", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="主键整数类型必须使用 UNSIGNED 属性", - config={"enabled": True}, - message_template="表 '{table}' 的主键列 '{pk_name}' 建议使用 UNSIGNED 属性", -)) -def check_mysql_ddl_011(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制主键必须使用无符号标志unsigned。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - pk_info = _extract_primary_key_info(sql_upper) - if pk_info is None: - return - if pk_info["type"] == "column": - col_def = pk_info.get("column_def", {}) - pk_name = pk_info["columns"][0] - col_type = col_def.get("type", "").upper() - if col_type in ("INT", "BIGINT", "TINYINT", "SMALLINT", "MEDIUMINT"): - if "UNSIGNED" not in col_def.get("full", "").upper(): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, pk_name=pk_name), - )) - - -# ── DDL-012: 基于主键回滚 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-012", - name="PRIRollBack", - category=RuleCategory.DDL, - severity=Severity.INFO, - db_type="mysql", - description="建议基于主键进行数据变更回滚操作", - config={"enabled": True}, - message_template="建议在回滚操作中使用主键进行定位", -)) -def check_mysql_ddl_012(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """基于主键回滚建议。""" - # 这是一个建议性规则,在 DELETE/UPDATE 语句中提示 - if not _is_dml_statement(sql_upper): - return - pk_info = _extract_primary_key_info(sql_upper) - if pk_info is None and _is_dml_statement(sql_upper): - # 简单提示:DML操作建议基于主键 - if re.search(r"\b(DELETE|UPDATE)\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ── DDL-013: 开启索引名称规范(索引名必须以idx_为开头) ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-013", - name="DDLIndexNameSpec", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="普通索引名称必须以 'idx_' 为前缀,唯一索引以 'uniq_' 为前缀", - config={"enabled": True, "index_prefix": "idx_", "unique_prefix": "uniq_"}, - message_template="表 '{table}' 的索引 '{idx_name}' 命名不规范,普通索引应以 '{prefix}' 开头", -)) -def check_mysql_ddl_013(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """开启索引名称规范。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - idx_prefix = rule.config.get("index_prefix", "idx_") - uniq_prefix = rule.config.get("unique_prefix", "uniq_") - indexes = _extract_index_definitions(sql_upper) - for idx in indexes: - if idx["type"] == "INDEX" and not idx["name"].lower().startswith(idx_prefix.lower()): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format( - table=table_name, idx_name=idx["name"] or "(unnamed)", prefix=idx_prefix, - ), - )) - - -# ── DDL-014: 开启MySQL关键词检查 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-014", - name="CheckIdentifier", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="mysql", - description="表名和字段名不能是MySQL保留关键字", - config={"enabled": True}, - message_template="表 '{table}' 中的标识符 '{identifier}' 是MySQL保留关键字,请修改", -)) -def check_mysql_ddl_014(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """开启MySQL关键词检查。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - # MySQL保留关键字列表(常用部分) - mysql_keywords = { - "SELECT", "INSERT", "UPDATE", "DELETE", "FROM", "WHERE", "ORDER", "GROUP", - "BY", "HAVING", "LIMIT", "OFFSET", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", - "ON", "AS", "AND", "OR", "NOT", "NULL", "TRUE", "FALSE", "CREATE", "DROP", - "ALTER", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", "PRIMARY", "KEY", - "FOREIGN", "REFERENCES", "UNIQUE", "CHECK", "DEFAULT", "AUTO_INCREMENT", - "UNSIGNED", "CHARSET", "COLLATE", "ENGINE", "COMMENT", "INT", "VARCHAR", - "TEXT", "BLOB", "DATE", "TIME", "DATETIME", "TIMESTAMP", "FLOAT", "DOUBLE", - "DECIMAL", "SHOW", "USE", "DESC", "EXPLAIN", "SET", "COMMIT", "ROLLBACK", - } - columns = _extract_column_definitions(sql_upper) - if table_name.upper() in mysql_keywords: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, identifier=table_name), - )) - for col in columns: - if col["name"].upper() in mysql_keywords: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, identifier=col["name"]), - )) - - -# ── DDL-015: 允许跨库表迁移 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-015", - name="DDLEnableAcrossDBRename", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="mysql", - description="默认不允许跨库RENAME TABLE操作", - config={"enabled": False}, - message_template="禁止跨库RENAME TABLE操作: {detail}", -)) -def check_mysql_ddl_015(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许跨库表迁移(默认关闭)。""" - if rule.config.get("enabled", False): - return - if not re.search(r"\bRENAME\s+TABLE\b", sql_upper, re.IGNORECASE): - return - # 检查是否包含数据库名前缀 (db.table) - if re.search(r"`?\w+`?\.`?\w+`?\s+TO\s+`?\w+`?\.`?\w+`?", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(detail=sql_clean[:80]), - )) - - -# ── DDL-016: 允许删除表 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-016", - name="DDLEnableDropTable", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="mysql", - description="默认不允许 DROP TABLE 操作", - config={"enabled": False}, - message_template="禁止 DROP TABLE 操作,表 '{table}' 将被删除", -)) -def check_mysql_ddl_016(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许删除表(默认关闭)。""" - if rule.config.get("enabled", False): - return - match = re.search(r"\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?[`\"']?(\w+)[`\"']?", sql_upper, re.IGNORECASE) - if match: - table_name = match.group(1) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name), - )) - - -# ── DDL-017: 允许删除库 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-017", - name="DDLEnableDropDatabase", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="mysql", - description="默认不允许 DROP DATABASE 操作", - config={"enabled": False}, - message_template="禁止 DROP DATABASE 操作,数据库 '{db}' 将被删除", -)) -def check_mysql_ddl_017(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许删除库(默认关闭)。""" - if rule.config.get("enabled", False): - return - match = re.search(r"\bDROP\s+DATABASE\s+(?:IF\s+EXISTS\s+)?[`\"']?(\w+)[`\"']?", sql_upper, re.IGNORECASE) - if match: - db_name = match.group(1) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(db=db_name), - )) - - -# ── DDL-018: 允许主键类型为非int/bigint ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-018", - name="DDLAllowPRINotInt", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="默认要求主键类型必须为 INT 或 BIGINT", - config={"enabled": False}, - message_template="表 '{table}' 的主键类型为 '{pk_type}',建议使用 INT 或 BIGINT", -)) -def check_mysql_ddl_018(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许主键类型为非int/bigint(默认不允许)。""" - if rule.config.get("enabled", False): - return - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - pk_info = _extract_primary_key_info(sql_upper) - if pk_info is None: - return - if pk_info["type"] == "column": - col_def = pk_info.get("column_def", {}) - pk_type = col_def.get("type", "").upper() - if pk_type not in ("INT", "BIGINT", "TINYINT", "SMALLINT", "MEDIUMINT"): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, pk_type=pk_type), - )) - - -# ── DDL-019: 允许索引名为空 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-019", - name="DDLEnableNullIndexName", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="默认要求索引必须命名", - config={"enabled": False}, - message_template="表 '{table}' 存在未命名的索引,建议为所有索引指定名称", -)) -def check_mysql_ddl_019(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许索引名为空(默认不允许)。""" - if rule.config.get("enabled", False): - return - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - indexes = _extract_index_definitions(sql_upper) - for idx in indexes: - if not idx["name"] and idx["type"] not in ("PRIMARY", "FOREIGN"): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name), - )) - - -# ── DDL-020: 允许单个工单提交多条DDL语句 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-020", - name="DDLMultiToCommit", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="默认不允许单个工单提交多条DDL语句", - config={"enabled": False, "max_statements": 1}, - message_template="单个工单包含 {count} 条DDL语句,建议每条工单只提交1条DDL", -)) -def check_mysql_ddl_020(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许单个工单提交多条DDL语句(默认不允许)。""" - if rule.config.get("enabled", False): - return - ddl_count = 0 - # 按分号分割统计DDL语句数 - statements = [s.strip() for s in sql_clean.split(";") if s.strip()] - for stmt in statements: - if re.search(r"\b(CREATE|ALTER|DROP|TRUNCATE)\b", stmt, re.IGNORECASE): - ddl_count += 1 - max_stmt = rule.config.get("max_statements", 1) - if ddl_count > max_stmt: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(count=ddl_count), - )) - - -# ── DDL-021: DDL单个工单允许多alter语句提交 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-021", - name="DDLAllowMultiAlter", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="默认不允许单个工单包含多条 ALTER TABLE 语句", - config={"enabled": False, "max_alter": 1}, - message_template="单个工单包含 {count} 条 ALTER TABLE 语句,建议拆分为多个工单", -)) -def check_mysql_ddl_021(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """DDL单个工单允许多alter语句提交(默认不允许)。""" - if rule.config.get("enabled", False): - return - alter_count = _count_alter_statements(sql_upper) - max_alter = rule.config.get("max_alter", 1) - if alter_count > max_alter: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(count=alter_count), - )) - - -# ── DDL-022: 允许字段进行类型转换 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-022", - name="DDLAllowColumnType", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="默认不允许 ALTER TABLE MODIFY/CHANGE 进行字段类型转换", - config={"enabled": False}, - message_template="ALTER TABLE 包含字段类型变更操作,请确认变更的必要性和兼容性", -)) -def check_mysql_ddl_022(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许字段进行类型转换(默认不允许)。""" - if rule.config.get("enabled", False): - return - if not re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - return - operations = _extract_alter_operations(sql_upper) - for op in operations: - if op["type"] in ("MODIFY_COLUMN", "CHANGE_COLUMN"): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ── DDL-023: 允许使用after/first ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-023", - name="DDLAllowChangeColumnPosition", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="默认不建议使用 AFTER/FIRST 调整字段位置(引起全表重建)", - config={"enabled": False}, - message_template="ALTER TABLE 使用了 AFTER/FIRST 调整字段位置,可能引起全表重建", -)) -def check_mysql_ddl_023(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许使用after/first(默认不建议)。""" - if rule.config.get("enabled", False): - return - if not re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - return - if re.search(r"\b(AFTER|FIRST)\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ── DDL-024: 允许创建视图 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-024", - name="AllowCreateView", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="默认不允许创建视图", - config={"enabled": False}, - message_template="禁止创建视图: '{view_name}'", -)) -def check_mysql_ddl_024(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许创建视图(默认不允许)。""" - if rule.config.get("enabled", False): - return - match = re.search(r"\bCREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+[`\"']?(\w+)[`\"']?", sql_upper, re.IGNORECASE) - if match: - view_name = match.group(1) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(view_name=view_name), - )) - - -# ── DDL-025: 允许创建外键 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-025", - name="DDLEnableForeignKey", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="mysql", - description="默认不允许创建外键约束", - config={"enabled": False}, - message_template="禁止创建外键约束: '{fk_name}',建议在应用层实现外键逻辑", -)) -def check_mysql_ddl_025(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许创建外键(默认不允许)。""" - if rule.config.get("enabled", False): - return - if re.search(r"\bFOREIGN\s+KEY\b", sql_upper, re.IGNORECASE): - fk_match = re.search(r"FOREIGN\s+KEY\s+[`\"']?(\w*)[`\"']?", sql_upper, re.IGNORECASE) - fk_name = fk_match.group(1) if fk_match and fk_match.group(1) else "(unnamed)" - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(fk_name=fk_name), - )) - - -# ── DDL-026: 允许创建分区表 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-026", - name="AllowCreatePartition", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="默认不建议创建分区表", - config={"enabled": False}, - message_template="创建分区表需要谨慎评估,请确认分区策略的合理性", -)) -def check_mysql_ddl_026(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许创建分区表(默认不建议)。""" - if rule.config.get("enabled", False): - return - if re.search(r"\bPARTITION\s+BY\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ── DDL-027: 允许添加bit,enum,set类型字段 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-027", - name="AllowSpecialType", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="默认不建议使用 BIT/ENUM/SET 类型", - config={"enabled": False}, - message_template="表 '{table}' 使用了特殊类型 '{sp_type}',建议评估替代方案", -)) -def check_mysql_ddl_027(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许添加bit,enum,set类型字段(默认不建议)。""" - if rule.config.get("enabled", False): - return - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - columns = _extract_column_definitions(sql_upper) - special_types = {"BIT", "ENUM", "SET"} - for col in columns: - if col["type"].upper() in special_types: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, sp_type=col["type"]), - )) - - -# ── DDL-028: 指定表名前缀 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-028", - name="DDLTablePrefix", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="表名必须以指定前缀开头", - config={"enabled": True, "prefix": "t_"}, - message_template="表名 '{table}' 不符合前缀规范,必须以 '{prefix}' 开头", -)) -def check_mysql_ddl_028(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """指定表名前缀。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - prefix = rule.config.get("prefix", "t_") - table_name = _extract_table_name(sql_upper, sql_clean) - if table_name and not table_name.lower().startswith(prefix.lower()): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, prefix=prefix), - )) - - -# ── DDL-029: 允许的Collate范围 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-029", - name="SupportCollation", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="表字符集排序规则必须在允许的范围内", - config={"enabled": True, "allowed_collations": ["utf8mb4_general_ci", "utf8mb4_unicode_ci"]}, - message_template="表 '{table}' 使用了不允许的排序规则 '{collation}',允许的规则: {allowed}", -)) -def check_mysql_ddl_029(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许的Collate范围。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - charset_info = _extract_charset_info(sql_upper) - collation = charset_info.get("collation") - allowed = rule.config.get("allowed_collations", []) - if collation and allowed and collation.lower() not in [a.lower() for a in allowed]: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format( - table=table_name, collation=collation, allowed=", ".join(allowed), - ), - )) - - -# ── DDL-030: 允许的Charset范围 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-030", - name="SupportCharset", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="表字符集必须在允许的范围内", - config={"enabled": True, "allowed_charsets": ["utf8mb4"]}, - message_template="表 '{table}' 使用了不允许的字符集 '{charset}',允许的字符集: {allowed}", -)) -def check_mysql_ddl_030(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许的Charset范围。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - charset_info = _extract_charset_info(sql_upper) - charset = charset_info.get("charset") - allowed = rule.config.get("allowed_charsets", ["utf8mb4"]) - if charset and allowed and charset.lower() not in [a.lower() for a in allowed]: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format( - table=table_name, charset=charset, allowed=", ".join(allowed), - ), - )) - - -# ── DDL-031: 建表必须拥有的字段 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-031", - name="MustHaveColumns", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="建表时必须包含指定的字段", - config={"enabled": True, "required_columns": ["id", "created_at", "updated_at"]}, - message_template="表 '{table}' 缺少必须字段 '{column}'", -)) -def check_mysql_ddl_031(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """建表必须拥有的字段。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - required = rule.config.get("required_columns", []) - columns = _extract_column_definitions(sql_upper) - column_names = {c["name"].lower() for c in columns} - for req_col in required: - if req_col.lower() not in column_names: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, column=req_col), - )) - - -# ── DDL-032: 单个索引指定字段上限 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-032", - name="DDLMaxKeyParts", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="单个索引最多允许包含的字段数", - config={"enabled": True, "max_parts": 8}, - message_template="表 '{table}' 的索引 '{idx_name}' 包含 {count} 个字段,超过上限 {max_parts}", -)) -def check_mysql_ddl_032(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """单个索引指定字段上限。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - max_parts = rule.config.get("max_parts", 8) - indexes = _extract_index_definitions(sql_upper) - for idx in indexes: - col_count = len(idx.get("columns", [])) - if col_count > max_parts: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format( - table=table_name, idx_name=idx["name"] or "(unnamed)", - count=col_count, max_parts=max_parts, - ), - )) - - -# ── DDL-033: 单个表最多允许几个索引 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-033", - name="DDLMaxKey", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="单个表最多允许创建的索引数量", - config={"enabled": True, "max_index": 20}, - message_template="表 '{table}' 包含 {count} 个索引,超过上限 {max_idx}", -)) -def check_mysql_ddl_033(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """单个表最多允许几个索引。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - max_idx = rule.config.get("max_index", 12) - indexes = _extract_index_definitions(sql_upper) - idx_count = len([i for i in indexes if i["type"] != "FOREIGN"]) - if idx_count > max_idx: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, count=idx_count, max_idx=max_idx), - )) - - -# ── DDL-034: DDL最大影响行数 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-034", - name="MaxDDLAffectRows", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="DDL操作影响的最大行数限制(0表示不限制)", - config={"enabled": False, "max_rows": 0}, - message_template="DDL操作可能影响 {rows} 行,超过上限 {max_rows}", -)) -def check_mysql_ddl_034(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """DDL最大影响行数(此规则作为配置项存在,实际行数需运行时检查)。""" - max_rows = rule.config.get("max_rows", 0) - if max_rows == 0: - return - # 此规则的实际检查需要数据库连接获取表行数 - # 这里仅做语句级别的标记提示 - if re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=f"ALTER TABLE '{table_name}' 需要确认影响行数不超过 {max_rows}", - )) - - -# ── DDL-035: char字段最大长度 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-035", - name="DDLMaxCharLength", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="CHAR 类型字段的最大长度限制", - config={"enabled": True, "max_length": 255}, - message_template="表 '{table}' 的字段 '{column}' CHAR({length}) 超过最大长度限制 {max_len}", -)) -def check_mysql_ddl_035(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """char字段最大长度。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - max_len = rule.config.get("max_length", 10) - paren_match = re.search(r"\((.*)\)", sql_upper, re.DOTALL) - if not paren_match: - return - inner = paren_match.group(1) - parts = [] - depth = 0 - current = "" - for char in inner: - if char == "(": - depth += 1 - current += char - elif char == ")": - depth -= 1 - current += char - elif char == "," and depth == 0: - parts.append(current.strip()) - current = "" - else: - current += char - if current.strip(): - parts.append(current.strip()) - - for part in parts: - if re.match(r"^(PRIMARY\s+KEY|FOREIGN\s+KEY|INDEX|KEY|UNIQUE|CONSTRAINT|CHECK)\b", part, re.IGNORECASE): - continue - char_match = re.search(r"[`\"']?(\w+)[`\"']?\s+CHAR\s*\((\d+)\)", part, re.IGNORECASE) - if char_match: - col_name = char_match.group(1) - length = int(char_match.group(2)) - if length > max_len: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format( - table=table_name, column=col_name, length=length, max_len=max_len, - ), - )) - - -# ── DDL-036: 表名最大长度限制 ── -@register_rule(AuditRuleV2( - code="MYSQL-DDL-036", - name="MaxTableNameLen", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="表名最大长度限制", - config={"enabled": True, "max_length": 45}, - message_template="表名 '{table}' 长度为 {length},超过最大长度限制 {max_len}", -)) -def check_mysql_ddl_036(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """表名最大长度限制。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) - if not table_name: - return - max_len = rule.config.get("max_length", 45) - if len(table_name) > max_len: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, length=len(table_name), max_len=max_len), - )) - - -# ────────────────────────────────────────────── -# ══════════════════════════════════════════════ -# DML 规则 (9条) — MYSQL-DML-001 ~ MYSQL-DML-009 -# ══════════════════════════════════════════════ -# ────────────────────────────────────────────── - - -# ── DML-001: Insert最大插入行数上限 ── -@register_rule(AuditRuleV2( - code="MYSQL-DML-001", - name="DMLMaxInsertRows", - category=RuleCategory.DML, - severity=Severity.WARNING, - db_type="mysql", - description="INSERT 语句单次插入的最大行数限制", - config={"enabled": True, "max_rows": 500}, - message_template="INSERT 语句插入 {count} 行,超过最大限制 {max_rows} 行,请分批插入", -)) -def check_mysql_dml_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Insert最大插入行数上限。""" - if not re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE): - return - max_rows = rule.config.get("max_rows", 500) - row_count = _count_insert_rows(sql_clean) - if row_count == -1: - # INSERT ... SELECT 无法确定行数 - return - if row_count > max_rows: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(count=row_count, max_rows=max_rows), - )) - - -# ── DML-002: 允许update/insert语句使用limit关键字 ── -@register_rule(AuditRuleV2( - code="MYSQL-DML-002", - name="DMLAllowLimitSTMT", - category=RuleCategory.DML, - severity=Severity.ERROR, - db_type="mysql", - description="默认不允许 UPDATE/DELETE 语句使用 LIMIT 关键字", - config={"enabled": False}, - message_template="{stmt_type} 语句禁止使用 LIMIT 关键字", -)) -def check_mysql_dml_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许update/insert语句使用limit关键字(默认不允许)。""" - if rule.config.get("enabled", False): - return - if not re.search(r"\b(UPDATE|DELETE)\b", sql_upper, re.IGNORECASE): - return - if _has_limit_clause(sql_upper): - stmt_type = "UPDATE" if re.search(r"\bUPDATE\b", sql_upper, re.IGNORECASE) else "DELETE" - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(stmt_type=stmt_type), - )) - - -# ── DML-003: 允许insert语句插入Null值 ── -@register_rule(AuditRuleV2( - code="MYSQL-DML-003", - name="DMLAllowInsertNull", - category=RuleCategory.DML, - severity=Severity.WARNING, - db_type="mysql", - description="默认不建议 INSERT 语句显式插入 NULL 值", - config={"enabled": False}, - message_template="INSERT 语句中包含 NULL 值插入,建议确认必要性", -)) -def check_mysql_dml_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许insert语句插入Null值(默认不建议)。""" - if rule.config.get("enabled", False): - return - if not re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE): - return - # 匹配 VALUES (...NULL...) 中的 NULL - values_section = re.search(r"\bVALUES\s+\((.*?)\)", sql_upper, re.IGNORECASE | re.DOTALL) - if values_section and "NULL" in values_section.group(1).upper(): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ── DML-004: 不允许隐式转换 ── -@register_rule(AuditRuleV2( - code="MYSQL-DML-004", - name="DDLImplicitTypeConversion", - category=RuleCategory.DML, - severity=Severity.WARNING, - db_type="mysql", - description="WHERE 条件中避免隐式类型转换", - config={"enabled": True}, - message_template="WHERE 条件中字段 '{column}' 可能存在隐式类型转换,请确保类型一致", -)) -def check_mysql_dml_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """不允许隐式转换(检测WHERE中可能的类型不匹配)。""" - if not _has_where_clause(sql_upper): - return - # 简单检测:数字字段与字符串值比较,或函数字段使用 - where_match = re.search(r"\bWHERE\b(.+?)(?:\s+ORDER\s+BY|\s+GROUP\s+BY|\s+LIMIT|\s+HAVING|$)", - sql_upper, re.IGNORECASE | re.DOTALL) - if not where_match: - return - where_clause = where_match.group(1) - # 检测函数包裹字段(常见隐式转换场景) - func_patterns = [ - r"\b(CONVERT|CAST)\s*\(", - r"\bDATE\s*\(", - r"\bYEAR\s*\(", - r"\bMONTH\s*\(", - ] - for pattern in func_patterns: - if re.search(pattern, where_clause, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(column="(函数包装字段)"), - )) - - -# ── DML-005: 检查Insert语句中插入的字段名是否存在 ── -@register_rule(AuditRuleV2( - code="MYSQL-DML-005", - name="DMLInsertColumns", - category=RuleCategory.DML, - severity=Severity.ERROR, - db_type="mysql", - description="INSERT 语句必须显式指定字段名", - config={"enabled": True}, - message_template="INSERT 语句必须显式指定插入的字段名,禁止使用 INSERT INTO table VALUES (...)", -)) -def check_mysql_dml_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """检查Insert语句中插入的字段名是否存在。""" - if not re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE): - return - insert_cols = _extract_insert_columns(sql_upper) - if not insert_cols: - # 检查是否是 INSERT INTO table VALUES (没有指定字段) - if re.search(r"\bINSERT\b.+?\bVALUES\b", sql_upper, re.IGNORECASE) and \ - not re.search(r"\bINSERT\b.+?\([^)]+\)\s*\bVALUES\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ── DML-006: 强制DML语句必须拥有where条件 ── -@register_rule(AuditRuleV2( - code="MYSQL-DML-006", - name="DMLWhere", - category=RuleCategory.DML, - severity=Severity.ERROR, - db_type="mysql", - description="UPDATE/DELETE 语句必须包含 WHERE 条件", - config={"enabled": True}, - message_template="{stmt_type} 语句必须包含 WHERE 条件,禁止全表操作", -)) -def check_mysql_dml_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制DML语句必须拥有where条件。""" - if not re.search(r"\b(UPDATE|DELETE)\b", sql_upper, re.IGNORECASE): - return - if not _has_where_clause(sql_upper): - stmt_type = "UPDATE" if re.search(r"\bUPDATE\b", sql_upper, re.IGNORECASE) else "DELETE" - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(stmt_type=stmt_type), - )) - - -# ── DML-007: 禁止DML语句使用Order by子句 ── -@register_rule(AuditRuleV2( - code="MYSQL-DML-007", - name="DMLOrder", - category=RuleCategory.DML, - severity=Severity.WARNING, - db_type="mysql", - description="UPDATE/DELETE 语句禁止使用 ORDER BY 子句", - config={"enabled": True}, - message_template="{stmt_type} 语句禁止使用 ORDER BY 子句", -)) -def check_mysql_dml_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止DML语句使用Order by子句。""" - if not re.search(r"\b(UPDATE|DELETE)\b", sql_upper, re.IGNORECASE): - return - if _has_order_by(sql_upper): - stmt_type = "UPDATE" if re.search(r"\bUPDATE\b", sql_upper, re.IGNORECASE) else "DELETE" - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(stmt_type=stmt_type), - )) - - -# ── DML-008: 禁止DML语句使用Select子句 ── -@register_rule(AuditRuleV2( - code="MYSQL-DML-008", - name="DMLSelect", - category=RuleCategory.DML, - severity=Severity.ERROR, - db_type="mysql", - description="UPDATE/DELETE 语句禁止使用子查询(SELECT 子句)", - config={"enabled": True}, - message_template="{stmt_type} 语句禁止使用 SELECT 子查询", -)) -def check_mysql_dml_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止DML语句使用Select子句。""" - if not re.search(r"\b(UPDATE|DELETE)\b", sql_upper, re.IGNORECASE): - return - if re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE): - stmt_type = "UPDATE" if re.search(r"\bUPDATE\b", sql_upper, re.IGNORECASE) else "DELETE" - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(stmt_type=stmt_type), - )) - - -# ── DML-009: DML最大影响行数 ── -@register_rule(AuditRuleV2( - code="MYSQL-DML-009", - name="MaxAffectRows", - category=RuleCategory.DML, - severity=Severity.WARNING, - db_type="mysql", - description="DML 操作影响的最大行数限制(0表示不限制)", - config={"enabled": False, "max_rows": 0}, - message_template="DML操作可能影响 {rows} 行,超过上限 {max_rows}", -)) -def check_mysql_dml_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """DML最大影响行数(此规则作为配置项存在,实际行数需运行时检查)。""" - max_rows = rule.config.get("max_rows", 0) - if max_rows == 0: - return - # 仅作标记提示,实际行数需要在执行时检查 - if _is_dml_statement(sql_upper): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=f"DML语句需要确认影响行数不超过 {max_rows}", - )) - - -# ────────────────────────────────────────────── -# ══════════════════════════════════════════════ -# Online-DDL 规则 (3条) — MYSQL-OSC-001 ~ MYSQL-OSC-003 -# ══════════════════════════════════════════════ -# ────────────────────────────────────────────── - - -# ── OSC-001: 开启在线表变更工具 ── -@register_rule(AuditRuleV2( - code="MYSQL-OSC-001", - name="IsOSC", - category=RuleCategory.DDL, - severity=Severity.INFO, - db_type="mysql", - description="建议使用在线表变更工具(pt-osc/gh-ost)执行大表DDL", - config={"enabled": True, "tool": "gh-ost"}, - message_template="表 '{table}' 的DDL操作建议使用 {tool} 在线变更工具执行", -)) -def check_mysql_osc_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """开启在线表变更工具建议。""" - if not re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - tool = rule.config.get("tool", "gh-ost") - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, tool=tool), - )) - - -# ── OSC-002: 表体积大于该值后触发在线表变更同步 ── -@register_rule(AuditRuleV2( - code="MYSQL-OSC-002", - name="OscSize", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="mysql", - description="表体积超过阈值(MB)后必须使用在线变更工具", - config={"enabled": True, "size_mb": 100}, - message_template="表 '{table}' 预估体积 {size}MB,超过阈值 {threshold}MB,必须使用在线变更工具", -)) -def check_mysql_osc_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """表体积大于阈值触发在线表变更建议。""" - if not re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - threshold = rule.config.get("size_mb", 100) - # 此规则实际需要在数据库中查询表大小,这里仅作提示 - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=f"ALTER TABLE '{table_name}' 请确认表体积是否超过 {threshold}MB,大表请使用在线变更工具", - )) - - -# ── OSC-003: 同步工具参数配置 ── -@register_rule(AuditRuleV2( - code="MYSQL-OSC-003", - name="OSCExpr", - category=RuleCategory.DDL, - severity=Severity.INFO, - db_type="mysql", - description="在线变更工具参数配置检查", - config={"enabled": True, "chunk_size": 1000, "max_load": "Threads_running=80"}, - message_template="请确认在线变更工具参数: chunk_size={chunk_size}, max_load={max_load}", -)) -def check_mysql_osc_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """同步工具参数配置检查。""" - if not re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - return - chunk_size = rule.config.get("chunk_size", 1000) - max_load = rule.config.get("max_load", "Threads_running=80") - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(chunk_size=chunk_size, max_load=max_load), - )) - - -# ────────────────────────────────────────────── -# ══════════════════════════════════════════════ -# 命名规则 (5条) — MYSQL-NAM-001 ~ MYSQL-NAM-005 -# ══════════════════════════════════════════════ -# ────────────────────────────────────────────── - - -# ── NAM-001: 表名小写下划线规范 ── -@register_rule(AuditRuleV2( - code="MYSQL-NAM-001", - name="TableNamingCase", - category=RuleCategory.NAMING, - severity=Severity.WARNING, - db_type="mysql", - description="表名必须使用小写下划线命名法(snake_case)", - config={"enabled": True}, - message_template="表名 '{table}' 不符合命名规范,必须使用小写下划线命名(snake_case)", -)) -def check_mysql_nam_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """表名小写下划线规范。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) - if table_name and not _is_snake_case(table_name): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name), - )) - - -# ── NAM-002: 字段名小写下划线规范 ── -@register_rule(AuditRuleV2( - code="MYSQL-NAM-002", - name="ColumnNamingCase", - category=RuleCategory.NAMING, - severity=Severity.WARNING, - db_type="mysql", - description="字段名必须使用小写下划线命名法(snake_case)", - config={"enabled": True}, - message_template="表 '{table}' 的字段 '{column}' 不符合命名规范,必须使用小写下划线命名(snake_case)", -)) -def check_mysql_nam_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """字段名小写下划线规范。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - columns = _extract_column_definitions(sql_upper) - for col in columns: - if not _is_snake_case(col["name"]): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, column=col["name"]), - )) - - -# ── NAM-003: 索引名小写下划线规范 ── -@register_rule(AuditRuleV2( - code="MYSQL-NAM-003", - name="IndexNamingCase", - category=RuleCategory.NAMING, - severity=Severity.WARNING, - db_type="mysql", - description="索引名必须使用小写下划线命名法", - config={"enabled": True}, - message_template="表 '{table}' 的索引 '{idx_name}' 不符合命名规范,必须使用小写下划线命名", -)) -def check_mysql_nam_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """索引名小写下划线规范。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - indexes = _extract_index_definitions(sql_upper) - for idx in indexes: - if idx["name"] and not _is_snake_case(idx["name"]): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, idx_name=idx["name"]), - )) - - -# ── NAM-004: 表名数字开头检查 ── -@register_rule(AuditRuleV2( - code="MYSQL-NAM-004", - name="TableNamingNoDigitPrefix", - category=RuleCategory.NAMING, - severity=Severity.WARNING, - db_type="mysql", - description="表名不能以数字开头", - config={"enabled": True}, - message_template="表名 '{table}' 不能以数字开头", -)) -def check_mysql_nam_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """表名数字开头检查。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) - if table_name and re.match(r"^\d", table_name): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name), - )) - - -# ── NAM-005: 字段名长度限制 ── -@register_rule(AuditRuleV2( - code="MYSQL-NAM-005", - name="MaxColumnNameLength", - category=RuleCategory.NAMING, - severity=Severity.WARNING, - db_type="mysql", - description="字段名最大长度限制", - config={"enabled": True, "max_length": 30}, - message_template="表 '{table}' 的字段 '{column}' 名长度为 {length},超过限制 {max_len}", -)) -def check_mysql_nam_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """字段名长度限制。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - max_len = rule.config.get("max_length", 30) - columns = _extract_column_definitions(sql_upper) - for col in columns: - if len(col["name"]) > max_len: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format( - table=table_name, column=col["name"], length=len(col["name"]), max_len=max_len, - ), - )) - - -# ────────────────────────────────────────────── -# ══════════════════════════════════════════════ -# 性能规则 (5条) — MYSQL-PERF-001 ~ MYSQL-PERF-005 -# ══════════════════════════════════════════════ -# ────────────────────────────────────────────── - - -# ── PERF-001: 禁止SELECT * ── -@register_rule(AuditRuleV2( - code="MYSQL-PERF-001", - name="NoSelectAll", - category=RuleCategory.PERFORMANCE, - severity=Severity.ERROR, - db_type="mysql", - description="禁止 SELECT * 查询,必须显式指定字段", - config={"enabled": True}, - message_template="禁止使用 SELECT *,请显式指定查询字段", -)) -def check_mysql_perf_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止SELECT *。""" - if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE): - return - if re.search(r"\bSELECT\s+\*\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ── PERF-002: 缺少LIMIT的大查询检查 ── -@register_rule(AuditRuleV2( - code="MYSQL-PERF-002", - name="SelectLimitRequired", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="mysql", - description="SELECT 查询建议添加 LIMIT 子句", - config={"enabled": True, "max_limit": 1000}, - message_template="SELECT 查询缺少 LIMIT 子句,建议添加 LIMIT {max_limit}", -)) -def check_mysql_perf_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """缺少LIMIT的大查询检查。""" - if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE): - return - if _is_dml_statement(sql_upper): - return - if not _has_limit_clause(sql_upper): - max_limit = rule.config.get("max_limit", 1000) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(max_limit=max_limit), - )) - - -# ── PERF-003: 全表扫描风险 — WHERE条件缺少索引字段 ── -@register_rule(AuditRuleV2( - code="MYSQL-PERF-003", - name="FullTableScanRisk", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="mysql", - description="UPDATE/DELETE 的 WHERE 条件应尽量使用索引字段", - config={"enabled": True}, - message_template="{stmt_type} 语句 WHERE 条件 '{where_clause}' 可能无法使用索引,存在全表扫描风险", -)) -def check_mysql_perf_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """全表扫描风险检查。""" - if not re.search(r"\b(UPDATE|DELETE|SELECT)\b", sql_upper, re.IGNORECASE): - return - if not _has_where_clause(sql_upper): - return - # 检测 WHERE 中使用函数或 LIKE '%xxx%' 等无法使用索引的模式 - where_match = re.search(r"\bWHERE\b(.+?)(?:\s+ORDER\s+BY|\s+GROUP\s+BY|\s+LIMIT|\s+HAVING|$)", - sql_upper, re.IGNORECASE | re.DOTALL) - if not where_match: - return - where_clause = where_match.group(1).strip() - # 检测 LIKE '%xxx' 或 LIKE '%xxx%' - if re.search(r"\bLIKE\s+['\"]%", where_clause, re.IGNORECASE): - stmt_type_match = re.search(r"\b(UPDATE|DELETE|SELECT)\b", sql_upper, re.IGNORECASE) - stmt_type = stmt_type_match.group(1).upper() if stmt_type_match else "SQL" - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(stmt_type=stmt_type, where_clause=where_clause[:50]), - )) - - -# ── PERF-004: 大IN子句检查 ── -@register_rule(AuditRuleV2( - code="MYSQL-PERF-004", - name="LargeInClause", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="mysql", - description="IN 子句中的值数量不应超过阈值", - config={"enabled": True, "max_in_values": 100}, - message_template="IN 子句包含 {count} 个值,超过建议上限 {max_in}", -)) -def check_mysql_perf_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """大IN子句检查。""" - max_in = rule.config.get("max_in_values", 100) - in_counts = [] - for match in re.finditer(r"\bIN\s*\(([^)]+)\)", sql_upper, re.IGNORECASE): - inner = match.group(1) - if inner.strip(): - count = inner.count(",") + 1 - in_counts.append(count) - for count in in_counts: - if count > max_in: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(count=count, max_in=max_in), - )) - - -# ── PERF-005: 多表JOIN检查 ── -@register_rule(AuditRuleV2( - code="MYSQL-PERF-005", - name="ExcessiveJoins", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="mysql", - description="JOIN 表数量不应超过阈值", - config={"enabled": True, "max_joins": 5}, - message_template="查询包含 {count} 个 JOIN,超过建议上限 {max_joins}", -)) -def check_mysql_perf_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """多表JOIN检查。""" - if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE): - return - join_count = len(re.findall(r"\bJOIN\b", sql_upper, re.IGNORECASE)) - max_joins = rule.config.get("max_joins", 5) - if join_count > max_joins: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(count=join_count, max_joins=max_joins), - )) - - -# ────────────────────────────────────────────── -# ══════════════════════════════════════════════ -# 安全规则 (5条) — MYSQL-SEC-001 ~ MYSQL-SEC-005 -# ══════════════════════════════════════════════ -# ────────────────────────────────────────────── - - -# ── SEC-001: 敏感字段检测 ── -@register_rule(AuditRuleV2( - code="MYSQL-SEC-001", - name="SensitiveColumnDetection", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="mysql", - description="禁止在表定义中使用敏感字段名(如password, secret等)", - config={"enabled": True}, - message_template="表 '{table}' 包含敏感字段名 '{column}',请确认是否符合安全规范", -)) -def check_mysql_sec_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """敏感字段检测。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - table_name = _extract_table_name(sql_upper, sql_clean) or "unknown" - sensitive_patterns = ["password", "passwd", "secret", "token", "api_key", "apikey", "credential", "auth"] - columns = _extract_column_definitions(sql_upper) - for col in columns: - col_lower = col["name"].lower() - for pattern in sensitive_patterns: - if pattern in col_lower: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(table=table_name, column=col["name"]), - )) - - -# ── SEC-002: SQL注入风险 — 字符串拼接检查 ── -@register_rule(AuditRuleV2( - code="MYSQL-SEC-002", - name="SQLInjectionRisk", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="mysql", - description="检测SQL中可能的注入风险(如字符串拼接占位符)", - config={"enabled": True}, - message_template="SQL语句可能存在注入风险,请使用参数化查询", -)) -def check_mysql_sec_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """SQL注入风险检查。""" - # 检测 ${xxx} 或 #{xxx} 等模板占位符(不应出现在最终SQL中) - if re.search(r"\$\{[^}]+\}", sql_clean) or re.search(r"#\{[^}]+\}", sql_clean): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - # 检测字符串拼接模式 - if "' || '" in sql_clean or "' + '" in sql_clean or "CONCAT(" in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ── SEC-003: 明文密码检测 ── -@register_rule(AuditRuleV2( - code="MYSQL-SEC-003", - name="PlaintextPassword", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="mysql", - description="禁止 INSERT/UPDATE 中设置明文密码值", - config={"enabled": True}, - message_template="禁止在SQL中写入明文密码,请使用加密后的值", -)) -def check_mysql_sec_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """明文密码检测。""" - if not _is_dml_statement(sql_upper): - return - # 检测 password/secret 相关的值设置 - if re.search(r"\b(password|secret|passwd)\b\s*[=)]\s*['\"][^'\"]+['\"]", - sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ── SEC-004: 注释符检查 ── -@register_rule(AuditRuleV2( - code="MYSQL-SEC-004", - name="NoCommentInSQL", - category=RuleCategory.SECURITY, - severity=Severity.WARNING, - db_type="mysql", - description="SQL中不应包含注释(可能是SQL注入手法)", - config={"enabled": True}, - message_template="SQL中包含注释符,请移除: {comment}", -)) -def check_mysql_sec_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """注释符检查。""" - if "--" in sql_clean: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(comment="--"), - )) - if "/*" in sql_clean or "*/" in sql_clean: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(comment="/* */"), - )) - - -# ── SEC-005: 多语句检查 ── -@register_rule(AuditRuleV2( - code="MYSQL-SEC-005", - name="NoMultiStatement", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="mysql", - description="禁止单条输入中包含多条SQL语句(分号分隔)", - config={"enabled": True}, - message_template="禁止提交多条SQL语句,请拆分为多个工单", -)) -def check_mysql_sec_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """多语句检查。""" - stripped = sql_clean.rstrip(";").strip() - if ";" in stripped: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -# ═══════════════════════════════════════════════════ -# 模块总结 -# ── SEC-006: 权限相关高危语句拦截 ── -@register_rule(AuditRuleV2( - code="MYSQL-SEC-006", - name="DangerousPrivilegeStatement", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="mysql", - description="拦截 GRANT ALL / REVOKE ALL 等全局权限变更操作", - config={"enabled": True}, - message_template="禁止执行 '{keyword}' 全局权限操作,请联系 DBA 通过权限管理流程申请", -)) -def check_mysql_sec_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """全局权限语句拦截。""" - # GRANT ALL / GRANT ALL PRIVILEGES - if re.search(r'\bGRANT\s+ALL\b', sql_upper): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(keyword="GRANT ALL"), - )) - # REVOKE ALL - elif re.search(r'\bREVOKE\s+ALL\b', sql_upper): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(keyword="REVOKE ALL"), - )) - - -# ── SEC-007: LOAD DATA / LOAD XML 拦截 ── -@register_rule(AuditRuleV2( - code="MYSQL-SEC-007", - name="BlockLoadData", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="mysql", - description="禁止 LOAD DATA / LOAD XML 操作,存在安全风险且可能导入大量数据", - config={"enabled": True}, - message_template="禁止使用 '{keyword}',该操作存在安全风险,请使用 INSERT 批量导入", -)) -def check_mysql_sec_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """LOAD DATA/LOAD XML 拦截。""" - if re.search(r'\bLOAD\s+DATA\b', sql_upper): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(keyword="LOAD DATA"), - )) - elif re.search(r'\bLOAD\s+XML\b', sql_upper): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(keyword="LOAD XML"), - )) - - -# ── SEC-008: SLEEP / BENCHMARK 注入拦截 ── -@register_rule(AuditRuleV2( - code="MYSQL-SEC-008", - name="BlockTimeBasedInjection", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="mysql", - description="拦截 SLEEP() / BENCHMARK() 等时间盲注函数", - config={"enabled": True}, - message_template="禁止使用 '{func}()' 函数,疑似时间盲注攻击", -)) -def check_mysql_sec_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """时间盲注函数拦截。""" - if re.search(r'\bSLEEP\s*\(', sql_upper): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(func="SLEEP"), - )) - if re.search(r'\bBENCHMARK\s*\(', sql_upper): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(func="BENCHMARK"), - )) - - -# ── SEC-009: INTO OUTFILE / INTO DUMPFILE 拦截 ── -@register_rule(AuditRuleV2( - code="MYSQL-SEC-009", - name="BlockIntoOutfile", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="mysql", - description="禁止 INTO OUTFILE / INTO DUMPFILE,防止写入 WebShell 或导出敏感数据", - config={"enabled": True}, - message_template="禁止使用 '{keyword}',该操作可写入文件系统,存在严重安全风险", -)) -def check_mysql_sec_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """INTO OUTFILE/DUMPFILE 拦截。""" - if re.search(r'\bINTO\s+(OUT|DUMP)FILE\b', sql_upper): - keyword = "INTO OUTFILE" if "OUTFILE" in sql_upper else "INTO DUMPFILE" - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(keyword=keyword), - )) - - -# ═══════════════════════════════════════════════════ -# 总计 67 条规则: -# - DDL 规则: 36 条 (MYSQL-DDL-001 ~ MYSQL-DDL-036) -# - DML 规则: 9 条 (MYSQL-DML-001 ~ MYSQL-DML-009) -# - Online-DDL 规则: 3 条 (MYSQL-OSC-001 ~ MYSQL-OSC-003) -# - 命名规则: 5 条 (MYSQL-NAM-001 ~ MYSQL-NAM-005) -# - 性能规则: 5 条 (MYSQL-PERF-001 ~ MYSQL-PERF-005) -# - 安全规则: 9 条 (MYSQL-SEC-001 ~ MYSQL-SEC-009) -# ═══════════════════════════════════════════════════ diff --git a/backend/app/services/notification_sender.py b/backend/app/services/notification_sender.py deleted file mode 100644 index ec08d08..0000000 --- a/backend/app/services/notification_sender.py +++ /dev/null @@ -1,319 +0,0 @@ -"""通知发送器 — 支持钉钉、飞书、邮件的实际发送。 - -使用 httpx 进行异步 HTTP 请求,aiosmtplib 发送邮件。 -支持消息模板渲染,根据不同事件类型发送不同格式的消息。 -""" -import time -import hmac -import hashlib -import base64 -import json -from typing import Dict, Any, Optional -from urllib.parse import quote - - -class NotificationSender: - """通知发送器,支持钉钉、飞书、邮件。""" - - # 敏感字段关键词(用于通知脱敏) - _SENSITIVE_PATTERNS = [ - (r"password\s*=\s*'[^']*'", "password='***'"), - (r"password\s*=\s*\"[^\"]*\"", 'password="***"'), - (r"secret\s*=\s*'[^']*'", "secret='***'"), - (r"token\s*=\s*'[^']*'", "token='***'"), - (r"DROP\s+TABLE\s+\w+", "DROP TABLE ***"), - (r"TRUNCATE\s+TABLE?\s*\w+", "TRUNCATE ***"), - (r"FLUSHALL", "FLUSHALL"), - (r"FLUSHDB", "FLUSHDB"), - ] - - @staticmethod - def _mask_sensitive_data(data: Dict[str, Any]) -> Dict[str, Any]: - """对通知数据中的敏感字段进行脱敏。""" - import re - masked = dict(data) - # sql_preview 截断到前100字符 + 脱敏 - if "sql_preview" in masked and masked["sql_preview"]: - preview = str(masked["sql_preview"])[:100] - for pattern, replacement in NotificationSender._SENSITIVE_PATTERNS: - preview = re.sub(pattern, replacement, preview, flags=re.IGNORECASE) - masked["sql_preview"] = preview + ("..." if len(str(data.get("sql_preview", ""))) > 100 else "") - # error 字段脱敏 - if "error" in masked and masked["error"]: - err = str(masked["error"])[:200] - for pattern, replacement in NotificationSender._SENSITIVE_PATTERNS: - err = re.sub(pattern, replacement, err, flags=re.IGNORECASE) - masked["error"] = err - return masked - - # 内置消息模板 - TEMPLATES = { - "ticket_created": { - "title": "新工单待审批", - "body": "**申请人**: {applicant}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**工单标题**: {title}\n**SQL摘要**: {sql_preview}", - }, - "ticket_approved": { - "title": "工单已审批通过", - "body": "**工单标题**: {title}\n**审批人**: {approver}\n**目标实例**: {instance_name} ({db_type})\n**状态**: 已批准,可执行", - }, - "ticket_rejected": { - "title": "工单已被拒绝", - "body": "**工单标题**: {title}\n**审批人**: {approver}\n**拒绝原因**: {comment}\n**目标实例**: {instance_name} ({db_type})", - }, - "ticket_executed": { - "title": "工单执行完成", - "body": "**工单标题**: {title}\n**执行人**: {operator}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**执行结果**: 成功\n**耗时**: {duration}ms\n**影响行数**: {affected_rows}", - }, - "ticket_failed": { - "title": "工单执行失败", - "body": "**工单标题**: {title}\n**执行人**: {operator}\n**目标实例**: {instance_name} ({db_type})\n**目标库**: {target_database}\n**错误信息**: {error}", - }, - "ticket_rollback": { - "title": "工单已回滚", - "body": "**回滚人**: {operator}\n**工单ID**: {ticket_id}", - }, - "backup_completed": { - "title": "备份完成", - "body": "**数据库**: {database}\n**文件大小**: {size}\n**耗时**: {duration}s", - }, - "backup_failed": { - "title": "备份失败", - "body": "**错误信息**: {error}\n请检查数据库连接和磁盘空间", - }, - "schema_drift_detected": { - "title": "检测到 Schema 漂移", - "body": "**实例**: {instance_name}\n**数据库**: {database}\n**发现 {drift_count} 处变更**:\n{drift_details}", - }, - } - - # 飞书卡片颜色映射 - FEISHU_COLORS = { - "ticket_created": "turquoise", - "ticket_approved": "blue", - "ticket_rejected": "red", - "ticket_executed": "green", - "ticket_failed": "red", - "ticket_rollback": "orange", - "backup_completed": "green", - "backup_failed": "red", - "schema_drift_detected": "orange", - } - - # 钉钉消息颜色标记 - DINGTALK_TAGS = { - "ticket_created": "🆕", - "ticket_approved": "✅", - "ticket_rejected": "❌", - "ticket_executed": "✅", - "ticket_failed": "❌", - "ticket_rollback": "⚠️", - "backup_completed": "✅", - "backup_failed": "❌", - "schema_drift_detected": "⚠️", - } - - async def send(self, channel_type: str, config: Dict[str, Any], - event_type: str, data: Dict[str, Any]) -> Dict[str, Any]: - """发送通知。 - - Args: - channel_type: 渠道类型 (dingtalk / feishu / email) - config: 渠道配置 - event_type: 事件类型 - data: 模板数据 - """ - # 通知脱敏:隐藏 SQL 中的敏感内容 - data = self._mask_sensitive_data(data) - - # 自动补充 sql_preview(取 sql_content 前100字符) - if "sql_preview" not in data and "sql_content" in data: - sql = data["sql_content"] - data["sql_preview"] = sql[:100] + ("..." if len(sql) > 100 else "") - - try: - if channel_type == "dingtalk": - return await self._send_dingtalk(config, event_type, data) - elif channel_type in ("webhook_feishu", "feishu"): - return await self._send_feishu(config, event_type, data) - elif channel_type == "email": - return await self._send_email(config, event_type, data) - else: - return {"success": False, "message": f"不支持的通知类型: {channel_type}"} - except Exception as e: - return {"success": False, "message": f"发送失败: {str(e)}"} - - async def _send_dingtalk(self, config: Dict[str, Any], event_type: str, - data: Dict[str, Any]) -> Dict[str, Any]: - """发送钉钉消息。""" - import httpx - - webhook_url = config.get("webhook_url", "") - secret = config.get("secret", "") - - if not webhook_url: - return {"success": False, "message": "缺少 webhook_url 配置"} - - # 生成签名 - if secret: - timestamp = str(int(time.time() * 1000)) - sign = self._generate_dingtalk_sign(timestamp, secret) - if "?" in webhook_url: - webhook_url = f"{webhook_url}×tamp={timestamp}&sign={sign}" - else: - webhook_url = f"{webhook_url}?timestamp={timestamp}&sign={sign}" - - template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"]) - # 安全格式化 - safe_data = {**data} - for key in ["applicant", "ticket_id", "title", "db_type", "instance_name", - "target_database", "sql_content", "sql_preview", "approver", - "comment", "result", "duration", "affected_rows", "error", - "operator", "database", "size", "drift_count", "drift_details"]: - safe_data.setdefault(key, "") - title = template["title"].format_map(safe_data) - body = template["body"].format_map(safe_data) - tag = self.DINGTALK_TAGS.get(event_type, "📌") - - # 钉钉 markdown 单个 \n 视为空格,需要 \n\n 才能换行 - body = body.replace("\n", "\n\n") - - payload = { - "msgtype": "markdown", - "markdown": { - "title": f"{tag} {title}", - "text": f"### {tag} {title}\n\n{body}\n\n---\n*来自 SQL 审核平台*", - } - } - - async with httpx.AsyncClient(timeout=10) as client: - response = await client.post(webhook_url, json=payload) - response.raise_for_status() - result = response.json() - if result.get("errcode") == 0: - return {"success": True, "message": "钉钉消息发送成功"} - return {"success": False, "message": f"钉钉返回错误: {result}"} - - async def _send_feishu(self, config: Dict[str, Any], event_type: str, - data: Dict[str, Any]) -> Dict[str, Any]: - """发送飞书消息 — 使用 Interactive Card。""" - import httpx - - webhook_url = config.get("webhook_url", "") - if not webhook_url: - return {"success": False, "message": "缺少 webhook_url 配置"} - - template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"]) - # 安全格式化:缺失字段用空字符串代替,避免 KeyError - safe_data = {**data} - for key in ["applicant", "ticket_id", "title", "db_type", "instance_name", - "target_database", "sql_content", "sql_preview", "approver", - "comment", "result", "duration", "affected_rows", "error", - "operator", "database", "size", "drift_count", "drift_details"]: - safe_data.setdefault(key, "") - title = template["title"].format_map(safe_data) - body = template["body"].format_map(safe_data) - color = self.FEISHU_COLORS.get(event_type, "blue") - - payload = { - "msg_type": "interactive", - "card": { - "header": { - "title": {"tag": "plain_text", "content": title}, - "template": color, - }, - "elements": [ - {"tag": "div", "text": {"tag": "lark_md", "content": body}}, - {"tag": "note", "elements": [{"tag": "plain_text", "content": "来自 SQL 审核平台"}]}, - ] - } - } - - async with httpx.AsyncClient(timeout=10) as client: - response = await client.post(webhook_url, json=payload) - response.raise_for_status() - return {"success": True, "message": "飞书消息发送成功"} - - async def _send_email(self, config: Dict[str, Any], event_type: str, - data: Dict[str, Any]) -> Dict[str, Any]: - """发送邮件通知。""" - try: - import aiosmtplib - from email.mime.text import MIMEText - from email.mime.multipart import MIMEMultipart - except ImportError: - return {"success": False, "message": "缺少 aiosmtplib 依赖,请安装: pip install aiosmtplib"} - - smtp_host = config.get("smtp_host", "") - smtp_port = config.get("smtp_port", 587) - smtp_user = config.get("smtp_user", "") - smtp_password = config.get("smtp_password", "") - from_addr = config.get("from_addr", smtp_user) - to_addr = data.get("recipient_email", "") - use_tls = config.get("use_tls", True) - - if not all([smtp_host, smtp_user, smtp_password, to_addr]): - missing = [k for k, v in { - "smtp_host": smtp_host, "smtp_user": smtp_user, - "smtp_password": smtp_password, "recipient_email": to_addr, - }.items() if not v] - return {"success": False, "message": f"缺少邮件配置: {', '.join(missing)}"} - - template = self.TEMPLATES.get(event_type, self.TEMPLATES["ticket_created"]) - subject = template["title"].format(**data) - body_text = template["body"].format(**data).replace("\n", "
") - - html_body = f""" - - -

{subject}

-
- {body_text} -
-
-

本邮件由 SQL 审核平台自动发送

- - - """ - - msg = MIMEMultipart("alternative") - msg["Subject"] = subject - msg["From"] = from_addr - msg["To"] = to_addr - msg.attach(MIMEText(html_body, "html", "utf-8")) - - await aiosmtplib.send( - msg, - hostname=smtp_host, - port=smtp_port, - username=smtp_user, - password=smtp_password, - start_tls=use_tls, - ) - - return {"success": True, "message": f"邮件已发送至 {to_addr}"} - - def _generate_dingtalk_sign(self, timestamp: str, secret: str) -> str: - """生成钉钉 webhook 签名。""" - string_to_sign = f"{timestamp}\n{secret}" - hmac_code = hmac.new( - secret.encode("utf-8"), - string_to_sign.encode("utf-8"), - digestmod=hashlib.sha256, - ).digest() - return quote(base64.b64encode(hmac_code).decode("utf-8")) - - async def notify_ticket_event(self, ticket_id: int, event_type: str, - channels: list, ticket_data: Dict[str, Any]) -> list: - """发送工单事件通知到所有启用的渠道。""" - results = [] - for channel in channels: - try: - config = json.loads(channel.config) if isinstance(channel.config, str) else channel.config - result = await self.send( - channel.channel_type, config, event_type, ticket_data - ) - results.append({"channel": channel.name, **result}) - except Exception as e: - results.append({"channel": getattr(channel, "name", "unknown"), - "success": False, "message": str(e)}) - return results diff --git a/backend/app/services/postgres_audit_rules.py b/backend/app/services/postgres_audit_rules.py deleted file mode 100644 index ca9ad65..0000000 --- a/backend/app/services/postgres_audit_rules.py +++ /dev/null @@ -1,1289 +0,0 @@ -"""PostgreSQL 特有审核规则引擎。 - -包含 45+ 条 PostgreSQL 特有审核规则,覆盖以下维度: -- DDL (18条): CREATE TABLE / ALTER TABLE 特有语法规范 -- DML (8条): INSERT/UPDATE/DELETE 操作规范 -- DQL (6条): SELECT 查询规范 -- SECURITY (6条): PostgreSQL 安全风险检测 -- PERFORMANCE (7条): PostgreSQL 性能优化建议 - -规则编码规范: -- DDL: PG-DDL-001 ~ PG-DDL-018 -- DML: PG-DML-001 ~ PG-DML-008 -- DQL: PG-DQL-001 ~ PG-DQL-006 -- SEC: PG-SEC-001 ~ PG-SEC-006 -- PERF: PG-PERF-001 ~ PG-PERF-007 - -依赖: - from app.services.audit_engine_v2 import ( - AuditRuleV2, AuditFindingV2, AuditReportV2, - Severity, RuleCategory, register_rule, - _extract_table_name, _extract_column_definitions, - _extract_index_definitions, - ) -""" - -from __future__ import annotations - -import re -from typing import Any, Callable, Dict, List, Optional, Tuple - -from app.services.audit_engine_v2 import ( - AuditRuleV2, - AuditFindingV2, - AuditReportV2, - Severity, - RuleCategory, - register_rule, - _extract_table_name, - _extract_column_definitions, - _extract_index_definitions, -) - - -# ═══════════════════════════════════════════════ -# PostgreSQL 规则专用辅助函数 -# ═══════════════════════════════════════════════ - - -def _extract_pg_table_name(sql_upper: str, sql_clean: str) -> Optional[str]: - """从 PostgreSQL CREATE/ALTER TABLE 语句中提取表名(支持 schema.table 形式)。""" - m = re.search( - r"\bCREATE\s+(?:TEMPORARY\s+|TEMP\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:\"?\w+\"?\.)?(\"?\w+\"?)", - sql_clean, re.IGNORECASE, - ) - if m: - return m.group(1).strip('"') - m = re.search( - r"\bALTER\s+TABLE\s+(?:ONLY\s+)?(?:\"?\w+\"?\.)?(\"?\w+\"?)", - sql_clean, re.IGNORECASE, - ) - if m: - return m.group(1).strip('"') - return None - - -def _extract_pg_column_defs(sql_upper: str) -> List[Dict[str, str]]: - """从 PostgreSQL CREATE TABLE (...) 中提取列定义。""" - paren_match = re.search(r"\((.*)\)\s*(?:INHERITS|TABLESPACE|PARTITION|WITH|;|$)", sql_upper, re.DOTALL) - if not paren_match: - return [] - inner = paren_match.group(1) - parts = [] - depth = 0 - current = "" - for char in inner: - if char == "(": - depth += 1 - current += char - elif char == ")": - depth -= 1 - current += char - elif char == "," and depth == 0: - parts.append(current.strip()) - current = "" - else: - current += char - if current.strip(): - parts.append(current.strip()) - columns: List[Dict[str, str]] = [] - for part in parts: - if re.match(r"^(CONSTRAINT|PRIMARY\s+KEY|UNIQUE|CHECK|EXCLUDE|FOREIGN\s+KEY|REFERENCES|LIKE)\b", part, re.IGNORECASE): - continue - col_match = re.match(r'"?(\w+)"?\s+(\w+(?:\s*\[\s*\])?|\w+\s+\w+)', part, re.IGNORECASE) - if col_match: - columns.append({"name": col_match.group(1), "type": col_match.group(2).upper(), "full": part}) - return columns - - -def _has_pg_serial_type(col_type: str) -> bool: - """判断是否为 SERIAL 类型。""" - return any(st in col_type for st in ("SERIAL", "BIGSERIAL", "SMALLSERIAL")) - - -# ═══════════════════════════════════════════════ -# DDL 规则 (PG-DDL-001 ~ PG-DDL-018, 共18条) -# ═══════════════════════════════════════════════ - - -# ── PG-DDL-001 ── -@register_rule(AuditRuleV2( - code="PG-DDL-001", name="PGDDLEnablePrimaryKey", - category=RuleCategory.DDL, severity=Severity.ERROR, db_type="postgres", - description="CREATE TABLE 语句必须包含 PRIMARY KEY 定义", - config={"allow_no_pk": False}, - message_template="建表语句缺少主键定义,请添加 PRIMARY KEY(推荐使用 GENERATED ALWAYS AS IDENTITY)", -)) -def check_pg_ddl_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制必须拥有主键。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - if "PRIMARY KEY" not in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - - -# ── PG-DDL-002 ── -@register_rule(AuditRuleV2( - code="PG-DDL-002", name="PGDDLCheckTableComment", - category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres", - description="建表语句必须包含 COMMENT ON 表注释", - config={"require_column_comment": True}, - message_template="表 '{table}' 缺少注释,请添加 COMMENT ON TABLE {table} IS '...'", -)) -def check_pg_ddl_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制表/列必须拥有注释(COMMENT ON)。""" - table_name = _extract_pg_table_name(sql_upper, sql_clean) - if not table_name: - return - has_comment = re.search(r"\bCOMMENT\s+ON\s+(?:TABLE|COLUMN)\b", sql_upper, re.IGNORECASE) - if not has_comment: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(table=table_name), - )) - - -# ── PG-DDL-003 ── -@register_rule(AuditRuleV2( - code="PG-DDL-003", name="PGDDLCheckColumnNullable", - category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres", - description="非timestamp类型字段必须声明 NOT NULL", - config={"nullable_exceptions": ["TIMESTAMP", "TIMESTAMPTZ", "DATE", "TIME", "JSON", "JSONB", "TEXT"]}, - message_template="字段 '{column}' 类型为 {col_type},未声明 NOT NULL,建议添加约束", -)) -def check_pg_ddl_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制非timestamp类型字段NOT NULL。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - columns = _extract_pg_column_defs(sql_upper) - exceptions = rule.config.get("nullable_exceptions", []) - for col in columns: - col_type = col["type"] - if any(exc in col_type for exc in exceptions): - continue - if "NOT NULL" not in col["full"] and "NULL" not in col["full"]: - if "DEFAULT" in col["full"]: - continue - if _has_pg_serial_type(col_type): - continue - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(column=col["name"], col_type=col_type), - )) - - -# ── PG-DDL-004 ── -@register_rule(AuditRuleV2( - code="PG-DDL-004", name="PGDDLCheckColumnDefault", - category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres", - description="字段应当拥有默认值(除主键和 GENERATED 列外)", - config={"require_default": True}, - message_template="字段 '{column}' 缺少 DEFAULT 值,建议添加", -)) -def check_pg_ddl_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """字段必须拥有默认值。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - for col in _extract_pg_column_defs(sql_upper): - if "PRIMARY KEY" in col["full"] or "GENERATED" in col["full"] or _has_pg_serial_type(col["type"]): - continue - if "DEFAULT" not in col["full"]: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(column=col["name"]), - )) - - -# ── PG-DDL-005 ── -@register_rule(AuditRuleV2( - code="PG-DDL-005", name="PGDDLCheckFloatDouble", - category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres", - description="浮点类型精度丢失,建议使用 NUMERIC/DECIMAL", - message_template="字段 '{column}' 使用了 {col_type} 类型,建议使用 NUMERIC(p,s) 以确保精度", -)) -def check_pg_ddl_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """NUMERIC vs FLOAT 精度建议。""" - if not re.search(r"\bCREATE\s+TABLE\b|\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - return - float_types = ("FLOAT", "REAL", "DOUBLE", "DOUBLE PRECISION") - for col in _extract_pg_column_defs(sql_upper): - for ft in float_types: - if ft in col["type"]: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(column=col["name"], col_type=col["type"]), - )) - break - - -# ── PG-DDL-006 ── -@register_rule(AuditRuleV2( - code="PG-DDL-006", name="PGDDLEnableSerial", - category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres", - description="推荐使用 GENERATED ALWAYS AS IDENTITY 替代 SERIAL", - config={"recommend_identity": True, "generated_type": "ALWAYS"}, - message_template="建议使用 GENERATED {generated_type} AS IDENTITY 替代 SERIAL/BIGSERIAL,更符合 SQL 标准", -)) -def check_pg_ddl_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """推荐自增列使用 GENERATED ALWAYS AS IDENTITY。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - for col in _extract_pg_column_defs(sql_upper): - if _has_pg_serial_type(col["type"]): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(generated_type=rule.config.get("generated_type", "ALWAYS")), - )) - break - - -# ── PG-DDL-007 ── -@register_rule(AuditRuleV2( - code="PG-DDL-007", name="PGDDLPrimaryKeyMust", - category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres", - description="主键字段名应为 id(支持配置)", - config={"pk_name": "id"}, - message_template="主键名 '{pk_name}' 建议改为 '{suggested}'", -)) -def check_pg_ddl_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """主键名称为 id。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - suggested = rule.config.get("pk_name", "id") - pk_match = re.search(r"PRIMARY\s+KEY\s*\(([^)]+)\)", sql_upper, re.IGNORECASE) - if pk_match: - for col in [c.strip().strip('"') for c in pk_match.group(1).split(",")]: - if col.lower() != suggested.lower(): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(pk_name=col, suggested=suggested), - )) - for col in _extract_pg_column_defs(sql_upper): - if "PRIMARY KEY" in col["full"] and col["name"].lower() != suggested.lower(): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(pk_name=col["name"], suggested=suggested), - )) - - -# ── PG-DDL-008 ── -@register_rule(AuditRuleV2( - code="PG-DDL-008", name="PGDDLEnableDropTable", - category=RuleCategory.DDL, severity=Severity.ERROR, db_type="postgres", - description="生产环境默认禁止执行 DROP TABLE 操作", - config={"env_production_only": True, "allow_drop": False}, - message_template="禁止执行 DROP TABLE 操作,数据将永久丢失。如需删除请先备份", -)) -def check_pg_ddl_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许删除表(生产环境默认禁止)。""" - if not re.search(r"\bDROP\s+TABLE\b", sql_upper, re.IGNORECASE): - return - if not rule.config.get("allow_drop", False): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - - -# ── PG-DDL-009 ── -@register_rule(AuditRuleV2( - code="PG-DDL-009", name="PGDDLEnableDropDatabase", - category=RuleCategory.DDL, severity=Severity.ERROR, db_type="postgres", - description="禁止执行 DROP DATABASE 操作", - config={"allow_drop_db": False}, - message_template="禁止执行 DROP DATABASE 操作,将导致整个数据库被删除", -)) -def check_pg_ddl_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许删除库(默认禁止)。""" - if not re.search(r"\bDROP\s+DATABASE\b", sql_upper, re.IGNORECASE): - return - if not rule.config.get("allow_drop_db", False): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - - -# ── PG-DDL-010 ── -@register_rule(AuditRuleV2( - code="PG-DDL-010", name="PGDDLAllowPRINotInt", - category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres", - description="检查主键类型是否为 integer/bigint(允许 UUID 等)", - config={"allowed_pk_types": ["INT", "BIGINT", "INTEGER", "SERIAL", "BIGSERIAL", "UUID"]}, - message_template="主键类型 '{pk_type}' 不在推荐列表中,建议使用 BIGINT/SERIAL/UUID", -)) -def check_pg_ddl_010(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许主键类型为非integer/bigint(给出建议)。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - allowed = rule.config.get("allowed_pk_types", []) - for col in _extract_pg_column_defs(sql_upper): - if "PRIMARY KEY" in col["full"]: - if not any(a.upper() in col["type"] for a in allowed): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(pk_type=col["type"]), - )) - - -# ── PG-DDL-011 ── -@register_rule(AuditRuleV2( - code="PG-DDL-011", name="PGDDLMultiToCommit", - category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres", - description="单工单包含多条 DDL 语句", - config={"max_ddl_per_ticket": 5, "allow_multi": True}, - message_template="检测到单工单包含 {count} 条 DDL 语句,建议不超过 {max} 条", -)) -def check_pg_ddl_011(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许单工单多条DDL(但建议不超过限制)。""" - ddl_patterns = [ - r"\bCREATE\s+(?:TABLE|INDEX|SEQUENCE|VIEW|MATERIALIZED\s+VIEW|TRIGGER|FUNCTION)\b", - r"\bALTER\s+TABLE\b", - r"\bDROP\s+(?:TABLE|INDEX|SEQUENCE|VIEW)\b", - ] - count = sum(len(re.findall(pat, sql_upper, re.IGNORECASE)) for pat in ddl_patterns) - max_ddl = rule.config.get("max_ddl_per_ticket", 5) - if count > max_ddl: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(count=count, max=max_ddl), - )) - - -# ── PG-DDL-012 ── -@register_rule(AuditRuleV2( - code="PG-DDL-012", name="PGDDLAllowColumnType", - category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres", - description="ALTER TABLE ALTER TYPE 必须带 USING 子句", - message_template="字段类型转换 ALTER TYPE 缺少 USING 子句,可能导致数据丢失", -)) -def check_pg_ddl_012(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许字段类型转换(USING子句)。""" - if not re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - return - if re.search(r"\bALTER\s+(?:COLUMN\s+)?\w+\s+TYPE\b", sql_upper, re.IGNORECASE): - if "USING" not in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - - -# ── PG-DDL-013 ── -@register_rule(AuditRuleV2( - code="PG-DDL-013", name="PGAllowCreateView", - category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres", - description="检查 MATERIALIZED VIEW 的刷新策略", - config={"allow_materialized_view": True, "refresh_strategy": "CONCURRENTLY"}, - message_template="创建 MATERIALIZED VIEW 时建议使用 REFRESH MATERIALIZED VIEW CONCURRENTLY", -)) -def check_pg_ddl_013(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许创建视图(给出 MATERIALIZED VIEW 建议)。""" - if not re.search(r"\bCREATE\s+(?:MATERIALIZED\s+)?VIEW\b", sql_upper, re.IGNORECASE): - return - if "MATERIALIZED" in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - - -# ── PG-DDL-014 ── -@register_rule(AuditRuleV2( - code="PG-DDL-014", name="PGDDLEnableForeignKey", - category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres", - description="外键约束会导致共享行锁,影响并发性能", - config={"allow_foreign_key": True, "warn_about_lock": True}, - message_template="外键约束(REFERENCES)在 PG 中可能导致共享行锁,影响并发写入性能,建议应用层控制", -)) -def check_pg_ddl_014(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许外键(但警告PG外键有共享锁问题)。""" - if not re.search(r"\bCREATE\s+TABLE\b|\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE): - return - if re.search(r"\bREFERENCES\s+\w+", sql_upper, re.IGNORECASE) or "FOREIGN KEY" in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - - -# ── PG-DDL-015 ── -@register_rule(AuditRuleV2( - code="PG-DDL-015", name="PGAllowCreatePartition", - category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres", - description="检查分区表创建规范(Declarative Partitioning)", - config={"supported_partition_types": ["RANGE", "LIST", "HASH"]}, - message_template="分区表建议使用 PARTITION BY {supported_types},并确保有分区键索引", -)) -def check_pg_ddl_015(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许创建分区表(Declarative Partitioning)。""" - if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE): - return - if "PARTITION BY" in sql_upper: - supported = rule.config.get("supported_partition_types", ["RANGE", "LIST", "HASH"]) - if not any(pt in sql_upper for pt in supported): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(supported_types="/".join(supported)), - )) - - -# ── PG-DDL-016 ── -@register_rule(AuditRuleV2( - code="PG-DDL-016", name="PGDDLTablePrefix", - category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres", - description="表名前缀规范检查", - config={"required_prefixes": []}, - message_template="表名 '{table}' 不符合前缀规范,建议前缀: {prefixes}", -)) -def check_pg_ddl_016(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """表名前缀规范。""" - required = rule.config.get("required_prefixes", []) - if not required: - return - table_name = _extract_pg_table_name(sql_upper, sql_clean) - if not table_name or not any(table_name.startswith(p) for p in required): - if table_name: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(table=table_name, prefixes=", ".join(required)), - )) - - -# ── PG-DDL-017 ── -@register_rule(AuditRuleV2( - code="PG-DDL-017", name="PGSupportEncoding", - category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres", - description="数据库/表应使用 UTF8 编码", - config={"allowed_encodings": ["UTF8"]}, - message_template="编码设置 '{encoding}' 不在推荐列表中,建议使用 UTF8", -)) -def check_pg_ddl_017(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许的编码(UTF8推荐)。""" - enc_match = re.search(r"\bENCODING\s*[=:]\s*['\"](\w+)['\"]|\bENCODING\s+(\w+)", sql_upper, re.IGNORECASE) - if enc_match: - encoding = enc_match.group(1) or enc_match.group(2) - allowed = rule.config.get("allowed_encodings", ["UTF8"]) - if encoding and encoding.upper() not in [a.upper() for a in allowed]: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(encoding=encoding), - )) - - -# ── PG-DDL-018 ── -@register_rule(AuditRuleV2( - code="PG-DDL-018", name="PGMaxTableNameLen", - category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres", - description="PostgreSQL 表名最大长度限制为 63 字节(NAMEDATALEN - 1)", - config={"max_name_bytes": 63}, - message_template="表名 '{table}' 长度为 {length} 字节,超过 PG 限制 {max} 字节,超长部分将被截断", -)) -def check_pg_ddl_018(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """表名最大长度(PG限制63字节)。""" - table_name = _extract_pg_table_name(sql_upper, sql_clean) - if not table_name: - return - name_bytes = len(table_name.encode("utf-8")) - max_bytes = rule.config.get("max_name_bytes", 63) - if name_bytes > max_bytes: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(table=table_name, length=name_bytes, max=max_bytes), - )) - - -# ═══════════════════════════════════════════════ -# DML 规则 (PG-DML-001 ~ PG-DML-008, 共8条) -# ═══════════════════════════════════════════════ - - -# ── PG-DML-001 ── -@register_rule(AuditRuleV2( - code="PG-DML-001", name="PGDMLMaxInsertRows", - category=RuleCategory.DML, severity=Severity.WARNING, db_type="postgres", - description="INSERT 语句最大行数限制", - config={"max_insert_rows": 1000}, - message_template="单次 INSERT 包含 {count} 行,超过最大限制 {max},请分批插入", -)) -def check_pg_dml_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Insert最大行数上限。""" - if not re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE): - return - max_rows = rule.config.get("max_insert_rows", 1000) - values_match = re.search(r"\bVALUES\s+(.+)", sql_upper, re.IGNORECASE | re.DOTALL) - if values_match: - values_part = values_match.group(1) - row_count = 0 - depth = 0 - for char in values_part: - if char == "(": - if depth == 0: - row_count += 1 - depth += 1 - elif char == ")": - depth -= 1 - if row_count > max_rows: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(count=row_count, max=max_rows), - )) - - -# ── PG-DML-002 ── -@register_rule(AuditRuleV2( - code="PG-DML-002", name="PGDMLAllowLimitSTMT", - category=RuleCategory.DML, severity=Severity.INFO, db_type="postgres", - description="PostgreSQL 原生支持 UPDATE/DELETE 带 LIMIT,但需谨慎使用", - config={"allow_limit": True, "warn_when_used": True}, - message_template="UPDATE/DELETE 使用了 LIMIT,可能导致部分数据未处理,建议配合 ORDER BY 使用", -)) -def check_pg_dml_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许UPDATE/DELETE带LIMIT(PG原生支持,但给出警告)。""" - for keyword in ("UPDATE", "DELETE"): - if re.match(rf"\s*{keyword}\b", sql_upper, re.IGNORECASE): - if re.search(r"\bLIMIT\s+\d+", sql_upper, re.IGNORECASE): - if rule.config.get("warn_when_used", True): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - break - - -# ── PG-DML-003 ── -@register_rule(AuditRuleV2( - code="PG-DML-003", name="PGDMLAllowInsertNull", - category=RuleCategory.DML, severity=Severity.INFO, db_type="postgres", - description="允许插入 NULL,但建议显式使用 DEFAULT", - config={"allow_null": True, "prefer_default": True}, - message_template="INSERT 包含 NULL 值,建议改用 DEFAULT 关键字以保持语义清晰", -)) -def check_pg_dml_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """允许插入Null(但建议使用DEFAULT)。""" - if not re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE): - return - if re.search(r"\bVALUES\b.*\bNULL\b", sql_upper, re.IGNORECASE): - if rule.config.get("prefer_default", True): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - - -# ── PG-DML-004 ── -@register_rule(AuditRuleV2( - code="PG-DML-004", name="PGDMLWhere", - category=RuleCategory.DML, severity=Severity.ERROR, db_type="postgres", - description="UPDATE/DELETE 必须包含 WHERE 条件", - message_template="{operation} 语句缺少 WHERE 条件,将操作全表数据,极其危险", -)) -def check_pg_dml_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """强制DML必须拥有WHERE。""" - for keyword in ("UPDATE", "DELETE"): - if re.match(rf"\s*{keyword}\b", sql_upper, re.IGNORECASE): - if "WHERE" not in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(operation=keyword), - )) - - -# ── PG-DML-005 ── -@register_rule(AuditRuleV2( - code="PG-DML-005", name="PGDMLReturning", - category=RuleCategory.DML, severity=Severity.INFO, db_type="postgres", - description="推荐 DML 使用 RETURNING 子句获取操作结果", - config={"recommend_returning": True}, - message_template="DML 语句建议使用 RETURNING 子句获取操作后的值(如 RETURNING id)", -)) -def check_pg_dml_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """推荐DML使用RETURNING子句。""" - for keyword in ("INSERT", "UPDATE", "DELETE"): - if re.match(rf"\s*{keyword}\b", sql_upper, re.IGNORECASE): - if "RETURNING" not in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - break - - -# ── PG-DML-006 ── -@register_rule(AuditRuleV2( - code="PG-DML-006", name="PGDMLCTE", - category=RuleCategory.DML, severity=Severity.INFO, db_type="postgres", - description="CTE (Common Table Expression) 使用规范", - config={"max_cte_depth": 3, "warn_recursive": True}, - message_template="CTE 使用规范: {cte_msg}", -)) -def check_pg_dml_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """CTE使用规范。""" - if not re.search(r"\bWITH\s+\w+\s+AS\s*\(", sql_upper, re.IGNORECASE): - return - if rule.config.get("warn_recursive", True) and re.search(r"\bWITH\s+RECURSIVE\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=Severity.WARNING, - message="递归 CTE (WITH RECURSIVE) 可能导致无限循环,请确保有终止条件", - )) - cte_count = len(re.findall(r"\bWITH\b", sql_upper, re.IGNORECASE)) - max_depth = rule.config.get("max_cte_depth", 3) - if cte_count > max_depth: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(cte_msg=f"检测到 {cte_count} 层嵌套 CTE,建议不超过 {max_depth} 层"), - )) - - -# ── PG-DML-007 ── -@register_rule(AuditRuleV2( - code="PG-DML-007", name="PGMaxAffectRows", - category=RuleCategory.DML, severity=Severity.WARNING, db_type="postgres", - description="DML 最大影响行数(通过 LIMIT 或 WHERE 范围控制)", - config={"max_affected_rows": 10000}, - message_template="UPDATE/DELETE 缺少行数限制,可能影响超过 {max} 行数据,建议添加 LIMIT 或更精确的 WHERE", -)) -def check_pg_dml_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """DML最大影响行数。""" - max_rows = rule.config.get("max_affected_rows", 10000) - for keyword in ("UPDATE", "DELETE"): - if re.match(rf"\s*{keyword}\b", sql_upper, re.IGNORECASE): - has_limit = re.search(r"\bLIMIT\s+\d+", sql_upper, re.IGNORECASE) is not None - has_pk = re.search(r"\bid\s*[=<>]|\b\w+_id\s*[=<>]", sql_upper, re.IGNORECASE) is not None - if not has_limit and not has_pk and "WHERE" in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(max=max_rows), - )) - break - - -# ── PG-DML-008 ── -@register_rule(AuditRuleV2( - code="PG-DML-008", name="PGDDLImplicitTypeConversion", - category=RuleCategory.DML, severity=Severity.WARNING, db_type="postgres", - description="禁止 WHERE 条件中的隐式类型转换,会导致索引失效", - message_template="WHERE 条件中字段 '{column}' 与值之间存在隐式类型转换风险,请使用显式转换 ::TYPE", -)) -def check_pg_dml_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """不允许隐式类型转换。""" - if "WHERE" not in sql_upper: - return - where_match = re.search(r"\bWHERE\b(.+?)(?:GROUP|HAVING|ORDER|LIMIT|RETURNING|;|$)", sql_upper, re.IGNORECASE | re.DOTALL) - if not where_match: - return - where_clause = where_match.group(1) - patterns = [ - r"(\w+)\s*[=<>]\s*'\d+'", - r"(\w+)\s*=\s*NULL", - ] - for pat in patterns: - matches = re.findall(pat, where_clause, re.IGNORECASE) - if matches: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(column=matches[0]), - )) - break - - -# ═══════════════════════════════════════════════ -# DQL 规则 (PG-DQL-001 ~ PG-DQL-006, 共6条) -# ═══════════════════════════════════════════════ - - -# ── PG-DQL-001 ── -@register_rule(AuditRuleV2( - code="PG-DQL-001", name="PGDQLSelectStar", - category=RuleCategory.DQL, severity=Severity.WARNING, db_type="postgres", - description="SELECT * 返回不必要的列,影响网络传输和内存", - message_template="禁止使用 SELECT *,请明确指定需要的字段(COUNT(*) 除外)", -)) -def check_pg_dql_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """SELECT * 检测。""" - if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE): - return - if re.search(r"\bSELECT\s+\*\s+FROM\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - - -# ── PG-DQL-002 ── -@register_rule(AuditRuleV2( - code="PG-DQL-002", name="PGDQLLimitOffset", - category=RuleCategory.DQL, severity=Severity.INFO, db_type="postgres", - description="大表分页建议使用游标分页替代 LIMIT/OFFSET", - config={"max_offset_rows": 100000}, - message_template="使用 LIMIT/OFFSET 在大表上性能较差(OFFSET 越大越慢),建议改用游标分页 (WHERE id > ? LIMIT N) 或 keyset pagination", -)) -def check_pg_dql_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """推荐LIMIT+OFFSET分页优化。""" - if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE): - return - if re.search(r"\bOFFSET\s+\d+\b", sql_upper, re.IGNORECASE): - offset_match = re.search(r"\bOFFSET\s+(\d+)", sql_upper, re.IGNORECASE) - if offset_match: - offset_val = int(offset_match.group(1)) - max_offset = rule.config.get("max_offset_rows", 100000) - if offset_val > max_offset: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=Severity.WARNING, - message=f"OFFSET {offset_val} 过大,超过建议值 {max_offset},强烈建议改用游标分页", - )) - else: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - - -# ── PG-DQL-003 ── -@register_rule(AuditRuleV2( - code="PG-DQL-003", name="PGDQLWindowFunction", - category=RuleCategory.DQL, severity=Severity.INFO, db_type="postgres", - description="Window 函数使用检查,确保有合适的索引支持", - config={"warn_no_partition": True}, - message_template="Window 函数 ({functions}) 使用 detected,建议确保 PARTITION BY / ORDER BY 列有索引支持", -)) -def check_pg_dql_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Window函数使用检查。""" - window_funcs = [ - "ROW_NUMBER", "RANK", "DENSE_RANK", "NTILE", - "LAG", "LEAD", "FIRST_VALUE", "LAST_VALUE", - "NTH_VALUE", "PERCENT_RANK", "CUME_DIST", - ] - found_funcs = [f for f in window_funcs if re.search(rf"\b{f}\s*\(", sql_upper, re.IGNORECASE)] - if found_funcs: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(functions=", ".join(found_funcs)), - )) - - -# ── PG-DQL-004 ── -@register_rule(AuditRuleV2( - code="PG-DQL-004", name="PGDQLRegex", - category=RuleCategory.DQL, severity=Severity.WARNING, db_type="postgres", - description="~ 操作符(正则表达式匹配)性能警告,无法使用索引", - config={"prefer_prefix_regex": True}, - message_template="正则表达式匹配 (~ / ~*) 无法使用 B-Tree 索引,性能较差。建议: 1) 使用前缀匹配 ^ 配合 text_pattern_ops 索引; 2) 改用 pg_trgm 的 gin/gist 索引", -)) -def check_pg_dql_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """~操作符性能警告。""" - if re.search(r"\w+\s*~\*?\s*['\"]", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - - -# ── PG-DQL-005 ── -@register_rule(AuditRuleV2( - code="PG-DQL-005", name="PGDQLConcurrentIndex", - category=RuleCategory.DQL, severity=Severity.INFO, db_type="postgres", - description="生产环境创建索引应使用 CREATE INDEX CONCURRENTLY 避免锁表", - config={"require_concurrently": True}, - message_template="生产环境建议 CREATE INDEX CONCURRENTLY 代替 CREATE INDEX,避免阻塞写入", -)) -def check_pg_dql_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """推荐CREATE INDEX CONCURRENTLY。""" - if not re.search(r"\bCREATE\s+INDEX\b", sql_upper, re.IGNORECASE): - return - if "CONCURRENTLY" not in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - - -# ── PG-DQL-006 ── -@register_rule(AuditRuleV2( - code="PG-DQL-006", name="PGDQLAnalyzeVacuum", - category=RuleCategory.DQL, severity=Severity.INFO, db_type="postgres", - description="大批量数据变更后应执行 ANALYZE 更新统计信息", - config={"recommend_analyze": True}, - message_template="大批量数据操作后建议执行 ANALYZE table_name 更新统计信息,帮助优化器生成正确执行计划", -)) -def check_pg_dql_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """检查ANALYZE更新统计信息。""" - is_bulk = False - if re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE): - values_match = re.search(r"\bVALUES\s+(.+)", sql_upper, re.IGNORECASE | re.DOTALL) - if values_match: - values_part = values_match.group(1) - row_count = 0 - depth = 0 - for char in values_part: - if char == "(": - if depth == 0: - row_count += 1 - depth += 1 - elif char == ")": - depth -= 1 - if row_count > 100: - is_bulk = True - if re.search(r"\bUPDATE\s+\w+\s+SET\b", sql_upper, re.IGNORECASE) and "WHERE" in sql_upper: - if not re.search(r"\bid\s*[=<>]", sql_upper, re.IGNORECASE) and "LIMIT" not in sql_upper: - is_bulk = True - if is_bulk: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, - )) - - -# ═══════════════════════════════════════════════ -# 安全规则 (PG-SEC-001 ~ PG-SEC-006, 共6条) -# ═══════════════════════════════════════════════ - - -# ── PG-SEC-001 ── -@register_rule(AuditRuleV2( - code="PG-SEC-001", name="PGSecuritySuperuser", - category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="postgres", - description="禁止使用超级用户(superuser)执行日常操作", - config={"forbidden_roles": ["postgres", "rds_superuser"]}, - message_template="检测到超级用户操作 ({role}),禁止使用超级用户执行日常 SQL,应使用最小权限原则", -)) -def check_pg_sec_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止超级用户操作。""" - forbidden = rule.config.get("forbidden_roles", ["postgres", "rds_superuser"]) - superuser_patterns = [ - r"\bALTER\s+(?:ROLE|USER)\s+(\w+)\s+.*\bSUPERUSER\b", - r"\bCREATE\s+(?:ROLE|USER)\s+(\w+).*\bSUPERUSER\b", - r"\bSET\s+ROLE\s+(\w+)", - ] - for pat in superuser_patterns: - match = re.search(pat, sql_upper, re.IGNORECASE) - if match: - role = match.group(1) - if role in forbidden or "SUPERUSER" in sql_upper.upper(): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(role=role), - )) - break - - -# ── PG-SEC-002 ── -@register_rule(AuditRuleV2( - code="PG-SEC-002", name="PGSecurityFileAccess", - category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="postgres", - description="禁止调用 pg_read_file, pg_read_binary_file 等文件访问函数", - config={"forbidden_functions": [ - "pg_read_file", "pg_read_binary_file", "pg_ls_dir", - "pg_stat_file", "pg_read_file_all", - ]}, - message_template="禁止调用危险函数 '{func}',该函数可访问服务器文件系统", -)) -def check_pg_sec_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止pg_read_file/pg_load。""" - forbidden = rule.config.get("forbidden_functions", []) - for func in forbidden: - if re.search(rf"\b{func}\s*\(", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(func=func), - )) - break - - -# ── PG-SEC-003 ── -@register_rule(AuditRuleV2( - code="PG-SEC-003", name="PGSecurityCopyFile", - category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="postgres", - description="COPY FROM/TO 可读写服务器文件系统,存在安全风险", - config={"allow_copy": False, "allowed_paths": []}, - message_template="COPY {direction} 可访问文件系统,存在安全风险。如需使用请确保文件路径受控", -)) -def check_pg_sec_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """COPY FROM/TO文件系统风险。""" - if rule.config.get("allow_copy", False): - return - copy_from = re.search(r"\bCOPY\b.*\bFROM\b", sql_upper, re.IGNORECASE) - copy_to = re.search(r"\bCOPY\b.*\bTO\b", sql_upper, re.IGNORECASE) - if copy_from: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(direction="FROM"), - )) - if copy_to: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(direction="TO"), - )) - - -# ── PG-SEC-004 ── -@register_rule(AuditRuleV2( - code="PG-SEC-004", name="PGSecurityExtension", - category=RuleCategory.SECURITY, severity=Severity.WARNING, db_type="postgres", - description="危险扩展检查(dblink, plpython 等可执行外部代码)", - config={ - "dangerous_extensions": [ - "dblink", "plpython3u", "plpython2u", "plperlu", "pltclu", - "file_fdw", "postgres_fdw", "pg_file_settings", - "adminpack", "auto_explain", - ], - "allowed_extensions": ["pgcrypto", "uuid-ossp", "pg_trgm"], - }, - message_template="扩展 '{ext}' 存在安全风险,请评估必要性: 可访问外部资源或执行外部代码", -)) -def check_pg_sec_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """危险扩展检查。""" - if not re.search(r"\bCREATE\s+EXTENSION\b", sql_upper, re.IGNORECASE): - return - dangerous = rule.config.get("dangerous_extensions", []) - allowed = rule.config.get("allowed_extensions", []) - for ext in dangerous: - if ext in allowed: - continue - if re.search(rf"\bCREATE\s+EXTENSION\s+(?:IF\s+NOT\s+EXISTS\s+)?{re.escape(ext)}\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(ext=ext), - )) - - -# ── PG-SEC-005 ── -@register_rule(AuditRuleV2( - code="PG-SEC-005", name="PGSecurityPassword", - category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="postgres", - description="密码复杂度检查", - config={ - "min_length": 12, - "require_special": True, - "require_upper": True, - "require_digit": True, - }, - message_template="密码不符合复杂度要求: 最少 {min_len} 位,需包含大小写字母、数字和特殊字符", -)) -def check_pg_sec_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """密码复杂度检查。""" - pwd_match = re.search( - r"\b(?:CREATE|ALTER)\s+(?:ROLE|USER)\s+\w+.*\bPASSWORD\s+['\"]([^'\"]+)['\"]", - sql_clean, re.IGNORECASE, - ) - if not pwd_match: - return - password = pwd_match.group(1) - min_len = rule.config.get("min_length", 12) - issues = [] - if len(password) < min_len: - issues.append(f"长度不足 {min_len} 位") - if rule.config.get("require_upper", True) and not re.search(r"[A-Z]", password): - issues.append("缺少大写字母") - if not re.search(r"[a-z]", password): - issues.append("缺少小写字母") - if rule.config.get("require_digit", True) and not re.search(r"\d", password): - issues.append("缺少数字") - if rule.config.get("require_special", True) and not re.search(r"[!@#$%^&*()_+\-=\[\]{};':\"|,.<>/?]", password): - issues.append("缺少特殊字符") - if issues: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=f"密码不符合复杂度要求: {', '.join(issues)}", - )) - - -# ── PG-SEC-006 ── -@register_rule(AuditRuleV2( - code="PG-SEC-006", name="PGSecurityListenNotify", - category=RuleCategory.SECURITY, severity=Severity.INFO, db_type="postgres", - description="LISTEN/NOTIFY 使用限制(防止 DoS 攻击)", - config={"max_channels_per_session": 10, "warn_payload_size": 8000}, - message_template="LISTEN/NOTIFY 使用 detected: {msg}", -)) -def check_pg_sec_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """LISTEN/NOTIFY使用限制。""" - has_listen = re.search(r"\bLISTEN\s+\w+", sql_upper, re.IGNORECASE) - has_notify = re.search(r"\bNOTIFY\s+\w+", sql_upper, re.IGNORECASE) - if has_listen: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format( - msg="LISTEN 通道在会话断开时自动取消订阅,建议配置 UNLISTEN 清理" - ), - )) - if has_notify: - payload_match = re.search(r"\bNOTIFY\s+\w+\s*,\s*['\"]([^'\"]+)['\"]", sql_clean, re.IGNORECASE) - if payload_match: - payload = payload_match.group(1) - max_size = rule.config.get("warn_payload_size", 8000) - if len(payload.encode("utf-8")) > max_size: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=Severity.WARNING, - message=f"NOTIFY payload 大小超过 {max_size} 字节(PG限制 8000 字节),建议减小消息体", - )) - - -# ═══════════════════════════════════════════════ -# 性能规则 (PG-PERF-001 ~ PG-PERF-007, 共7条) -# ═══════════════════════════════════════════════ - - -# ── PG-PERF-001 ── -@register_rule(AuditRuleV2( - code="PG-PERF-001", name="PGPerfVacuum", - category=RuleCategory.PERFORMANCE, severity=Severity.INFO, db_type="postgres", - description="大表 VACUUM 检查,建议避免 VACUUM FULL 阻塞操作", - config={"warn_vacuum_full": True, "recommend_autovacuum": True, "table_size_threshold_mb": 1000}, - message_template="VACUUM 操作建议: {msg}", -)) -def check_pg_perf_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """大表VACUUM检查。""" - if not re.search(r"\bVACUUM\b", sql_upper, re.IGNORECASE): - return - if rule.config.get("warn_vacuum_full", True) and re.search(r"\bVACUUM\s+FULL\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=Severity.WARNING, - message=rule.message_template.format( - msg="VACUUM FULL 会获取排他锁并重建整个表,大表操作会长时间阻塞读写。建议优先使用标准 VACUUM 或 pg_repack" - ), - )) - else: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format( - msg="建议启用 autovacuum 并合理配置阈值 (autovacuum_vacuum_scale_factor),避免手动 VACUUM 遗漏" - ), - )) - - -# ── PG-PERF-002 ── -@register_rule(AuditRuleV2( - code="PG-PERF-002", name="PGPerfSeqScan", - category=RuleCategory.PERFORMANCE, severity=Severity.WARNING, db_type="postgres", - description="检测可能导致全表扫描的查询模式", - config={"min_table_size_for_warn": 10000}, - message_template="查询可能触发全表扫描: {reason}", -)) -def check_pg_perf_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """全表扫描检测(静态检查)。""" - if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE): - return - reasons = [] - if "WHERE" not in sql_upper and "LIMIT" not in sql_upper: - reasons.append("SELECT 缺少 WHERE 和 LIMIT 条件") - where_match = re.search(r"\bWHERE\b(.+?)(?:GROUP|ORDER|LIMIT|OFFSET|;|$)", sql_upper, re.IGNORECASE | re.DOTALL) - if where_match: - where_clause = where_match.group(1) - if re.search(r"\b\w+\s*\([^)]+\)\s*[=<>]", where_clause): - reasons.append("WHERE 子句中列上使用了函数,可能导致索引失效") - if re.search(r"\bLIKE\s+'%[^']+'", where_clause): - reasons.append("LIKE 前导通配符 % 无法使用索引") - if re.search(r"\b\w+\s+IS\s+NULL\b", where_clause): - reasons.append("IS NULL 条件可能无法使用标准 B-Tree 索引") - if re.search(r"\bILIKE\b", sql_upper, re.IGNORECASE): - reasons.append("ILIKE 无法使用 B-Tree 索引,建议使用 citext 类型或 pg_trgm 扩展") - if reasons: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(reason="; ".join(reasons)), - )) - - -# ── PG-PERF-003 ── -@register_rule(AuditRuleV2( - code="PG-PERF-003", name="PGPerfIndexBloat", - category=RuleCategory.PERFORMANCE, severity=Severity.INFO, db_type="postgres", - description="索引膨胀检测建议,REINDEX CONCURRENTLY 使用", - config={"bloat_threshold_pct": 50, "recommend_reindex_concurrently": True}, - message_template="索引维护建议: {msg}", -)) -def check_pg_perf_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """索引膨胀检测。""" - if re.search(r"\bREINDEX\s+INDEX\b", sql_upper, re.IGNORECASE) and "CONCURRENTLY" not in sql_upper: - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=Severity.WARNING, - message=rule.message_template.format( - msg="REINDEX 会锁表,生产环境建议使用 REINDEX TABLE CONCURRENTLY 或 REINDEX INDEX CONCURRENTLY (PG 12+)" - ), - )) - if re.search(r"\bCREATE\s+(?:UNIQUE\s+)?INDEX\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format( - msg="定期监控索引膨胀率 (pgstattuple 扩展),膨胀超过 50% 时考虑 REINDEX CONCURRENTLY" - ), - )) - - -# ── PG-PERF-004 ── -@register_rule(AuditRuleV2( - code="PG-PERF-004", name="PGPerfLockTimeout", - category=RuleCategory.PERFORMANCE, severity=Severity.WARNING, db_type="postgres", - description="锁超时配置检查,长时间持有锁影响并发", - config={"recommended_lock_timeout_ms": 5000, "recommended_statement_timeout_ms": 30000}, - message_template="锁/超时配置建议: {msg}", -)) -def check_pg_perf_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """锁超时配置。""" - timeout_patterns = [ - (r"\bSET\s+lock_timeout\s*=\s*['\"]?(\d+\s*\w*)['\"]?", "lock_timeout"), - (r"\bSET\s+statement_timeout\s*=\s*['\"]?(\d+\s*\w*)['\"]?", "statement_timeout"), - ] - for pat, name in timeout_patterns: - match = re.search(pat, sql_upper, re.IGNORECASE) - if match: - value = match.group(1) - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format(msg=f"{name} 设置为 {value},建议合理配置避免长时间锁等待"), - )) - if re.search(r"\bUPDATE\s+\w+\s+SET\b", sql_upper, re.IGNORECASE) and "WHERE" in sql_upper and "LIMIT" not in sql_upper: - recommended = rule.config.get("recommended_statement_timeout_ms", 30000) - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format( - msg=f"大批量操作建议在事务中设置 SET LOCAL statement_timeout = '{recommended}ms' 避免长时间持有锁" - ), - )) - - -# ── PG-PERF-005 ── -@register_rule(AuditRuleV2( - code="PG-PERF-005", name="PGPerfConnectionPool", - category=RuleCategory.PERFORMANCE, severity=Severity.INFO, db_type="postgres", - description="连接池使用建议", - config={"max_connections": 200, "recommended_pool_size": 20}, - message_template="连接管理建议: {msg}", -)) -def check_pg_perf_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """连接池使用建议。""" - if re.search(r"\bSET\s+(?:SESSION|LOCAL)\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format( - msg="频繁使用 SET SESSION/LOCAL 可能影响连接池复用,建议通过连接池统一配置参数" - ), - )) - if re.search(r"\bBEGIN\b", sql_upper, re.IGNORECASE) and not re.search(r"\bCOMMIT\b|\bROLLBACK\b", sql_upper, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=Severity.WARNING, - message=rule.message_template.format( - msg="检测到 BEGIN 但无 COMMIT/ROLLBACK,长时间打开的事务可能导致连接池耗尽和锁堆积" - ), - )) - - -# ── PG-PERF-006 ── -@register_rule(AuditRuleV2( - code="PG-PERF-006", name="PGPerfWorkMem", - category=RuleCategory.PERFORMANCE, severity=Severity.INFO, db_type="postgres", - description="work_mem 配置建议,影响排序和哈希操作性能", - config={"min_work_mem_mb": 4, "recommended_work_mem_mb": 16}, - message_template="内存配置建议: {msg}", -)) -def check_pg_perf_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """work_mem配置建议。""" - has_sort = False - has_hash = False - if "ORDER BY" in sql_upper: - order_cols = re.findall(r"\bORDER\s+BY\s+(.+?)(?:LIMIT|OFFSET|;|$)", sql_upper, re.IGNORECASE) - if order_cols and len(order_cols[0].split(",")) > 2: - has_sort = True - if "GROUP BY" in sql_upper: - has_hash = True - if re.search(r"\bDISTINCT\b", sql_upper, re.IGNORECASE): - has_sort = True - if "WITH" in sql_upper and ("JOIN" in sql_upper or "UNION" in sql_upper): - has_sort = True - if has_sort or has_hash: - rec_mem = rule.config.get("recommended_work_mem_mb", 16) - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format( - msg=f"复杂排序/哈希操作建议确保 work_mem >= {rec_mem}MB,或设置 SET LOCAL work_mem = '{rec_mem}MB'" - ), - )) - - -# ── PG-PERF-007 ── -@register_rule(AuditRuleV2( - code="PG-PERF-007", name="PGPerfParallelQuery", - category=RuleCategory.PERFORMANCE, severity=Severity.INFO, db_type="postgres", - description="并行查询使用规范(max_parallel_workers_per_gather)", - config={"max_parallel_workers": 4, "min_table_size_for_parallel": "8MB"}, - message_template="并行查询建议: {msg}", -)) -def check_pg_perf_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """并行查询使用规范。""" - if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE): - return - is_complex = False - join_count = len(re.findall(r"\bJOIN\b", sql_upper, re.IGNORECASE)) - if join_count >= 2: - is_complex = True - if re.search(r"\bUNION\s+ALL?\b", sql_upper, re.IGNORECASE): - is_complex = True - if re.search(r"\bCOUNT\s*\(|\bSUM\s*\(|\bAVG\s*\(", sql_upper, re.IGNORECASE) and "GROUP BY" in sql_upper: - is_complex = True - if is_complex: - max_workers = rule.config.get("max_parallel_workers", 4) - report.findings.append(AuditFindingV2( - rule_code=rule.code, rule_name=rule.name, level=rule.severity, - message=rule.message_template.format( - msg=f"复杂查询可受益于并行查询,建议确认 max_parallel_workers_per_gather <= {max_workers},并确保 min_parallel_table_scan_size 设置合理" - ), - )) - - -# ═══════════════════════════════════════════════ -# 注册表导出 -# ═══════════════════════════════════════════════ - -PG_RULE_REGISTRY: Dict[str, Tuple[AuditRuleV2, Callable]] = {} -"""PostgreSQL 特有规则注册表 (code -> (rule, check_func))。""" - - -def _init_pg_registry() -> None: - """初始化 PostgreSQL 规则注册表。""" - from app.services.audit_engine_v2 import _RULE_REGISTRY - global PG_RULE_REGISTRY - PG_RULE_REGISTRY = { - code: (rule, func) - for code, (rule, func) in _RULE_REGISTRY.items() - if code.startswith("PG-") - } - - -_init_pg_registry() - - -def get_pg_rules() -> List[AuditRuleV2]: - """获取所有 PostgreSQL 审核规则定义。""" - return sorted( - [rule for rule, _ in PG_RULE_REGISTRY.values()], - key=lambda r: r.code, - ) - - -def get_pg_rules_by_category(category: RuleCategory) -> List[AuditRuleV2]: - """按分类获取 PostgreSQL 审核规则。""" - return [rule for rule in get_pg_rules() if rule.category == category] - - -def get_pg_rule_count() -> Dict[str, int]: - """获取 PostgreSQL 规则统计信息。""" - rules = get_pg_rules() - return { - "total": len(rules), - "ddl": sum(1 for r in rules if r.category == RuleCategory.DDL), - "dml": sum(1 for r in rules if r.category == RuleCategory.DML), - "dql": sum(1 for r in rules if r.category == RuleCategory.DQL), - "security": sum(1 for r in rules if r.category == RuleCategory.SECURITY), - "performance": sum(1 for r in rules if r.category == RuleCategory.PERFORMANCE), - } - - -# ────────────────────────────────────────────── -# 模块加载自检 -# ────────────────────────────────────────────── -if __name__ == "__main__": - stats = get_pg_rule_count() - print("=" * 60) - print("PostgreSQL 审核规则引擎 - 规则统计") - print("=" * 60) - print(f"DDL 规则: {stats['ddl']} 条 (PG-DDL-001 ~ PG-DDL-018)") - print(f"DML 规则: {stats['dml']} 条 (PG-DML-001 ~ PG-DML-008)") - print(f"DQL 规则: {stats['dql']} 条 (PG-DQL-001 ~ PG-DQL-006)") - print(f"安全规则: {stats['security']} 条 (PG-SEC-001 ~ PG-SEC-006)") - print(f"性能规则: {stats['performance']} 条 (PG-PERF-001 ~ PG-PERF-007)") - print("-" * 60) - print(f"总计: {stats['total']} 条规则") - print("=" * 60) - print("\n规则明细:") - for rule in get_pg_rules(): - print(f" {rule.code} | {rule.severity.value:7s} | {rule.name}") diff --git a/backend/app/services/query_history_service.py b/backend/app/services/query_history_service.py deleted file mode 100644 index db0b0b5..0000000 --- a/backend/app/services/query_history_service.py +++ /dev/null @@ -1,451 +0,0 @@ -"""查询历史服务 — 记录和管理用户查询历史(SQLite 持久化)。 - -支持: -- 自动记录每次查询(内容、耗时、结果数、数据库类型) -- 查询历史分页查看 -- 收藏常用查询 -- 快捷查询(个人/团队共享) -- 查询统计(高频表、查询趋势) - -人性化设计: -- 自动记录查询历史,用户无需手动保存 -- 支持收藏常用查询,一键复用 -- 快捷查询模板,减少重复输入 -- 查询统计帮助用户了解自己的数据使用习惯 -""" -import re -from typing import List, Optional, Dict, Any -from datetime import datetime, timedelta -from collections import Counter - -from sqlalchemy import select, func, and_, or_ -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.query_history import QueryHistory, QuickQueryModel - - -class QueryHistoryService: - """查询历史服务(SQLite 持久化)。""" - - _max_history_per_user: int = 500 # 每个用户最多保留500条 - - # ── 查询历史 ── - - @staticmethod - async def record_query( - db: AsyncSession, - user_id: int, - username: str, - instance_id: int, - instance_name: str, - db_type: str, - database_name: str, - query_content: str, - query_type: str, - execution_time_ms: int, - result_rows: int, - is_success: bool = True, - error_message: Optional[str] = None, - ) -> QueryHistory: - """记录一次查询。 - - 每次查询自动调用,无需用户干预。 - """ - entry = QueryHistory( - user_id=user_id, - username=username, - instance_id=instance_id, - instance_name=instance_name, - db_type=db_type, - database_name=database_name, - query_content=query_content[:2000], # 限制长度 - query_type=query_type, - execution_time_ms=execution_time_ms, - result_rows=result_rows, - is_success=is_success, - error_message=error_message, - ) - db.add(entry) - await db.flush() - - # 限制每个用户的历史记录数量 - count_stmt = select(func.count()).select_from(QueryHistory).where( - QueryHistory.user_id == user_id - ) - total = (await db.execute(count_stmt)).scalar() - - if total > QueryHistoryService._max_history_per_user: - # 删除最旧的条目 - to_remove = total - QueryHistoryService._max_history_per_user - old_stmt = ( - select(QueryHistory.id) - .where(QueryHistory.user_id == user_id) - .order_by(QueryHistory.created_at.asc()) - .limit(to_remove) - ) - old_result = await db.execute(old_stmt) - old_ids = [row[0] for row in old_result.all()] - if old_ids: - delete_stmt = QueryHistory.__table__.delete().where( - QueryHistory.id.in_(old_ids) - ) - await db.execute(delete_stmt) - - await db.flush() - return entry - - @staticmethod - async def get_history( - db: AsyncSession, - user_id: int, - db_type: Optional[str] = None, - instance_id: Optional[int] = None, - keyword: Optional[str] = None, - page: int = 1, - page_size: int = 20, - ) -> Dict[str, Any]: - """获取用户查询历史。""" - stmt = select(QueryHistory).where(QueryHistory.user_id == user_id) - - if db_type: - stmt = stmt.where(QueryHistory.db_type == db_type) - if instance_id: - stmt = stmt.where(QueryHistory.instance_id == instance_id) - if keyword: - stmt = stmt.where(QueryHistory.query_content.ilike(f"%{keyword}%")) - - # 总数 - total_stmt = select(func.count()).select_from(stmt.subquery()) - total = (await db.execute(total_stmt)).scalar() - - # 排序 + 分页 - stmt = stmt.order_by(QueryHistory.created_at.desc()) - stmt = stmt.offset((page - 1) * page_size).limit(page_size) - result = await db.execute(stmt) - entries = result.scalars().all() - - return { - "total": total, - "page": page, - "page_size": page_size, - "items": [ - { - "id": e.id, - "instance_name": e.instance_name, - "db_type": e.db_type, - "database_name": e.database_name, - "query_content": (e.query_content or "")[:200], - "query_type": e.query_type, - "execution_time_ms": e.execution_time_ms, - "result_rows": e.result_rows, - "is_success": e.is_success, - "is_favorite": e.is_favorite, - "note": e.note, - "created_at": e.created_at.isoformat() if e.created_at else "", - } - for e in entries - ], - } - - @staticmethod - async def toggle_favorite(db: AsyncSession, user_id: int, history_id: int) -> bool: - """切换收藏状态。""" - stmt = select(QueryHistory).where( - and_( - QueryHistory.id == history_id, - QueryHistory.user_id == user_id, - ) - ) - result = await db.execute(stmt) - entry = result.scalar_one_or_none() - - if entry is None: - # 没有找到该条记录(可能不属于当前用户),直接返回 False - return False - - entry.is_favorite = not entry.is_favorite - await db.flush() - return entry.is_favorite - - @staticmethod - async def get_favorites( - db: AsyncSession, user_id: int, page: int = 1, page_size: int = 20 - ) -> Dict[str, Any]: - """获取收藏的查询。""" - stmt = select(QueryHistory).where( - and_( - QueryHistory.user_id == user_id, - QueryHistory.is_favorite == True, # noqa: E712 - ) - ) - - # 总数 - total_stmt = select(func.count()).select_from(stmt.subquery()) - total = (await db.execute(total_stmt)).scalar() - - # 排序 + 分页 - stmt = stmt.order_by(QueryHistory.created_at.desc()) - stmt = stmt.offset((page - 1) * page_size).limit(page_size) - result = await db.execute(stmt) - entries = result.scalars().all() - - return { - "total": total, - "page": page, - "page_size": page_size, - "items": [ - { - "id": e.id, - "instance_name": e.instance_name, - "db_type": e.db_type, - "database_name": e.database_name, - "query_content": (e.query_content or "")[:200], - "query_type": e.query_type, - "execution_time_ms": e.execution_time_ms, - "result_rows": e.result_rows, - "created_at": e.created_at.isoformat() if e.created_at else "", - } - for e in entries - ], - } - - # ── 快捷查询 ── - - @staticmethod - async def create_quick_query( - db: AsyncSession, - name: str, - description: str, - query_content: str, - db_type: str, - database_pattern: str, - is_shared: bool, - created_by: int, - created_by_name: str, - ) -> QuickQueryModel: - """创建快捷查询。""" - qq = QuickQueryModel( - name=name, - description=description, - query_content=query_content, - db_type=db_type, - database_pattern=database_pattern, - is_shared=is_shared, - created_by=created_by, - created_by_name=created_by_name, - ) - db.add(qq) - await db.flush() - return qq - - @staticmethod - async def get_quick_queries( - db: AsyncSession, - user_id: int, - db_type: Optional[str] = None, - keyword: Optional[str] = None, - ) -> List[Dict[str, Any]]: - """获取快捷查询列表(个人+团队共享)。""" - stmt = select(QuickQueryModel).where( - or_( - QuickQueryModel.is_shared == True, # noqa: E712 - QuickQueryModel.created_by == user_id, - ) - ) - - if db_type: - stmt = stmt.where(QuickQueryModel.db_type == db_type) - if keyword: - stmt = stmt.where(QuickQueryModel.name.ilike(f"%{keyword}%")) - - stmt = stmt.order_by(QuickQueryModel.created_at.desc()) - result = await db.execute(stmt) - queries = result.scalars().all() - - return [ - { - "id": qq.id, - "name": qq.name, - "description": qq.description, - "query_content": (qq.query_content or "")[:500], - "db_type": qq.db_type, - "is_shared": qq.is_shared, - "created_by_name": qq.created_by_name, - "usage_count": qq.usage_count, - "created_at": qq.created_at.isoformat() if qq.created_at else "", - } - for qq in queries - ] - - @staticmethod - async def use_quick_query(db: AsyncSession, quick_query_id: int) -> Optional[str]: - """使用快捷查询,返回查询内容并增加使用次数。""" - stmt = select(QuickQueryModel).where(QuickQueryModel.id == quick_query_id) - result = await db.execute(stmt) - qq = result.scalar_one_or_none() - if qq is None: - return None - qq.usage_count += 1 - await db.flush() - return qq.query_content - - @staticmethod - async def get_query_statistics(db: AsyncSession, user_id: int) -> Dict[str, Any]: - """获取用户查询统计。 - - 人性化:帮助用户了解自己的数据使用习惯。 - """ - # 总数 - count_stmt = select(func.count()).select_from(QueryHistory).where( - QueryHistory.user_id == user_id - ) - total_queries = (await db.execute(count_stmt)).scalar() - - if not total_queries: - return {"message": "暂无查询记录"} - - # 按数据库类型分布 - db_type_stmt = ( - select(QueryHistory.db_type, func.count().label("cnt")) - .where(QueryHistory.user_id == user_id) - .group_by(QueryHistory.db_type) - ) - db_type_result = await db.execute(db_type_stmt) - db_type_dist = {row[0]: row[1] for row in db_type_result.all()} - - # 高频查询表(从SQL中提取表名) - sql_stmt = ( - select(QueryHistory.query_content) - .where( - and_( - QueryHistory.user_id == user_id, - QueryHistory.db_type.in_(["mysql", "postgres"]), - ) - ) - ) - sql_result = await db.execute(sql_stmt) - tables = [] - for row in sql_result.all(): - content = row[0] or "" - found = re.findall(r"FROM\s+(\w+)", content, re.IGNORECASE) - tables.extend(found) - table_dist = Counter(tables).most_common(10) - - # 查询趋势(最近7天) - now = datetime.now() - daily_counts = {} - for i in range(7): - day = (now - timedelta(days=i)).strftime("%Y-%m-%d") - day_start = datetime.strptime(day, "%Y-%m-%d") - day_end = day_start + timedelta(days=1) - day_count_stmt = select(func.count()).select_from(QueryHistory).where( - and_( - QueryHistory.user_id == user_id, - QueryHistory.created_at >= day_start, - QueryHistory.created_at < day_end, - ) - ) - day_count = (await db.execute(day_count_stmt)).scalar() - daily_counts[day] = day_count - - # 平均执行时间 - avg_stmt = select(func.avg(QueryHistory.execution_time_ms)).where( - QueryHistory.user_id == user_id - ) - avg_time = (await db.execute(avg_stmt)).scalar() or 0 - - # 成功率 - success_stmt = select(func.count()).select_from(QueryHistory).where( - and_( - QueryHistory.user_id == user_id, - QueryHistory.is_success == True, # noqa: E712 - ) - ) - success_count = (await db.execute(success_stmt)).scalar() - - return { - "total_queries": total_queries, - "avg_execution_time_ms": round(float(avg_time), 2), - "db_type_distribution": db_type_dist, - "top_tables": [{"table": t, "count": c} for t, c in table_dist], - "daily_trend": daily_counts, - "success_rate": round(success_count / total_queries * 100, 2), - } - - # ── 预置快捷查询模板 ── - - @staticmethod - async def seed_default_quick_queries(db: AsyncSession): - """预置默认快捷查询模板(仅在表为空时插入)。""" - # 检查是否已有数据 - count_stmt = select(func.count()).select_from(QuickQueryModel) - count = (await db.execute(count_stmt)).scalar() - if count and count > 0: - return - - defaults = [ - QuickQueryModel( - name="查看所有表", description="列出数据库中所有表", - query_content="SHOW TABLES", db_type="mysql", database_pattern="*", - is_shared=True, created_by=0, created_by_name="系统", - ), - QuickQueryModel( - name="查看表结构", description="查看指定表的列信息", - query_content="DESCRIBE {table_name}", db_type="mysql", database_pattern="*", - is_shared=True, created_by=0, created_by_name="系统", - ), - QuickQueryModel( - name="查看慢查询", description="MySQL慢查询日志", - query_content="SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 20", - db_type="mysql", database_pattern="*", is_shared=True, - created_by=0, created_by_name="系统", - ), - QuickQueryModel( - name="查看活跃连接", description="当前数据库连接情况", - query_content="SHOW PROCESSLIST", db_type="mysql", database_pattern="*", - is_shared=True, created_by=0, created_by_name="系统", - ), - QuickQueryModel( - name="查看表大小", description="各表占用空间统计", - query_content="""SELECT - table_name AS `表名`, - ROUND(data_length / 1024 / 1024, 2) AS `数据大小(MB)`, - ROUND(index_length / 1024 / 1024, 2) AS `索引大小(MB)`, - table_rows AS `行数` -FROM information_schema.tables -WHERE table_schema = DATABASE() -ORDER BY data_length DESC -LIMIT 50""", db_type="mysql", database_pattern="*", is_shared=True, - created_by=0, created_by_name="系统", - ), - QuickQueryModel( - name="Redis查看所有键", description="SCAN遍历所有键", - query_content="SCAN 0 MATCH * COUNT 50", db_type="redis", - database_pattern="*", is_shared=True, created_by=0, created_by_name="系统", - ), - QuickQueryModel( - name="Redis查看服务器信息", description="Redis INFO命令", - query_content="INFO server", db_type="redis", database_pattern="*", - is_shared=True, created_by=0, created_by_name="系统", - ), - QuickQueryModel( - name="PG查看所有表", description="PostgreSQL列出所有表", - query_content="SELECT schemaname, tablename, n_tup_ins, n_tup_upd, n_tup_del FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 50", - db_type="postgres", database_pattern="*", is_shared=True, - created_by=0, created_by_name="系统", - ), - QuickQueryModel( - name="MongoDB查找文档", description="查找集合中的文档", - query_content='{"collection": "{collection_name}", "operation": "find", "limit": 20}', - db_type="mongo", database_pattern="*", is_shared=True, - created_by=0, created_by_name="系统", - ), - QuickQueryModel( - name="MongoDB统计文档数", description="统计集合文档数量", - query_content='{"collection": "{collection_name}", "operation": "countDocuments"}', - db_type="mongo", database_pattern="*", is_shared=True, - created_by=0, created_by_name="系统", - ), - ] - db.add_all(defaults) - await db.flush() diff --git a/backend/app/services/query_service.py b/backend/app/services/query_service.py deleted file mode 100644 index 16a23c6..0000000 --- a/backend/app/services/query_service.py +++ /dev/null @@ -1,1352 +0,0 @@ -"""统一多数据库查询服务 — 支持 MySQL、PostgreSQL、Redis、MongoDB 的只读查询。 - -核心功能: -1. 统一入口: 根据 db_type 自动路由到对应查询处理器 -2. 安全验证: - - SQL 数据库: 使用 SQLAnalyzer 验证只读 + 增强注入防护 - - Redis: 使用 READONLY_COMMANDS 白名单 + 子命令验证 - - MongoDB: 使用 READONLY_OPERATIONS 白名单 + pipeline 阶段验证 -3. 结果脱敏: 集成 DataMasker 对敏感字段脱敏 -4. 查询限制: 超时控制、行数限制、内存保护 -5. 审计日志: 完整的查询日志记录 - -使用方式: - result = await QueryService.execute_query(instance, database, query, user_id) -""" - -import asyncio -import json -import logging -import re -import time -from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional, Tuple - -from app.models.instance import Instance -from app.schemas.query import UnifiedQueryResult -from app.services.db_connector import DBConnector -from app.services.redis_connector import RedisConnector -from app.services.sql_analyzer import SQLAnalyzer - -logger = logging.getLogger(__name__) - -# ===== 配置常量(默认值,优先从 system_settings 读取) ===== - -# 默认最大返回行数 -MAX_ROWS = 10000 -# 默认查询超时(秒) -QUERY_TIMEOUT = 30 -# 最大查询长度(字符) -MAX_QUERY_LENGTH = 10000 -# 最大结果集内存限制(MB) -MAX_RESULT_MEMORY_MB = 50 - - -async def _get_setting(key: str, default: int) -> int: - """从 system_settings 读取配置,失败则返回默认值。""" - try: - from app.core.database import async_session - from app.models.system_setting import SystemSetting - from sqlalchemy import select - async with async_session() as db: - result = await db.execute( - select(SystemSetting.value).where(SystemSetting.key == key) - ) - val = result.scalar_one_or_none() - return int(val) if val is not None else default - except Exception: - return default - - -async def get_query_config() -> Dict[str, int]: - """批量获取查询相关配置,减少 DB 查询次数。""" - try: - from app.core.database import async_session - from app.models.system_setting import SystemSetting - from sqlalchemy import select - async with async_session() as db: - keys = [ - "query_max_limit", "query_timeout", "query_max_length", - "query_result_max_memory_mb", - "executor_sql_timeout", "executor_redis_timeout", "executor_mongo_timeout", - ] - stmt = select(SystemSetting.key, SystemSetting.value).where( - SystemSetting.key.in_(keys) - ) - result = await db.execute(stmt) - rows = {r[0]: r[1] for r in result.all()} - return { - "max_rows": int(rows.get("query_max_limit", MAX_ROWS)), - "query_timeout": int(rows.get("query_timeout", QUERY_TIMEOUT)), - "max_query_length": int(rows.get("query_max_length", MAX_QUERY_LENGTH)), - "max_memory_mb": int(rows.get("query_result_max_memory_mb", MAX_RESULT_MEMORY_MB)), - "sql_timeout": int(rows.get("executor_sql_timeout", 60)), - "redis_timeout": int(rows.get("executor_redis_timeout", 30)), - "mongo_timeout": int(rows.get("executor_mongo_timeout", 60)), - } - except Exception: - return { - "max_rows": MAX_ROWS, - "query_timeout": QUERY_TIMEOUT, - "max_query_length": MAX_QUERY_LENGTH, - "max_memory_mb": MAX_RESULT_MEMORY_MB, - "sql_timeout": 60, - "redis_timeout": 30, - "mongo_timeout": 60, - } - - -# ===== 动态配置缓存 ===== - -_active_config: Optional[Dict[str, int]] = None - - -async def refresh_config() -> Dict[str, int]: - """刷新配置缓存并返回。启动时和 settings 更新后调用。""" - global _active_config - _active_config = await get_query_config() - return _active_config - - -def current_config() -> Dict[str, int]: - """获取当前配置(同步),未初始化则返回默认值。""" - if _active_config is not None: - return _active_config - return { - "max_rows": MAX_ROWS, - "query_timeout": QUERY_TIMEOUT, - "max_query_length": MAX_QUERY_LENGTH, - "max_memory_mb": MAX_RESULT_MEMORY_MB, - "sql_timeout": 60, - "redis_timeout": 30, - "mongo_timeout": 60, - } - - -# ===== Redis 只读命令白名单 ===== - -REDIS_READONLY_COMMANDS = { - # 字符串操作 - "GET", "MGET", "STRLEN", "GETRANGE", "GETBIT", "SUBSTR", - # 哈希操作 - "HGET", "HGETALL", "HKEYS", "HVALS", "HLEN", "HEXISTS", "HMGET", "HSTRLEN", - # 列表操作 - "LRANGE", "LLEN", "LINDEX", "LPOS", - # 集合操作 - "SMEMBERS", "SCARD", "SISMEMBER", "SRANDMEMBER", "SUNION", "SINTER", "SDIFF", - # 有序集合操作 - "ZRANGE", "ZREVRANGE", "ZRANGEBYSCORE", "ZREVRANGEBYSCORE", - "ZCARD", "ZSCORE", "ZRANK", "ZREVRANK", "ZCOUNT", "ZLEXCOUNT", - "ZRANGEBYLEX", "ZREVRANGEBYLEX", - # 通用操作 - "EXISTS", "TTL", "PTTL", "TYPE", "DBSIZE", "KEYS", - "SCAN", "HSCAN", "SSCAN", "ZSCAN", - "INFO", "CONFIG", "MEMORY", "OBJECT", "SLOWLOG", "CLIENT", "CLUSTER", - # 位图/地理位置/HyperLogLog(只读) - "BITCOUNT", "BITPOS", "GEOPOS", "GEODIST", "GEOHASH", "GEORADIUS_RO", - "GEORADIUSBYMEMBER_RO", "PFCOUNT", - # 其他 - "PING", "ECHO", "TIME", "LASTSAVE", "ROLE", -} - -# 需要特殊处理的子命令: CONFIG GET / MEMORY USAGE / OBJECT ENCODING 等 -REDIS_SUBCOMMAND_ALLOWLIST = { - ("CONFIG", "GET"), - ("CONFIG", "RESETSTAT"), - ("MEMORY", "USAGE"), - ("MEMORY", "STATS"), - ("MEMORY", "DOCTOR"), - ("OBJECT", "ENCODING"), - ("OBJECT", "IDLETIME"), - ("OBJECT", "REFCOUNT"), - ("OBJECT", "FREQ"), - ("CLIENT", "LIST"), - ("CLIENT", "INFO"), - ("CLUSTER", "INFO"), - ("CLUSTER", "NODES"), - ("CLUSTER", "SLOTS"), - ("SLOWLOG", "GET"), - ("SLOWLOG", "LEN"), -} - -# Redis 危险命令黑名单(即使被误分类为只读也禁止) -REDIS_COMMAND_BLACKLIST = { - "FLUSHDB", "FLUSHALL", "SHUTDOWN", "DEBUG", "SAVE", "BGSAVE", - "BGREWRITEAOF", "SLAVEOF", "REPLICAOF", "SYNC", "PSYNC", - "CONFIG", "ACL", "MODULE", "COMMAND", -} - -# ===== MongoDB 只读操作白名单 ===== - -MONGODB_READONLY_OPS = { - "find", "findOne", "countDocuments", "estimatedDocumentCount", - "distinct", "aggregate", -} - -MONGODB_WRITE_OPS = { - "insertOne", "insertMany", "updateOne", "updateMany", - "replaceOne", "deleteOne", "deleteMany", "drop", - "createIndex", "dropIndex", "renameCollection", - "findOneAndUpdate", "findOneAndReplace", "findOneAndDelete", - "bulkWrite", -} - -# MongoDB aggregate 危险阶段黑名单 -MONGODB_AGGREGATE_BLACKLIST = { - "$out", "$merge", "$currentOp", "$listLocalSessions", -} - -# ===== SQL 注入防护模式 ===== - -# 危险的 SQL 注释模式(用于注释掉合法查询尾部) -SQL_COMMENT_PATTERNS = [ - r"--\s*$", # 行尾注释 - r"/\*.*\*/", # 块注释 - r"#\s*$", # MySQL 风格注释 -] - -# 危险的 UNION 注入模式 -SQL_UNION_INJECTION_PATTERN = re.compile( - r"\bUNION\s+(ALL\s+)?SELECT\b", re.IGNORECASE -) - -# 堆叠查询检测(多条语句) -SQL_STACKED_PATTERN = re.compile(r"\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|EXEC|EXECUTE)\b", re.IGNORECASE) - -# 时间延迟注入检测 -SQL_TIMING_ATTACK_PATTERN = re.compile( - r"\b(BENCHMARK|SLEEP|PG_SLEEP|WAITFOR\s+DELAY|WAITFOR\s+TIME)\s*\(", - re.IGNORECASE, -) - - -class QuerySecurityError(ValueError): - """查询安全验证失败异常。""" - pass - - -class QueryTimeoutError(ValueError): - """查询超时异常。""" - pass - - -class QueryResultTooLargeError(ValueError): - """查询结果过大异常。""" - pass - - -# ===== 查询日志记录器 ===== - - -class QueryLogger: - """查询操作日志记录器。 - - 记录每次查询的完整上下文,用于安全审计和问题追溯。 - """ - - @staticmethod - def log_query( - user_id: int, - db_type: str, - database: Optional[str], - query: str, - elapsed_ms: int, - row_count: int, - is_success: bool, - error_msg: Optional[str] = None, - client_ip: Optional[str] = None, - ) -> None: - """记录查询日志。 - - Args: - user_id: 查询用户ID - db_type: 数据库类型 - database: 目标数据库 - query: 查询内容(已脱敏) - elapsed_ms: 执行耗时 - row_count: 返回行数 - is_success: 是否成功 - error_msg: 错误信息 - client_ip: 客户端IP - """ - log_data = { - "event": "query_executed", - "user_id": user_id, - "db_type": db_type, - "database": database, - "query_preview": query[:500] if query else "", - "query_length": len(query) if query else 0, - "elapsed_ms": elapsed_ms, - "row_count": row_count, - "is_success": is_success, - "client_ip": client_ip, - } - if error_msg: - log_data["error"] = error_msg[:500] - - if is_success: - logger.info("Query executed: %s", json.dumps(log_data, ensure_ascii=False, default=str)) - else: - logger.warning("Query failed: %s", json.dumps(log_data, ensure_ascii=False, default=str)) - - @staticmethod - def log_security_event( - event_type: str, - user_id: int, - db_type: str, - query: str, - reason: str, - client_ip: Optional[str] = None, - ) -> None: - """记录安全事件日志。 - - Args: - event_type: 事件类型(injection_attempt, unauthorized_command 等) - user_id: 用户ID - db_type: 数据库类型 - query: 查询内容 - reason: 阻止原因 - client_ip: 客户端IP - """ - log_data = { - "event": "security_event", - "event_type": event_type, - "user_id": user_id, - "db_type": db_type, - "query_preview": query[:500] if query else "", - "reason": reason, - "client_ip": client_ip, - } - logger.warning("Security event: %s", json.dumps(log_data, ensure_ascii=False, default=str)) - - -# ===== 查询处理器基类 ===== - - -class BaseQueryHandler(ABC): - """查询处理器基类。 - - 所有数据库类型的查询处理器必须继承此基类, - 并实现 execute 和 validate_readonly 方法。 - """ - - @abstractmethod - async def execute( - self, - instance: Instance, - database: Optional[str], - query: str, - user_id: int, - options: Optional[Dict[str, Any]] = None, - ) -> UnifiedQueryResult: - """执行查询并返回统一格式的结果。 - - Args: - instance: 数据源实例配置 - database: 目标数据库名/Redis DB号/MongoDB collection - query: 查询语句(SQL/Redis命令/MongoDB查询JSON) - user_id: 查询用户ID - options: 额外选项(如 limit, offset 等) - - Returns: - UnifiedQueryResult: 统一格式的查询结果 - """ - pass - - @abstractmethod - def validate_readonly(self, query: str) -> Tuple[bool, str]: - """验证查询是否为只读。 - - Args: - query: 查询语句 - - Returns: - (is_safe, error_message): 是否安全及错误信息 - """ - pass - - def _validate_query_length(self, query: str) -> None: - """验证查询长度是否在允许范围内。 - - Args: - query: 查询语句 - - Raises: - QuerySecurityError: 查询过长 - """ - if not query or not query.strip(): - raise QuerySecurityError("查询语句不能为空") - cfg = current_config() - if len(query) > cfg["max_query_length"]: - max_len = cfg["max_query_length"] - raise QuerySecurityError( - f"查询语句过长({len(query)} 字符),最大允许 {max_len} 字符" - ) - - def _check_result_size(self, rows: List[Dict[str, Any]]) -> bool: - """检查结果集大小是否超过内存限制。 - - Args: - rows: 结果行列表 - - Returns: - 是否超过限制 - """ - if not rows: - return False - # 估算内存使用(粗略) - try: - size_bytes = len(json.dumps(rows, default=str).encode("utf-8")) - size_mb = size_bytes / (1024 * 1024) - return size_mb > current_config()["max_memory_mb"] - except (OverflowError, MemoryError): - return True - - -# ===== SQL 查询处理器(MySQL / PostgreSQL 共用基类) ===== - - -class SQLQueryHandlerBase(BaseQueryHandler): - """SQL 查询处理器基类(MySQL 和 PostgreSQL 共用逻辑)。""" - - _analyzer = SQLAnalyzer() - - def validate_readonly(self, query: str) -> Tuple[bool, str]: - """使用 SQLAnalyzer 验证 SQL 是否为只读。 - - Args: - query: SQL 查询语句 - - Returns: - (is_safe, error_message) - """ - is_safe, error_msg = self._analyzer.validate_readonly(query) - return is_safe, error_msg - - def _enhanced_security_check(self, query: str, user_id: int) -> None: - """增强安全验证 — 检测 SQL 注入攻击模式。 - - 检测项: - 1. 查询长度限制 - 2. 堆叠查询(多条语句) - 3. UNION 注入 - 4. 时间延迟攻击 - 5. 注释攻击 - 6. 危险的子查询 - - Args: - query: SQL 查询语句 - user_id: 查询用户ID - - Raises: - QuerySecurityError: 检测到安全问题 - """ - # 1. 长度验证 - self._validate_query_length(query) - - upper_query = query.upper().strip() - - # 2. 检测堆叠查询(尝试执行多条语句) - stripped = query.strip().rstrip(";").strip() - in_string = False - string_char = None - for ch in stripped: - if ch in ("'", '"') and not in_string: - in_string = True - string_char = ch - elif ch == string_char and in_string: - # 检查转义 - string_char = None - in_string = False - elif ch == ";" and not in_string: - raise QuerySecurityError( - "检测到多条 SQL 语句(分号分隔),可能存在堆叠查询注入风险" - ) - - # 3. 检测 UNION 注入(只允许以 SELECT 或 WITH 或 EXPLAIN 开头) - allowed_prefixes = ("SELECT", "WITH", "EXPLAIN", "SHOW", "DESCRIBE", "DESC") - first_word = upper_query.split()[0] if upper_query else "" - if first_word not in allowed_prefixes: - # 进一步检查是否是 EXPLAIN SELECT 或 SHOW ... - if not any(upper_query.startswith(p) for p in allowed_prefixes): - raise QuerySecurityError( - f"查询必须以 SELECT/WITH/EXPLAIN/SHOW 开头,当前以 '{first_word}' 开头" - ) - - # 4. 检测时间延迟攻击 - if SQL_TIMING_ATTACK_PATTERN.search(query): - QueryLogger.log_security_event( - event_type="timing_attack_detected", - user_id=user_id, - db_type="sql", - query=query, - reason="检测到时间延迟函数(SLEEP/BENCHMARK等)", - ) - raise QuerySecurityError("查询包含时间延迟函数,已被安全系统拦截") - - # 5. 检测 INTO OUTFILE/DUMPFILE(文件写入) - if "INTO OUTFILE" in upper_query or "INTO DUMPFILE" in upper_query: - QueryLogger.log_security_event( - event_type="file_write_attempt", - user_id=user_id, - db_type="sql", - query=query, - reason="检测到 INTO OUTFILE/DUMPFILE 文件写入", - ) - raise QuerySecurityError("SELECT INTO OUTFILE/DUMPFILE 被禁止") - - # 6. 检测危险的存储过程调用 - if re.search(r"\b(Exec|Execute|Sp_|Xp_)", query, re.IGNORECASE): - raise QuerySecurityError("存储过程调用在查询模式中被禁止") - - # 不需要添加 LIMIT 的语句前缀 - _NO_LIMIT_PREFIXES = ("SHOW", "DESC", "DESCRIBE", "EXPLAIN", "WITH") - - def _auto_add_limit(self, sql: str, max_rows: int = -1) -> str: - """如果 SQL 没有 LIMIT 则自动添加。仅对 SELECT 语句生效。 - - Args: - sql: 原始 SQL - max_rows: 最大返回行数(-1 表示从配置读取) - - Returns: - 添加了 LIMIT 的 SQL - """ - if max_rows < 0: - max_rows = current_config()["max_rows"] - upper_sql = sql.strip().upper() - # SHOW / DESC / EXPLAIN 等语句不支持 LIMIT(或语义不同),跳过 - if any(upper_sql.startswith(p) for p in self._NO_LIMIT_PREFIXES): - return sql - # 仅对 SELECT 语句添加 LIMIT - if not upper_sql.startswith("SELECT"): - return sql - if not self._has_limit_clause(upper_sql): - sql = sql.rstrip(";") + f" LIMIT {max_rows}" - return sql - - @staticmethod - def _has_limit_clause(sql_upper: str) -> bool: - """安全地检测 SQL 是否已包含 LIMIT 子句。 - - 使用简单的词法分析,避免字符串内容的干扰。 - - Args: - sql_upper: 大写的 SQL 语句 - - Returns: - 是否包含 LIMIT 子句 - """ - # 简单但有效的检测:在字符串外查找 LIMIT 关键字 - tokens = re.split(r"('[^']*'|'[^']*)", sql_upper) - # 偶数索引的 token 在字符串外 - for i in range(0, len(tokens), 2): - if re.search(r"\bLIMIT\s+\d+", tokens[i]): - return True - return False - - -class MySQLQueryHandler(SQLQueryHandlerBase): - """MySQL 查询处理器。 - - 使用 DBConnector._query_mysql 执行查询, - SQLAnalyzer 验证只读性,自动添加 LIMIT,超时控制。 - """ - - async def execute( - self, - instance: Instance, - database: Optional[str], - query: str, - user_id: int, - options: Optional[Dict[str, Any]] = None, - ) -> UnifiedQueryResult: - """执行 MySQL 只读查询。 - - Args: - instance: MySQL 实例配置 - database: 目标数据库名 - query: SQL 查询语句 - user_id: 查询用户ID - options: 额外选项 - - Returns: - UnifiedQueryResult: 统一格式的查询结果 - - Raises: - QuerySecurityError: 查询不安全 - QueryTimeoutError: 查询超时 - """ - options = options or {} - custom_timeout = options.get("timeout", current_config()["query_timeout"]) - custom_max_rows = options.get("limit", current_config()["max_rows"]) - custom_max_rows = min(custom_max_rows, current_config()["max_rows"]) # 不允许超过全局上限 - - # 1. 增强安全验证 - self._enhanced_security_check(query, user_id) - - # 2. 验证只读 - is_safe, error_msg = self.validate_readonly(query) - if not is_safe: - raise QuerySecurityError(error_msg) - - # 3. 自动添加 LIMIT - query = self._auto_add_limit(query, custom_max_rows) - - # 4. 执行查询 - start = time.time() - rows: List[Dict[str, Any]] = [] - columns: List[str] = [] - try: - rows, columns = await asyncio.wait_for( - DBConnector._query_mysql(instance, database, query), - timeout=custom_timeout, - ) - except asyncio.TimeoutError: - QueryLogger.log_query( - user_id=user_id, - db_type="mysql", - database=database, - query=query, - elapsed_ms=int((time.time() - start) * 1000), - row_count=0, - is_success=False, - error_msg=f"查询超时({custom_timeout}秒)", - ) - raise QueryTimeoutError(f"查询超时({custom_timeout}秒)") - - elapsed = int((time.time() - start) * 1000) - row_count = len(rows) if rows else 0 - - # 5. 检查结果集大小 - truncated = row_count >= custom_max_rows - if self._check_result_size(rows if rows else []): - truncated = True - # 截断结果以保护内存 - rows = rows[:custom_max_rows] if rows else [] - logger.warning("查询结果超过内存限制 %dMB,已截断", current_config()["max_memory_mb"]) - - # 6. 记录查询日志 - QueryLogger.log_query( - user_id=user_id, - db_type="mysql", - database=database, - query=query, - elapsed_ms=elapsed, - row_count=row_count, - is_success=True, - ) - - return UnifiedQueryResult( - columns=columns, - rows=rows if rows else [], - raw_result=None, - total=row_count, - elapsed_ms=elapsed, - truncated=truncated, - db_type="mysql", - query_type="SELECT", - ) - - -class PostgresQueryHandler(SQLQueryHandlerBase): - """PostgreSQL 查询处理器。 - - 使用 DBConnector._query_postgres 执行查询, - SQLAnalyzer 验证只读性(SQL 标准兼容),自动添加 LIMIT,超时控制。 - """ - - async def execute( - self, - instance: Instance, - database: Optional[str], - query: str, - user_id: int, - options: Optional[Dict[str, Any]] = None, - ) -> UnifiedQueryResult: - """执行 PostgreSQL 只读查询。 - - Args: - instance: PostgreSQL 实例配置 - database: 目标数据库名 - query: SQL 查询语句 - user_id: 查询用户ID - options: 额外选项 - - Returns: - UnifiedQueryResult: 统一格式的查询结果 - - Raises: - QuerySecurityError: 查询不安全 - QueryTimeoutError: 查询超时 - """ - options = options or {} - custom_timeout = options.get("timeout", current_config()["query_timeout"]) - custom_max_rows = options.get("limit", current_config()["max_rows"]) - custom_max_rows = min(custom_max_rows, current_config()["max_rows"]) - - # 1. 增强安全验证 - self._enhanced_security_check(query, user_id) - - # 2. 验证只读 - is_safe, error_msg = self.validate_readonly(query) - if not is_safe: - raise QuerySecurityError(error_msg) - - # 3. 自动添加 LIMIT - query = self._auto_add_limit(query, custom_max_rows) - - # 4. 执行查询 - start = time.time() - rows: List[Dict[str, Any]] = [] - columns: List[str] = [] - try: - rows, columns = await asyncio.wait_for( - DBConnector._query_postgres(instance, database, query), - timeout=custom_timeout, - ) - except asyncio.TimeoutError: - QueryLogger.log_query( - user_id=user_id, - db_type="postgres", - database=database, - query=query, - elapsed_ms=int((time.time() - start) * 1000), - row_count=0, - is_success=False, - error_msg=f"查询超时({custom_timeout}秒)", - ) - raise QueryTimeoutError(f"查询超时({custom_timeout}秒)") - - elapsed = int((time.time() - start) * 1000) - row_count = len(rows) if rows else 0 - - # 5. 检查结果集大小 - truncated = row_count >= custom_max_rows - if self._check_result_size(rows if rows else []): - truncated = True - rows = rows[:custom_max_rows] if rows else [] - logger.warning("查询结果超过内存限制 %dMB,已截断", current_config()["max_memory_mb"]) - - # 6. 记录查询日志 - QueryLogger.log_query( - user_id=user_id, - db_type="postgres", - database=database, - query=query, - elapsed_ms=elapsed, - row_count=row_count, - is_success=True, - ) - - return UnifiedQueryResult( - columns=columns, - rows=rows if rows else [], - raw_result=None, - total=row_count, - elapsed_ms=elapsed, - truncated=truncated, - db_type="postgres", - query_type="SELECT", - ) - - -# ===== Redis 查询处理器 ===== - - -class RedisQueryHandler(BaseQueryHandler): - """Redis 查询处理器。 - - 使用 READONLY_COMMANDS 白名单 + BLACKLIST 验证命令安全性, - RedisConnector.execute_command 执行命令, - 返回格式化为表格/JSON 的结果。 - """ - - def validate_readonly(self, query: str) -> Tuple[bool, str]: - """验证 Redis 命令是否在只读白名单中。 - - 增强验证: - 1. 命令黑名单检查 - 2. 子命令白名单检查 - 3. 参数注入防护(检测管道符、shell注入) - - Args: - query: Redis 命令字符串,如 "GET mykey" - - Returns: - (is_safe, error_message) - """ - if not query or not query.strip(): - return False, "Redis 命令不能为空" - - # 长度验证 - if len(query) > current_config()["max_query_length"]: - max_len = current_config()["max_query_length"] - return False, f"Redis 命令过长({len(query)} 字符),最大允许 {max_len}" - - # 检测 shell 注入(管道符、重定向、命令替换) - shell_injection_chars = ["|", ";", "&", "$", "`", "$(", ">", "<"] - for char in shell_injection_chars: - if char in query: - return False, f"Redis 命令包含非法字符 '{char}',可能存在注入风险" - - parts = query.strip().split() - if not parts: - return False, "Redis 命令不能为空" - - cmd = parts[0].upper() - - # 检查命令黑名单(最严格的拦截) - if cmd in REDIS_COMMAND_BLACKLIST: - # 但允许 CONFIG GET 等白名单子命令 - if len(parts) >= 2: - sub = parts[1].upper() - subcmd_key = (cmd, sub) - if subcmd_key in REDIS_SUBCOMMAND_ALLOWLIST: - return True, "" - return False, f"Redis 命令 '{cmd}' 已被列入黑名单,禁止执行" - - # 检查子命令(如 CONFIG GET, MEMORY USAGE) - if len(parts) >= 2: - sub = parts[1].upper() - subcmd_key = (cmd, sub) - # 如果是已知的多词命令,检查是否在允许列表中 - if cmd in ("CONFIG", "MEMORY", "OBJECT", "CLIENT", "CLUSTER", "SLOWLOG"): - if subcmd_key not in REDIS_SUBCOMMAND_ALLOWLIST: - return False, f"Redis 命令 {cmd} {sub} 不在只读白名单中" - return True, "" - - if cmd not in REDIS_READONLY_COMMANDS: - return False, f"Redis 命令 '{cmd}' 不在只读白名单中,禁止执行" - - return True, "" - - async def execute( - self, - instance: Instance, - database: Optional[str], - query: str, - user_id: int, - options: Optional[Dict[str, Any]] = None, - ) -> UnifiedQueryResult: - """执行 Redis 只读命令。 - - Args: - instance: Redis 实例配置 - database: Redis DB 号(字符串,如 "0") - query: Redis 命令字符串 - user_id: 查询用户ID - options: 额外选项 - - Returns: - UnifiedQueryResult: 统一格式的查询结果 - - Raises: - QuerySecurityError: 命令不安全 - QueryTimeoutError: 命令超时 - """ - options = options or {} - custom_timeout = options.get("timeout", current_config()["query_timeout"]) - - # 1. 验证只读 - is_safe, error_msg = self.validate_readonly(query) - if not is_safe: - QueryLogger.log_security_event( - event_type="unauthorized_redis_command", - user_id=user_id, - db_type="redis", - query=query, - reason=error_msg, - ) - raise QuerySecurityError(error_msg) - - # 2. 解析 DB 号 - try: - db = int(database) if database else 0 - if db < 0 or db > 15: - raise ValueError("Redis DB 号必须在 0-15 之间") - except (ValueError, TypeError): - raise QuerySecurityError("Redis DB 号必须是 0-15 之间的整数") - - # 3. 执行命令 - start = time.time() - try: - result_str = await asyncio.wait_for( - RedisConnector.execute_command(instance, db, query), - timeout=custom_timeout, - ) - except asyncio.TimeoutError: - QueryLogger.log_query( - user_id=user_id, - db_type="redis", - database=f"DB{db}", - query=query, - elapsed_ms=int((time.time() - start) * 1000), - row_count=0, - is_success=False, - error_msg=f"Redis 命令超时({custom_timeout}秒)", - ) - raise QueryTimeoutError(f"Redis 命令超时({custom_timeout}秒)") - except Exception as e: - raise QuerySecurityError(f"Redis 命令执行失败: {str(e)}") - - elapsed = int((time.time() - start) * 1000) - - # 4. 格式化结果为表格形式 - parts = query.strip().split() - cmd = parts[0].upper() if parts else "" - - columns, rows = self._format_result(cmd, result_str) - - # 5. 记录查询日志 - QueryLogger.log_query( - user_id=user_id, - db_type="redis", - database=f"DB{db}", - query=query, - elapsed_ms=elapsed, - row_count=len(rows), - is_success=True, - ) - - return UnifiedQueryResult( - columns=columns, - rows=rows, - raw_result=result_str, - total=len(rows), - elapsed_ms=elapsed, - truncated=False, - db_type="redis", - query_type=cmd, - ) - - def _format_result(self, cmd: str, result: str) -> Tuple[List[str], List[Dict[str, Any]]]: - """将 Redis 原始结果格式化为表格。 - - Args: - cmd: Redis 命令名 - result: 原始结果字符串 - - Returns: - (columns, rows): 列名列表和行数据列表 - """ - if not result or result == "(nil)": - return ["result"], [{"result": "(nil)"}] - - lines = result.split("\n") - - # 对于返回列表的命令(KEYS, HKEYS, HVALS, SMEMBERS 等) - list_commands = {"KEYS", "HKEYS", "HVALS", "SMEMBERS", "LRANGE", "SINTER", "SUNION", "SDIFF"} - if cmd in list_commands and len(lines) > 1: - return ["value"], [{"value": line} for line in lines if line.strip()] - - # 对于 INFO 命令,返回 key-value 表格 - if cmd == "INFO": - rows = [] - for line in lines: - line = line.strip() - if line and not line.startswith("#") and ":" in line: - k, v = line.split(":", 1) - rows.append({"key": k.strip(), "value": v.strip()}) - if rows: - return ["key", "value"], rows - - # 对于 CONFIG GET,返回 key-value 表格 - if cmd == "CONFIG": - rows = [] - for i in range(0, len(lines), 2): - if i + 1 < len(lines): - rows.append({"key": lines[i].strip(), "value": lines[i + 1].strip()}) - elif lines[i].strip(): - rows.append({"key": lines[i].strip(), "value": ""}) - if rows: - return ["key", "value"], rows - - # 对于 HGETALL,返回 field-value 表格 - if cmd == "HGETALL": - rows = [] - for i in range(0, len(lines), 2): - if i + 1 < len(lines): - rows.append({"field": lines[i].strip(), "value": lines[i + 1].strip()}) - if rows: - return ["field", "value"], rows - - # 默认: 返回单行结果 - return ["result"], [{"result": result}] - - -# ===== MongoDB 查询处理器 ===== - - -class MongoQueryHandler(BaseQueryHandler): - """MongoDB 查询处理器。 - - 使用 READONLY_OPERATIONS 白名单验证操作安全性, - motor 异步驱动执行查询, - 增强的注入防护和结果大小限制。 - """ - - def validate_readonly(self, query: str) -> Tuple[bool, str]: - """验证 MongoDB 操作是否在只读白名单中。 - - 增强验证: - 1. 严格的 JSON 解析 - 2. 操作类型白名单 - 3. aggregate pipeline 阶段检查 - 4. 禁止 $where 和 JavaScript 执行 - - Args: - query: MongoDB 查询 JSON 字符串 - - Returns: - (is_safe, error_message) - """ - if not query or not query.strip(): - return False, "MongoDB 查询不能为空" - - # 长度验证 - if len(query) > current_config()["max_query_length"]: - max_len = current_config()["max_query_length"] - return False, f"MongoDB 查询过长({len(query)} 字符),最大允许 {max_len}" - - try: - query_obj = json.loads(query) - except json.JSONDecodeError as e: - return False, f"MongoDB 查询 JSON 格式错误: {str(e)}" - - if not isinstance(query_obj, dict): - return False, "MongoDB 查询必须是 JSON 对象" - - operation = query_obj.get("operation", "") - if not operation: - return False, "MongoDB 查询必须指定 'operation' 字段" - - if operation in MONGODB_WRITE_OPS: - return False, f"MongoDB 操作 '{operation}' 是写操作,禁止执行" - - if operation not in MONGODB_READONLY_OPS: - return False, f"MongoDB 操作 '{operation}' 不在允许的操作列表中" - - # 对 aggregate 进行额外检查: 禁止 $out, $merge 等写阶段 - if operation == "aggregate": - pipeline = query_obj.get("pipeline", []) - if not isinstance(pipeline, list): - return False, "aggregate 的 pipeline 必须是数组" - for idx, stage in enumerate(pipeline): - if not isinstance(stage, dict): - return False, f"aggregate pipeline 第 {idx} 阶段必须是对象" - stage_name = list(stage.keys())[0] if stage else "" - if stage_name in MONGODB_AGGREGATE_BLACKLIST: - return False, f"aggregate 管道中的 '{stage_name}' 阶段是写操作,禁止执行" - - # 禁止 $where 子句(JavaScript 执行风险) - filter_doc = query_obj.get("filter", {}) - if isinstance(filter_doc, dict) and "$where" in filter_doc: - return False, "MongoDB 查询禁止使用 $where 子句(JavaScript 执行风险)" - - # 禁止 $function, $accumulator 等可执行 JavaScript 的操作符 - js_operators = {"$function", "$accumulator"} - query_str = json.dumps(query_obj) - for op in js_operators: - if op in query_str: - return False, f"MongoDB 查询禁止使用 '{op}' 操作符(JavaScript 执行风险)" - - return True, "" - - async def execute( - self, - instance: Instance, - database: Optional[str], - query: str, - user_id: int, - options: Optional[Dict[str, Any]] = None, - ) -> UnifiedQueryResult: - """执行 MongoDB 只读查询。 - - Args: - instance: MongoDB 实例配置 - database: MongoDB 数据库名 - query: MongoDB 查询 JSON 字符串 - user_id: 查询用户ID - options: 额外选项 - - Returns: - UnifiedQueryResult: 统一格式的查询结果 - - Raises: - QuerySecurityError: 操作不安全 - QueryTimeoutError: 查询超时 - """ - options = options or {} - custom_timeout = options.get("timeout", current_config()["query_timeout"]) - custom_max_rows = options.get("limit", current_config()["max_rows"]) - custom_max_rows = min(custom_max_rows, current_config()["max_rows"]) - - # 1. 验证只读 - is_safe, error_msg = self.validate_readonly(query) - if not is_safe: - QueryLogger.log_security_event( - event_type="unauthorized_mongo_operation", - user_id=user_id, - db_type="mongo", - query=query, - reason=error_msg, - ) - raise QuerySecurityError(error_msg) - - # 2. 解析查询 JSON - try: - query_obj = json.loads(query) - except json.JSONDecodeError as e: - raise QuerySecurityError(f"MongoDB 查询 JSON 格式错误: {str(e)}") - - collection_name = query_obj.get("collection", database or "") - operation = query_obj.get("operation", "") - filter_doc = query_obj.get("filter", {}) - projection = query_obj.get("projection", None) - limit_val = min(query_obj.get("limit", custom_max_rows), custom_max_rows) - skip_val = query_obj.get("skip", 0) - sort_doc = query_obj.get("sort", None) - pipeline = query_obj.get("pipeline", []) - - if not collection_name: - raise QuerySecurityError("MongoDB 查询必须指定 'collection' 字段或在 database 参数中提供") - - # 验证 collection 名称安全(防止 NoSQL 注入) - if not re.match(r"^[\w]+$", collection_name): - raise QuerySecurityError("MongoDB collection 名称只能包含字母数字下划线") - - # 3. 导入 MongoConnector(延迟导入避免循环依赖) - from app.services.mongo_connector import MongoConnector - - start = time.time() - rows: List[Dict[str, Any]] = [] - columns: List[str] = [] - total = 0 - - try: - target_db = database or instance.database or "" - - if operation == "find": - docs, total = await asyncio.wait_for( - MongoConnector.execute_find( - instance, target_db, - collection_name, filter_doc, projection, - limit_val, skip_val, sort_doc, - ), - timeout=custom_timeout, - ) - columns = list(docs[0].keys()) if docs else [] - # 将 _id 转换为字符串以便 JSON 序列化 - for doc in docs: - if "_id" in doc: - doc["_id"] = str(doc["_id"]) - rows = docs - - elif operation == "findOne": - doc = await asyncio.wait_for( - MongoConnector.execute_find_one( - instance, target_db, - collection_name, filter_doc, projection, - ), - timeout=custom_timeout, - ) - if doc: - if "_id" in doc: - doc["_id"] = str(doc["_id"]) - columns = list(doc.keys()) - rows = [doc] - total = 1 - else: - columns, rows, total = [], [], 0 - - elif operation == "countDocuments": - count = await asyncio.wait_for( - MongoConnector.execute_count( - instance, target_db, - collection_name, filter_doc, - ), - timeout=custom_timeout, - ) - columns = ["count"] - rows = [{"count": count}] - total = 1 - - elif operation == "estimatedDocumentCount": - count = await asyncio.wait_for( - MongoConnector.execute_estimated_count( - instance, target_db, - collection_name, - ), - timeout=custom_timeout, - ) - columns = ["count"] - rows = [{"count": count}] - total = 1 - - elif operation == "distinct": - field = query_obj.get("field", "") - if not field: - raise QuerySecurityError("distinct 操作必须指定 'field' 字段") - # 验证字段名安全 - if not re.match(r"^[\w.]+$", field): - raise QuerySecurityError("字段名只能包含字母数字下划线和点号") - values = await asyncio.wait_for( - MongoConnector.execute_distinct( - instance, target_db, - collection_name, field, filter_doc, - ), - timeout=custom_timeout, - ) - columns = ["value"] - rows = [{"value": str(v)} for v in values] - total = len(values) - - elif operation == "aggregate": - docs = await asyncio.wait_for( - MongoConnector.execute_aggregate( - instance, target_db, - collection_name, pipeline, - ), - timeout=custom_timeout, - ) - columns = list(docs[0].keys()) if docs else [] - for doc in docs: - if "_id" in doc: - doc["_id"] = str(doc["_id"]) - rows = docs - total = len(docs) - - else: - raise QuerySecurityError(f"不支持的 MongoDB 操作: {operation}") - - except asyncio.TimeoutError: - QueryLogger.log_query( - user_id=user_id, - db_type="mongo", - database=database, - query=query, - elapsed_ms=int((time.time() - start) * 1000), - row_count=0, - is_success=False, - error_msg=f"MongoDB 查询超时({custom_timeout}秒)", - ) - raise QueryTimeoutError(f"MongoDB 查询超时({custom_timeout}秒)") - except ValueError: - raise - except Exception as e: - raise QuerySecurityError(f"MongoDB 查询执行失败: {str(e)}") - - elapsed = int((time.time() - start) * 1000) - - # 4. 检查结果集大小 - truncated = total >= custom_max_rows - if self._check_result_size(rows): - truncated = True - rows = rows[:custom_max_rows] - logger.warning("MongoDB 查询结果超过内存限制 %dMB,已截断", current_config()["max_memory_mb"]) - - # 5. 记录查询日志 - QueryLogger.log_query( - user_id=user_id, - db_type="mongo", - database=database, - query=query, - elapsed_ms=elapsed, - row_count=total, - is_success=True, - ) - - return UnifiedQueryResult( - columns=columns, - rows=rows, - raw_result={"operation": operation, "collection": collection_name}, - total=total, - elapsed_ms=elapsed, - truncated=truncated, - db_type="mongo", - query_type=operation, - ) - - -# ===== 统一查询服务入口 ===== - - -class QueryService: - """统一多数据库查询服务入口。 - - 根据实例的 db_type 自动路由到对应的查询处理器, - 支持 MySQL、PostgreSQL、Redis、MongoDB 四种数据库。 - - Examples: - >>> result = await QueryService.execute_query(instance, database, query, user_id) - >>> print(result.rows) - """ - - _handlers: Dict[str, BaseQueryHandler] = { - "mysql": MySQLQueryHandler(), - "postgres": PostgresQueryHandler(), - "postgresql": PostgresQueryHandler(), - "redis": RedisQueryHandler(), - "mongo": MongoQueryHandler(), - } - - @staticmethod - async def execute_query( - instance: Instance, - database: Optional[str], - query: str, - user_id: int, - options: Optional[Dict[str, Any]] = None, - ) -> UnifiedQueryResult: - """统一查询入口。 - - 根据 instance.db_type 自动路由到对应查询处理器, - 执行安全验证后返回统一格式的结果。 - - Args: - instance: 数据源实例配置 - database: 目标数据库/Redis DB号/MongoDB collection - query: 查询语句(SQL/Redis命令/MongoDB查询JSON) - user_id: 查询用户ID - options: 额外选项(如 limit, offset, timeout 等) - - Returns: - UnifiedQueryResult: 统一格式的查询结果 - - Raises: - QuerySecurityError: 不支持的数据库类型或查询不安全 - QueryTimeoutError: 查询超时 - """ - db_type = instance.db_type.lower() - handler = QueryService._handlers.get(db_type) - - if handler is None: - raise QuerySecurityError(f"暂不支持 {instance.db_type} 查询") - - # 每次查询刷新配置缓存(轻量:仅查一次 DB) - await refresh_config() - - return await handler.execute(instance, database, query, user_id, options) - - @staticmethod - async def execute_dml( - instance: Instance, database: str, sql: str - ) -> str: - """执行 DML 语句(INSERT/UPDATE/DELETE)。 - - 目前仅支持 SQL 数据库(MySQL、PostgreSQL)。 - Redis 和 MongoDB 的变更操作通过各自连接器处理。 - - Args: - instance: 数据源实例配置 - database: 目标数据库名 - sql: DML SQL 语句 - - Returns: - str: 执行结果描述 - - Raises: - QuerySecurityError: 不支持的数据库类型或执行失败 - """ - return await DBConnector.execute_dml(instance, database, sql) diff --git a/backend/app/services/redis_audit_rules.py b/backend/app/services/redis_audit_rules.py deleted file mode 100644 index 47bfec7..0000000 --- a/backend/app/services/redis_audit_rules.py +++ /dev/null @@ -1,1869 +0,0 @@ -"""Redis 审核规则引擎 — 43+ 条 Redis 特有的审核规则。 - -为 SQL 审核平台提供完整的 Redis 命令审核能力,覆盖: -- KEY 管理规则 (10条): Key命名规范、大小限制、过期策略 -- 命令安全规则 (12条): 危险命令检测(FLUSHDB/SHUTDOWN/CONFIG SET等) -- 数据结构规则 (8条): 各类型最佳实践与大小限制 -- 性能规则 (8条): 大Key检测、批量操作限制、阻塞命令 -- 安全规则 (5条): AUTH, ACL 配置与保护模式 - -使用方式: - engine = SQLAuditEngineV2(db_session) - report = await engine.audit("SET user:1001:name张三 EX3600", db_type="redis") - -规则编码规范: - KEY: REDIS-KEY-001 ~ REDIS-KEY-010 - CMD: REDIS-CMD-001 ~ REDIS-CMD-012 - DS: REDIS-DS-001 ~ REDIS-DS-008 - PERF: REDIS-PERF-001 ~ REDIS-PERF-008 - SEC: REDIS-SEC-001 ~ REDIS-SEC-005 -""" - -from __future__ import annotations - -import re -from typing import Any, Dict, List, Optional, Tuple - -from app.services.audit_engine_v2 import ( - AuditFindingV2, - AuditRuleV2, - AuditReportV2, - Severity, - RuleCategory, - register_rule, -) - - -# ═══════════════════════════════════════════════ -# Redis 辅助解析函数 -# ═══════════════════════════════════════════════ - - -def _parse_redis_command(sql_clean: str) -> Tuple[str, List[str]]: - """解析 Redis 命令,返回 (命令名大写, 参数列表)。 - - Args: - sql_clean: 单行 Redis 命令字符串 - - Returns: - (command_upper, args_list) 元组 - 例如: "SET key value EX 3600" -> ("SET", ["key", "value", "EX", "3600"]) - """ - parts = sql_clean.strip().split() - if not parts: - return "", [] - command = parts[0].upper() - args = parts[1:] if len(parts) > 1 else [] - return command, args - - -def _extract_key_from_command(command: str, args: List[str]) -> Optional[str]: - """从 Redis 命令中提取 Key 名称。 - - 大多数命令的第一个参数是 Key,部分命令需要特殊处理。 - - Args: - command: 命令名(大写) - args: 参数列表 - - Returns: - Key 名称,如果无法提取则返回 None - """ - if not args: - return None - - # 特殊命令:第二个参数才是 key - second_key_commands = { - "GETSET", "LPUSH", "RPUSH", "LPOP", "RPOP", "LINDEX", "LSET", - "LREM", "LINSERT", "SADD", "SREM", "SPOP", "SMOVE", "SISMEMBER", - "ZADD", "ZREM", "ZSCORE", "ZRANK", "ZREVRANK", "ZINCRBY", - "HSET", "HGET", "HDEL", "HINCRBY", "HINCRBYFLOAT", "HMSET", - "HMGET", "HSETNX", "HEXISTS", "HLEN", "HKEYS", "HVALS", - "HGETALL", "HSCAN", "HPOP", "SETEX", "PSETEX", "SETNX", - } - - # 多 key 命令 - multi_key_commands = { - "DEL", "UNLINK", "EXISTS", "TOUCH", "MGET", "MSET", "MSETNX", - "SDIFF", "SINTER", "SUNION", "SDIFFSTORE", "SINTERSTORE", "SUNIONSTORE", - "BLPOP", "BRPOP", "BRPOPLPUSH", "BLMOVE", "RPOPLPUSH", "LMOVE", - "RENAME", "RENAMENX", "SMOVE", "PFMERGE", "PFCOUNT", - "BZPOPMIN", "BZPOPMAX", "ZDIFF", "ZINTER", "ZUNION", - "ZDIFFSTORE", "ZINTERSTORE", "ZUNIONSTORE", - } - - if command in multi_key_commands and len(args) >= 1: - return args[0] - - if command in second_key_commands and len(args) >= 1: - return args[0] - - # 管理命令/无 key 命令:不应提取 key - no_key_commands = { - "CONFIG", "ACL", "AUTH", "INFO", "PING", "FLUSHDB", "FLUSHALL", - "DBSIZE", "BGSAVE", "BGREWRITEAOF", "SAVE", "LASTSAVE", - "SHUTDOWN", "SLAVEOF", "REPLICAOF", "SLOWLOG", "MONITOR", - "DEBUG", "OBJECT", "CLIENT", "CLUSTER", "SENTINEL", - "MODULE", "COMMAND", "MEMORY", "LATENCY", "WAIT", - } - if command in no_key_commands: - return None - - # 默认:第一个参数是 key - return args[0] - - -def _is_redis_command(sql_upper: str) -> bool: - """判断是否为 Redis 命令行(非空且非注释)。""" - stripped = sql_upper.strip() - return bool(stripped) and not stripped.startswith("#") - - -def _has_namespace_separator(key: str, separator: str = ":") -> bool: - """检查 Key 是否包含命名空间分隔符(默认冒号)。""" - return separator in key - - -def _count_namespaces(key: str, separator: str = ":") -> int: - """统计 Key 中命名空间层级数。""" - return key.count(separator) + 1 - - -def _extract_ttl_from_args(command: str, args: List[str]) -> Optional[int]: - """从命令参数中提取 TTL 值(秒)。 - - 检测 EX, PX, EXAT, PXAT 参数。 - - Returns: - TTL 秒数,如果没有找到则返回 None - """ - if not args: - return None - - cmd = command.upper() - - # SET ... EX seconds / PX milliseconds / EXAT timestamp / PXAT ms_timestamp - if cmd == "SET" or cmd.startswith("SET"): - for i, arg in enumerate(args): - arg_upper = arg.upper() - if arg_upper == "EX" and i + 1 < len(args): - try: - return int(args[i + 1]) - except (ValueError, TypeError): - return None - elif arg_upper == "PX" and i + 1 < len(args): - try: - return int(args[i + 1]) // 1000 - except (ValueError, TypeError): - return None - elif arg_upper in ("EXAT", "PXAT") and i + 1 < len(args): - return -1 # 绝对时间戳,视为已设置 TTL - return None - - # SETEX key seconds value / PSETEX key milliseconds value - if cmd == "SETEX" and len(args) >= 2: - try: - return int(args[1]) - except (ValueError, TypeError): - return None - if cmd == "PSETEX" and len(args) >= 2: - try: - return int(args[1]) // 1000 - except (ValueError, TypeError): - return None - - # EXPIRE key seconds / PEXPIRE key milliseconds / EXPIREAT key timestamp - if cmd in ("EXPIRE", "EXPIREAT") and len(args) >= 2: - try: - return int(args[1]) - except (ValueError, TypeError): - return None - if cmd == "PEXPIRE" and len(args) >= 2: - try: - return int(args[1]) // 1000 - except (ValueError, TypeError): - return None - - return None - - -def _is_ttl_set_in_command(sql_clean: str, command: str, args: List[str]) -> bool: - """检查命令是否设置了 TTL。""" - cmd = command.upper() - - # 直接设置 TTL 的命令 - ttl_commands = { - "EXPIRE", "PEXPIRE", "EXPIREAT", "PEXPIREAT", "SETEX", "PSETEX" - } - if cmd in ttl_commands: - return True - - # SET 命令带 EX/PX/EXAT/PXAT - if cmd in ("SET", "GETSET"): - for i, arg in enumerate(args): - if arg.upper() in ("EX", "PX", "EXAT", "PXAT"): - return True - - return False - - -def _is_write_command(command: str) -> bool: - """判断是否为写入类命令。""" - write_commands = { - "SET", "GETSET", "MSET", "MSETNX", "SETNX", "SETEX", "PSETEX", - "HSET", "HMSET", "HSETNX", "HDEL", "HINCRBY", "HINCRBYFLOAT", - "LPUSH", "RPUSH", "LPOP", "RPOP", "LSET", "LREM", "LINSERT", - "LTRIM", "LMOVE", "BLMOVE", "RPOPLPUSH", "BRPOPLPUSH", - "SADD", "SREM", "SPOP", "SMOVE", "SDIFFSTORE", "SINTERSTORE", "SUNIONSTORE", - "ZADD", "ZREM", "ZINCRBY", "ZREMRANGEBYRANK", "ZREMRANGEBYSCORE", - "ZREMRANGEBYLEX", "ZDIFFSTORE", "ZINTERSTORE", "ZUNIONSTORE", - "XADD", "XDEL", "XTRIM", "XGROUP", "XSETID", - "DEL", "UNLINK", "FLUSHDB", "FLUSHALL", - "APPEND", "INCR", "DECR", "INCRBY", "DECRBY", "INCRBYFLOAT", - "PFADD", "PFMERGE", "PFCOUNT", - "GEOADD", "GEORADIUS", "GEORADIUSBYMEMBER", - "BITSET", "SETBIT", "BITOP", - "RESTORE", "MIGRATE", "RENAME", "RENAMENX", - } - return command.upper() in write_commands - - -def _extract_pipeline_commands(sql_clean: str) -> List[str]: - """从 Pipeline 输入中提取每条命令。 - - Pipeline 命令通常用换行符分隔。 - """ - commands = [] - for line in sql_clean.split("\n"): - stripped = line.strip() - if stripped and not stripped.startswith("#"): - commands.append(stripped) - return commands - - -# ═══════════════════════════════════════════════ -# KEY 管理规则 (10条) -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="REDIS-KEY-001", - name="Key命名规范", - category=RuleCategory.NAMING, - severity=Severity.WARNING, - db_type="redis", - description="Key 建议使用冒号分隔的命名空间,如 user:1001:profile", - config={"namespace_separator": ":", "min_namespace_levels": 2}, - message_template="Key '{key}' 命名不符合规范,建议使用冒号分隔的命名空间(如 app:module:id)", -)) -def check_redis_key_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Key命名规范检查 - 建议使用冒号分隔的命名空间。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - key = _extract_key_from_command(command, args) - if not key: - return - separator = rule.config.get("namespace_separator", ":") - min_levels = rule.config.get("min_namespace_levels", 2) - if separator not in key or _count_namespaces(key, separator) < min_levels: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(key=key), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-KEY-002", - name="Key最大长度限制", - category=RuleCategory.NAMING, - severity=Severity.WARNING, - db_type="redis", - description="Key 名称最大长度建议不超过128字节", - config={"max_key_length": 128}, - message_template="Key '{key}' 长度为 {length} 字节,超过最大限制 {max} 字节", -)) -def check_redis_key_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Key最大长度限制检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - key = _extract_key_from_command(command, args) - if not key: - return - max_len = rule.config.get("max_key_length", 128) - key_bytes = len(key.encode("utf-8")) - if key_bytes > max_len: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(key=key, length=key_bytes, max=max_len), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-KEY-003", - name="Key值大小限制", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="redis", - description="String 值建议不超过 10KB,Hash 建议不超过 1000 字段", - config={"max_string_size": 10240, "max_hash_fields": 1000}, - message_template="{cmd} 操作可能导致大 Key 问题,String 值建议不超过 {max_str} 字节,Hash 建议不超过 {max_hash} 字段", -)) -def check_redis_key_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Key值大小限制检查 - 针对大 Value 的写入操作给出警告。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - # 检测 HMSET/HSET 大量字段(超过阈值的一半即警告) - hash_cmds = {"HSET", "HMSET", "HGETALL"} - if cmd in hash_cmds and len(args) > 20: - max_hash = rule.config.get("max_hash_fields", 1000) - # HMSET key field1 value1 field2 value2 ... - # 字段数 = (args - 1) // 2 - field_count = (len(args) - 1) // 2 if cmd in ("HSET", "HMSET") else 0 - if field_count > max_hash // 2: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=f"HMSET/HSET 操作包含约 {field_count} 个字段,接近大 Key 阈值 {max_hash},建议拆分 Hash 结构", - )) - return - - # 检测大 String 写入 - string_cmds = {"SET", "SETEX", "PSETEX", "APPEND", "GETSET"} - if cmd in string_cmds and len(args) >= 2: - max_str = rule.config.get("max_string_size", 10240) - # 找到 value 参数(通常是第2或第3个参数) - value = "" - if cmd in ("SETEX", "PSETEX") and len(args) >= 3: - value = args[2] - elif cmd == "APPEND" and len(args) >= 2: - value = args[1] - elif len(args) >= 2: - value = args[1] - value_bytes = len(value.encode("utf-8")) if value else 0 - if value_bytes > max_str: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=f"SET 操作的 Value 大小为 {value_bytes} 字节,超过建议阈值 {max_str} 字节(10KB)", - )) - - -@register_rule(AuditRuleV2( - code="REDIS-KEY-004", - name="写入Key必须设置TTL", - category=RuleCategory.DDL, - severity=Severity.ERROR, - db_type="redis", - description="写入 Key 必须设置 TTL,不允许永久 Key 存在", - config={"whitelist_keys": []}, - message_template="写入 Key '{key}' 未设置 TTL,请添加 EX/PX 参数或单独执行 EXPIRE 命令,禁止创建永久 Key", -)) -def check_redis_key_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """写入 Key 必须设置 TTL 检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - # 只对写入命令检查 - if not _is_write_command(cmd): - return - - key = _extract_key_from_command(command, args) - if not key: - return - - # 检查白名单 - whitelist = rule.config.get("whitelist_keys", []) - if any(key.startswith(w) for w in whitelist): - return - - # 检查是否已设置 TTL - if _is_ttl_set_in_command(sql_clean, cmd, args): - return - - # 排除一些天然带 TTL 的命令和一些特殊的命令 - ttl_builtin_cmds = {"SETEX", "PSETEX"} - if cmd in ttl_builtin_cmds: - return - - # DEL/UNLINK/FLUSH 等删除命令不需要 TTL - delete_cmds = {"DEL", "UNLINK", "FLUSHDB", "FLUSHALL", "EXPIRE", "PEXPIRE", - "EXPIREAT", "PEXPIREAT", "TTL", "PTTL", "PERSIST"} - if cmd in delete_cmds: - return - - # 自增类命令通常是更新已有 Key,不需要设置 TTL - incr_cmds = {"INCR", "DECR", "INCRBY", "DECRBY", "INCRBYFLOAT", - "HINCRBY", "HINCRBYFLOAT", "ZINCRBY"} - if cmd in incr_cmds: - return - - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(key=key), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-KEY-005", - name="默认TTL建议", - category=RuleCategory.DDL, - severity=Severity.INFO, - db_type="redis", - description="建议设置合理的默认 TTL,如 3600 秒(1小时)", - config={"recommended_default_ttl": 3600, "min_recommended_ttl": 60}, - message_template="建议为 Key '{key}' 设置合理的 TTL,默认推荐 {ttl} 秒(1小时),避免数据永久驻留", -)) -def check_redis_key_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """默认 TTL 建议 - 对未设置 TTL 或 TTL 过短的命令给出提示。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - # 只检查写入命令 - if not _is_write_command(cmd): - return - - key = _extract_key_from_command(command, args) - if not key: - return - - ttl = _extract_ttl_from_args(cmd, args) - if ttl is None: - return # 已由 REDIS-KEY-004 检查 - - min_ttl = rule.config.get("min_recommended_ttl", 60) - if ttl > 0 and ttl < min_ttl: - default_ttl = rule.config.get("recommended_default_ttl", 3600) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=f"Key '{key}' 的 TTL 设置为 {ttl} 秒,过短可能导致频繁缓存失效,建议至少 {min_ttl} 秒或默认 {default_ttl} 秒", - )) - - -@register_rule(AuditRuleV2( - code="REDIS-KEY-006", - name="最大TTL限制", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="redis", - description="Key 的 TTL 建议不超过 30 天(2592000 秒)", - config={"max_ttl_seconds": 2592000}, - message_template="Key '{key}' 的 TTL {ttl} 秒超过最大限制 {max} 秒({days} 天),建议缩短过期时间", -)) -def check_redis_key_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """最大 TTL 限制检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - ttl = _extract_ttl_from_args(cmd, args) - if ttl is None or ttl <= 0: - return - - max_ttl = rule.config.get("max_ttl_seconds", 2592000) - if ttl > max_ttl: - key = _extract_key_from_command(command, args) or "unknown" - days = max_ttl // 86400 - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(key=key, ttl=ttl, max=max_ttl, days=days), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-KEY-007", - name="禁止使用KEYS命令", - category=RuleCategory.PERFORMANCE, - severity=Severity.ERROR, - db_type="redis", - description="KEYS 命令会扫描全库,阻塞服务器,必须使用 SCAN 替代", - message_template="禁止使用 KEYS 命令('{cmd}'),会导致全库扫描阻塞服务器,请使用 SCAN 替代", -)) -def check_redis_key_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止使用 KEYS 命令。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - if command.upper() == "KEYS": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(cmd=sql_clean.strip()), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-KEY-008", - name="必须指定命名空间前缀", - category=RuleCategory.NAMING, - severity=Severity.WARNING, - db_type="redis", - description="Key 必须以指定的命名空间前缀开头,如 app:service:key", - config={"required_prefixes": ["app:", "svc:", "cache:", "session:", "mq:"]}, - message_template="Key '{key}' 缺少命名空间前缀,Key 必须以 {prefixes} 之一开头", -)) -def check_redis_key_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """必须指定命名空间前缀检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - key = _extract_key_from_command(command, args) - if not key: - return - - prefixes = rule.config.get("required_prefixes", ["app:", "svc:", "cache:", "session:", "mq:"]) - if not any(key.startswith(p) for p in prefixes): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(key=key, prefixes=", ".join(prefixes)), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-KEY-009", - name="禁止批量删除Key模式", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="禁止使用 DEL key:* 等通配符批量删除 Key 的模式", - config={}, - message_template="禁止使用通配符批量删除 Key 的模式 '{cmd}',请使用 SCAN + DEL 循环逐个删除", -)) -def check_redis_key_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止批量删除 Key 模式检查。 - - 检测使用通配符配合 DEL 的模式,如: - - DEL user:* - - EVAL "... KEYS[1] ..." 0 prefix:* - """ - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - # 检查 DEL/UNLINK 命令参数中包含通配符 * - if cmd in ("DEL", "UNLINK") and args: - for arg in args: - if "*" in arg or "?" in arg: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(cmd=sql_clean.strip()), - )) - return - - # 检查 EVAL/EVALSHA 脚本中的批量删除模式 - if cmd in ("EVAL", "EVALSHA") and args: - script_body = args[0].upper() - if "DEL" in script_body and ("KEYS" in script_body or "ARGV" in script_body): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=f"Lua 脚本 '{cmd}' 中可能包含批量删除操作,请确保使用 SCAN 逐个删除", - )) - - -@register_rule(AuditRuleV2( - code="REDIS-KEY-010", - name="推荐高效编码", - category=RuleCategory.DDL, - severity=Severity.INFO, - db_type="redis", - description="建议开启 ziplist/intset 等高效编码以节省内存", - config={"ziplist_max_entries": 512, "ziplist_max_value": 64}, - message_template="建议配置 ziplist/intset 高效编码参数(hash-max-ziplist-entries/list-max-ziplist-entries/set-max-intset-entries),节省内存", -)) -def check_redis_key_010(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """推荐高效编码检查 - 检测 CONFIG SET 命令中关于编码的配置。 - - 当用户执行 CONFIG SET 修改编码相关参数时进行检查和提示。 - """ - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - if cmd == "CONFIG" and len(args) >= 2: - subcmd = args[0].upper() - param = args[1].upper() - if subcmd == "SET" and "ziplist" in param.lower(): - try: - value = int(args[2]) if len(args) >= 3 else 0 - max_entries = rule.config.get("ziplist_max_entries", 512) - if value > max_entries: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"ziplist 条目数设置为 {value},超过建议值 {max_entries},过大可能导致性能下降", - )) - except (ValueError, TypeError, IndexError): - pass - - # 对于 Hash 操作,如果字段数较少但使用了大配置,给出提示 - if cmd in ("HSET", "HMSET") and len(args) >= 4: - field_count = (len(args) - 1) // 2 - max_entries = 512 - if field_count <= max_entries: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"Hash 字段数为 {field_count},建议配置 hash-max-ziplist-entries >= {max_entries} 以启用 ziplist 编码节省内存", - )) - - -# ═══════════════════════════════════════════════ -# 命令安全规则 (12条) -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="REDIS-CMD-001", - name="禁止FLUSHDB", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="禁止执行 FLUSHDB 命令,将清空当前数据库所有数据", - message_template="禁止执行 FLUSHDB,该命令将清空当前数据库所有数据,如需清理请使用 SCAN + DEL 逐个删除", -)) -def check_redis_cmd_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 FLUSHDB。""" - command, args = _parse_redis_command(sql_clean) - if command.upper() == "FLUSHDB": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="REDIS-CMD-002", - name="禁止FLUSHALL", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="禁止执行 FLUSHALL 命令,将清空所有数据库数据", - message_template="禁止执行 FLUSHALL,该命令将清空所有数据库数据,属于高危操作", -)) -def check_redis_cmd_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 FLUSHALL。""" - command, args = _parse_redis_command(sql_clean) - if command.upper() == "FLUSHALL": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="REDIS-CMD-003", - name="禁止SHUTDOWN", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="禁止执行 SHUTDOWN 命令,将关闭 Redis 服务器", - message_template="禁止执行 SHUTDOWN,该命令将关闭 Redis 服务器,如需维护请联系 DBA", -)) -def check_redis_cmd_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 SHUTDOWN。""" - command, args = _parse_redis_command(sql_clean) - if command.upper() == "SHUTDOWN": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="REDIS-CMD-004", - name="禁止CONFIG SET", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="禁止执行 CONFIG SET 命令修改 Redis 配置", - message_template="禁止执行 CONFIG SET 修改配置,参数调整需通过运维流程审批后由 DBA 执行", -)) -def check_redis_cmd_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 CONFIG SET。""" - command, args = _parse_redis_command(sql_clean) - if command.upper() == "CONFIG" and len(args) >= 1: - if args[0].upper() == "SET": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="REDIS-CMD-005", - name="禁止DEBUG命令", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="禁止执行 DEBUG 命令(含 DEBUG SEGFAULT 等调试命令)", - message_template="禁止执行 DEBUG 命令('{cmd}'),调试命令可能导致服务崩溃或性能问题", -)) -def check_redis_cmd_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 DEBUG 命令。""" - command, args = _parse_redis_command(sql_clean) - if command.upper() == "DEBUG": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(cmd=sql_clean.strip()), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-CMD-006", - name="禁止REPLICAOF/SLAVEOF", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="禁止执行 REPLICAOF/SLAVEOF 命令修改主从配置", - message_template="禁止执行 {cmd} 修改主从复制配置,主从变更需通过运维流程审批", -)) -def check_redis_cmd_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 REPLICAOF/SLAVEOF。""" - command, args = _parse_redis_command(sql_clean) - cmd = command.upper() - if cmd in ("REPLICAOF", "SLAVEOF"): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(cmd=cmd), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-CMD-007", - name="禁止MIGRATE", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="禁止执行 MIGRATE 命令进行数据迁移", - message_template="禁止执行 MIGRATE 数据迁移命令,数据迁移需通过 DBA 审批和协助", -)) -def check_redis_cmd_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 MIGRATE。""" - command, args = _parse_redis_command(sql_clean) - if command.upper() == "MIGRATE": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="REDIS-CMD-008", - name="禁止RESTORE", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="禁止执行 RESTORE 命令恢复序列化数据", - message_template="禁止执行 RESTORE 恢复数据命令,数据恢复需通过 DBA 审批和协助", -)) -def check_redis_cmd_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 RESTORE。""" - command, args = _parse_redis_command(sql_clean) - if command.upper() == "RESTORE": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="REDIS-CMD-009", - name="禁止EVAL/EVALSHA", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="禁止执行 EVAL/EVALSHA 运行 Lua 脚本(未授权脚本风险)", - config={"allowed_scripts": []}, - message_template="禁止执行 {cmd} 运行 Lua 脚本,未授权脚本可能导致性能问题或安全风险,请使用 Function 或提交审批", -)) -def check_redis_cmd_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 EVAL/EVALSHA。""" - command, args = _parse_redis_command(sql_clean) - cmd = command.upper() - if cmd in ("EVAL", "EVALSHA"): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(cmd=cmd), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-CMD-010", - name="禁止MODULE LOAD", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="禁止执行 MODULE LOAD 加载外部模块(安全风险)", - message_template="禁止执行 MODULE LOAD 加载外部模块,模块加载需通过安全审批和代码审计", -)) -def check_redis_cmd_010(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 MODULE LOAD。""" - command, args = _parse_redis_command(sql_clean) - cmd = command.upper() - if cmd == "MODULE" and len(args) >= 1 and args[0].upper() == "LOAD": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="REDIS-CMD-011", - name="禁止ACL SETUSER", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="禁止执行 ACL SETUSER 修改用户权限", - message_template="禁止执行 ACL SETUSER 修改用户权限,账户管理需通过运维流程", -)) -def check_redis_cmd_011(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止 ACL SETUSER。""" - command, args = _parse_redis_command(sql_clean) - cmd = command.upper() - if cmd == "ACL" and len(args) >= 1: - if args[0].upper() in ("SETUSER", "DELUSER", "LOAD", "SAVE", "GENPASS"): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="REDIS-CMD-012", - name="限制MONITOR命令", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="redis", - description="MONITOR 命令会导致严重的性能影响,生产环境限制使用", - message_template="MONITOR 命令会导致严重的性能影响(所有命令输出到客户端),生产环境请限制使用", -)) -def check_redis_cmd_012(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """限制 MONITOR 命令。""" - command, args = _parse_redis_command(sql_clean) - if command.upper() == "MONITOR": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - - -# ═══════════════════════════════════════════════ -# 数据结构规则 (8条) -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="REDIS-DS-001", - name="String值最大大小限制", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="redis", - description="String 类型值大小建议不超过 10MB", - config={"max_string_size_mb": 10, "max_string_size_bytes": 10485760}, - message_template="String 值大小约为 {size} 字节,超过建议阈值 {max} 字节({max_mb}MB),建议使用 Hash/List 拆分存储", -)) -def check_redis_ds_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """String 值最大大小限制检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - string_write_cmds = {"SET", "SETEX", "PSETEX", "GETSET", "APPEND"} - if cmd not in string_write_cmds or len(args) < 2: - return - - value = "" - if cmd in ("SETEX", "PSETEX") and len(args) >= 3: - value = args[2] - elif cmd in ("SET", "GETSET", "APPEND") and len(args) >= 2: - value = args[1] - - max_bytes = rule.config.get("max_string_size_bytes", 10485760) - value_bytes = len(value.encode("utf-8")) if value else 0 - if value_bytes > max_bytes: - max_mb = rule.config.get("max_string_size_mb", 10) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(size=value_bytes, max=max_bytes, max_mb=max_mb), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-DS-002", - name="Hash最大字段数限制", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="redis", - description="Hash 类型字段数建议不超过 10000", - config={"max_hash_fields": 10000}, - message_template="Hash 字段数 {fields} 超过建议阈值 {max},请拆分 Hash 或使用多个 Key 存储", -)) -def check_redis_ds_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Hash 最大字段数限制检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - if cmd in ("HMSET", "HSET") and len(args) >= 3: - field_count = (len(args) - 1) // 2 - max_fields = rule.config.get("max_hash_fields", 10000) - if field_count > max_fields: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(fields=field_count, max=max_fields), - )) - - # HGETALL 读取大 Hash 也警告 - if cmd == "HGETALL" and len(args) >= 1: - max_fields = rule.config.get("max_hash_fields", 10000) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"HGETALL 读取 Hash '{args[0]}' 可能返回大量字段,建议使用 HSCAN 分批读取,避免阻塞", - )) - - -@register_rule(AuditRuleV2( - code="REDIS-DS-003", - name="List最大长度限制", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="redis", - description="List 类型长度建议不超过 100000", - config={"max_list_length": 100000}, - message_template="List 长度可能超过建议阈值 {max},请使用 LTRIM 控制长度或使用 Stream 替代", -)) -def check_redis_ds_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """List 最大长度限制检查。 - - 检测 List 写入命令,当未带 LTRIM/XTRIM 等长度控制时警告。 - """ - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - list_push_cmds = {"LPUSH", "RPUSH", "LPUSHX", "RPUSHX"} - if cmd in list_push_cmds and len(args) >= 2: - max_len = rule.config.get("max_list_length", 100000) - # 大量元素一次性 LPUSH/RPUSH - element_count = len(args) - 1 # 减去 key - if element_count > 100: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"一次性 {cmd} 插入 {element_count} 个元素,List 长度可能增长过快,建议使用 LTRIM 控制长度不超过 {max_len}", - )) - - # LRANGE 大跨度读取 - if cmd == "LRANGE" and len(args) >= 3: - try: - start = int(args[1]) - end = int(args[2]) - range_size = abs(end - start) + 1 if end >= 0 else abs(start) + abs(end) - max_len = rule.config.get("max_list_length", 100000) - if range_size > max_len // 10: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=f"LRANGE 范围 {start} ~ {end} 涉及约 {range_size} 个元素,范围过大可能导致阻塞,建议分页读取", - )) - except (ValueError, TypeError): - pass - - -@register_rule(AuditRuleV2( - code="REDIS-DS-004", - name="Set最大成员数限制", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="redis", - description="Set 类型成员数建议不超过 100000", - config={"max_set_members": 100000}, - message_template="Set 成员数 {members} 超过建议阈值 {max},请拆分 Set 或使用 BloomFilter 替代", -)) -def check_redis_ds_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Set 最大成员数限制检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - # SADD 大量成员 - if cmd == "SADD" and len(args) > 100: - member_count = len(args) - 1 - max_members = rule.config.get("max_set_members", 100000) - if member_count > max_members // 10: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"SADD 一次性添加 {member_count} 个成员,Set 过大(>{max_members})会影响性能,建议拆分", - )) - - # SMEMBERS 全量读取大 Set 警告 - if cmd == "SMEMBERS" and len(args) >= 1: - max_members = rule.config.get("max_set_members", 100000) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"SMEMBERS 全量读取 Set '{args[0]}' 可能返回大量数据,建议使用 SSCAN 分批扫描(成员数建议不超过 {max_members})", - )) - - -@register_rule(AuditRuleV2( - code="REDIS-DS-005", - name="ZSet最大成员数限制", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="redis", - description="ZSet 类型成员数建议不超过 100000", - config={"max_zset_members": 100000}, - message_template="ZSet 成员数超过建议阈值 {max},请拆分 ZSet 或优化数据模型", -)) -def check_redis_ds_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """ZSet 最大成员数限制检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - # ZADD 大量成员 - if cmd == "ZADD" and len(args) > 200: - member_count = (len(args) - 1) // 2 - max_members = rule.config.get("max_zset_members", 100000) - if member_count > max_members // 10: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"ZADD 一次性添加 {member_count} 个成员,ZSet 过大(>{max_members})会影响排序性能,建议拆分", - )) - - # ZRANGE/ZREVRANGE 大跨度读取 - if cmd in ("ZRANGE", "ZREVRANGE") and len(args) >= 3: - try: - start = int(args[1]) - end = int(args[2]) - if end < 0 or (end - start) > 10000: - max_members = rule.config.get("max_zset_members", 100000) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"{cmd} 大范围读取({start}~{end})可能阻塞,建议分页(每次不超过 1000)或限制 ZSet 大小(<{max_members})", - )) - except (ValueError, TypeError): - pass - - -@register_rule(AuditRuleV2( - code="REDIS-DS-006", - name="Stream最大长度限制", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="redis", - description="Stream 类型必须限制最大长度(使用 MAXLEN),建议不超过 100000", - config={"max_stream_length": 100000}, - message_template="Stream 写入命令 '{cmd}' 未使用 MAXLEN 限制长度,请添加 MAXLEN ~ {max} 防止内存无限增长", -)) -def check_redis_ds_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Stream 最大长度限制检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - if cmd == "XADD" and len(args) >= 2: - has_maxlen = any(arg.upper() == "MAXLEN" for arg in args) - if not has_maxlen: - max_len = rule.config.get("max_stream_length", 100000) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(cmd=sql_clean.strip(), max=max_len), - )) - - # XTRIM 命令也需要检查 - if cmd == "XTRIM" and len(args) >= 1: - has_maxlen = any(arg.upper() == "MAXLEN" for arg in args) - if not has_maxlen: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message="XTRIM 命令未使用 MAXLEN 参数,请确保使用正确的修剪策略", - )) - - -@register_rule(AuditRuleV2( - code="REDIS-DS-007", - name="Bitmap最大大小限制", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="redis", - description="Bitmap 类型大小建议不超过 512MB(offset < 2^32)", - config={"max_bitmap_size_mb": 512, "max_bitmap_offset": 4294967296}, - message_template="Bitmap offset {offset} 超过建议阈值 {max_offset}({max_mb}MB),Bitmap 过大消耗大量内存", -)) -def check_redis_ds_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Bitmap 最大大小限制检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - bitmap_cmds = {"SETBIT", "GETBIT", "BITPOS", "BITCOUNT", "BITOP"} - if cmd not in bitmap_cmds: - return - - max_offset = rule.config.get("max_bitmap_offset", 4294967296) - max_mb = rule.config.get("max_bitmap_size_mb", 512) - - # SETBIT key offset value - if cmd == "SETBIT" and len(args) >= 2: - try: - offset = int(args[1]) - if offset > max_offset: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(offset=offset, max_offset=max_offset, max_mb=max_mb), - )) - except (ValueError, TypeError): - pass - - # BITOP 操作可能产生大 Bitmap - if cmd == "BITOP" and len(args) >= 3: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"BITOP 操作涉及 {len(args) - 2} 个 Bitmap,请确保结果 Bitmap 大小不超过 {max_mb}MB", - )) - - -@register_rule(AuditRuleV2( - code="REDIS-DS-008", - name="Geo最大成员数限制", - category=RuleCategory.DDL, - severity=Severity.WARNING, - db_type="redis", - description="Geo 类型成员数建议不超过 10000000", - config={"max_geo_members": 10000000}, - message_template="Geo 集合成员数 {members} 超过建议阈值 {max},大集合 GEOSEARCH/GEORADIUS 性能会显著下降", -)) -def check_redis_ds_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Geo 最大成员数限制检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - # GEOADD 大量成员 - if cmd == "GEOADD" and len(args) > 300: - member_count = (len(args) - 1) // 3 - max_members = rule.config.get("max_geo_members", 10000000) - if member_count > max_members // 100: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"GEOADD 一次性添加 {member_count} 个地理坐标,Geo 集合过大(>{max_members})会显著降低查询性能,建议按区域分集合存储", - )) - - # GEORADIUS/GEOSEARCH 大范围查询 - if cmd in ("GEORADIUS", "GEORADIUSBYMEMBER", "GEOSEARCH"): - if len(args) >= 4: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"{cmd} 地理范围查询可能返回大量结果,建议使用 COUNT 参数限制返回数量", - )) - - -# ═══════════════════════════════════════════════ -# 性能规则 (8条) -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="REDIS-PERF-001", - name="禁止阻塞命令", - category=RuleCategory.PERFORMANCE, - severity=Severity.ERROR, - db_type="redis", - description="禁止使用 BLPOP/BRPOP/BRPOPLPUSH/BLMOVE 等阻塞命令", - message_template="禁止执行阻塞命令 '{cmd}',会导致客户端连接长时间阻塞,请使用 LPOP/RPOP/LMOVE 非阻塞版本", -)) -def check_redis_perf_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止阻塞命令检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - blocking_cmds = {"BLPOP", "BRPOP", "BRPOPLPUSH", "BLMOVE", "BZPOPMIN", "BZPOPMAX"} - if cmd in blocking_cmds: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(cmd=cmd), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-PERF-002", - name="SORT命令必须带LIMIT", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="redis", - description="SORT 命令必须带 LIMIT 参数,防止返回过多数据", - message_template="SORT 命令未带 LIMIT 参数,可能返回大量数据导致阻塞,请添加 LIMIT offset count", -)) -def check_redis_perf_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """SORT 命令必须带 LIMIT 检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - if cmd == "SORT" and len(args) >= 1: - has_limit = any(arg.upper() == "LIMIT" for arg in args) - if not has_limit: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="REDIS-PERF-003", - name="Pipeline批量限制", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="redis", - description="Pipeline 批量命令数建议不超过 1000 条", - config={"max_pipeline_commands": 1000}, - message_template="Pipeline 包含 {count} 条命令,超过建议阈值 {max} 条,请分批执行(建议每批不超过 {max} 条)", -)) -def check_redis_perf_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Pipeline 批量限制检查。 - - 对多行输入的命令进行计数。 - """ - commands = _extract_pipeline_commands(sql_clean) - max_cmds = rule.config.get("max_pipeline_commands", 1000) - if len(commands) > max_cmds: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(count=len(commands), max=max_cmds), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-PERF-004", - name="大Key内存使用检测", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="redis", - description="检测可能产生大 Key 的操作,建议定期执行 MEMORY USAGE 检查", - config={"big_key_threshold_bytes": 10485760}, # 10MB - message_template="{cmd} 操作 Key '{key}' 可能产生大 Key(>{threshold}MB),建议执行 MEMORY USAGE {key} 检查", -)) -def check_redis_perf_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """大 Key 内存使用检测。 - - 对可能产生大 Key 的操作进行静态检测和警告。 - """ - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - threshold = rule.config.get("big_key_threshold_bytes", 10485760) - threshold_mb = threshold / 1048576 - - # 检测大 Value 写入 - big_value_cmds = { - "SET": (1, "value"), "SETEX": (2, "value"), "PSETEX": (2, "value"), - "HSET": (slice(2, None, 2), "hash fields"), - "HMSET": (slice(2, None, 2), "hash fields"), - "SADD": (slice(1, None), "set members"), - "ZADD": (slice(2, None, 2), "zset members"), - "LPUSH": (slice(1, None), "list elements"), - "RPUSH": (slice(1, None), "list elements"), - "XADD": (slice(2, None, 2), "stream fields"), - } - - if cmd in big_value_cmds and len(args) >= 1: - key = args[0] - index_info, desc = big_value_cmds[cmd] - - # 检查 value 总大小 - total_size = 0 - if isinstance(index_info, slice): - values = args[index_info] - for v in values: - total_size += len(v.encode("utf-8")) - elif isinstance(index_info, int) and len(args) > index_info: - total_size = len(args[index_info].encode("utf-8")) - - if total_size > threshold: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(cmd=cmd, key=key, threshold=threshold_mb), - )) - - # 检测全量读取大 Key 的命令 - full_read_cmds = {"HGETALL", "SMEMBERS", "ZRANGE", "ZREVRANGE", "LRANGE", "XREAD", "XREVRANGE"} - if cmd in full_read_cmds and len(args) >= 1: - key = args[0] - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"{cmd} 全量读取 Key '{key}' 可能返回大量数据,建议先用 MEMORY USAGE {key} 确认大小,或使用 SCAN/HSCAN/SSCAN/ZSCAN 分批读取", - )) - - -@register_rule(AuditRuleV2( - code="REDIS-PERF-005", - name="慢查询检查", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="redis", - description="检测可能导致慢查询的操作模式", - config={"slowlog_threshold_ms": 10}, - message_template="{cmd} 操作可能导致慢查询(>{threshold}ms),建议避免对大量数据执行复杂操作", -)) -def check_redis_perf_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """慢查询检查。 - - 检测可能导致慢查询的高复杂度操作。 - """ - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - threshold = rule.config.get("slowlog_threshold_ms", 10) - - # 高复杂度命令模式检测 - slow_patterns = { - "SORT": "SORT 全量排序复杂度 O(N+M*log(M))", - "SUNIONSTORE": "SUNIONSTORE 集合操作复杂度 O(N)", - "SINTERSTORE": "SINTERSTORE 集合操作复杂度 O(N*M)", - "ZUNIONSTORE": "ZUNIONSTORE 排序集合操作复杂度 O(N*K) + O(M*log(M))", - "ZINTERSTORE": "ZINTERSTORE 排序集合操作复杂度 O(N*K) + O(M*log(M))", - "ZRANGEBYLEX": "ZRANGEBYLEX 范围查询可能遍历大量成员", - "ZREMRANGEBYLEX": "ZREMRANGEBYLEX 批量删除复杂度 O(log(N)+M)", - "ZRANGEBYSCORE": "ZRANGEBYSCORE 大范围查询可能返回大量数据", - } - - if cmd in slow_patterns: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"{slow_patterns[cmd]},可能导致慢查询(>{threshold}ms),请注意监控慢查询日志(SLOWLOG GET)", - )) - - # KEYS 命令(已被 REDIS-KEY-007 覆盖为 ERROR,这里额外提示性能影响) - if cmd == "KEYS": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"KEYS 命令扫描全库复杂度 O(N),在数据量大时会导致长时间阻塞,生产环境绝对禁止", - )) - - # 大量元素的 MGET/MSET - if cmd in ("MGET", "MSET") and len(args) > 100: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"{cmd} 操作 {len(args)} 个 Key,批量过大可能阻塞,建议分批执行(每批不超过 100 个 Key)", - )) - - -@register_rule(AuditRuleV2( - code="REDIS-PERF-006", - name="Lua脚本执行时间限制", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="redis", - description="Lua 脚本执行时间建议不超过 5 秒", - config={"max_lua_exec_ms": 5000}, - message_template="Lua 脚本 '{script_snippet}...' 可能执行时间过长,建议设置 lua-time-limit 不超过 {max_ms}ms,避免阻塞", -)) -def check_redis_perf_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """Lua 脚本执行时间限制检查。 - - 通过脚本长度和复杂度进行静态估计。 - """ - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - if cmd not in ("EVAL", "EVALSHA", "FCALL", "FCALL_RO"): - return - - max_ms = rule.config.get("max_lua_exec_ms", 5000) - - # 如果是 EVAL,检查脚本复杂度 - if cmd == "EVAL" and args: - script = args[0] - # 统计脚本中的 Redis 调用次数 - redis_call_count = script.upper().count("REDIS.CALL") + script.upper().count("REDIS.PCALL") - if redis_call_count > 50: - snippet = script[:80].replace("\n", " ") - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=f"Lua 脚本包含 {redis_call_count} 次 redis.call/redis.pcall 调用,可能执行时间超过 {max_ms}ms,建议拆分脚本", - )) - return - - # 脚本过长 - if len(script) > 5000: - snippet = script[:80].replace("\n", " ") - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=rule.message_template.format(script_snippet=snippet, max_ms=max_ms), - )) - - # FCALL 检查 - if cmd in ("FCALL", "FCALL_RO") and args: - func_name = args[0] - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"Function '{func_name}' 调用需确保执行时间不超过 {max_ms}ms,建议在函数内部添加超时保护", - )) - - -@register_rule(AuditRuleV2( - code="REDIS-PERF-007", - name="连接数限制警告", - category=RuleCategory.PERFORMANCE, - severity=Severity.WARNING, - db_type="redis", - description="监控 Redis 连接数,建议不超过 maxclients 的 80%", - config={"connection_threshold_ratio": 0.8, "maxclients_default": 10000}, - message_template="当前连接数监控:建议连接数不超过 maxclients 的 {ratio}%(当前配置 maxclients={maxclients},警戒值={threshold}),请配置连接池和连接复用", -)) -def check_redis_perf_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """连接数限制警告。 - - 当检测到 INFO CLIENTS 或 CLIENT LIST 查询时给出连接管理建议。 - """ - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - if cmd == "INFO" and args: - section = args[0].upper() - if section in ("CLIENTS", "ALL", "DEFAULT"): - ratio = rule.config.get("connection_threshold_ratio", 0.8) - maxclients = rule.config.get("maxclients_default", 10000) - threshold = int(maxclients * ratio) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=rule.message_template.format(ratio=int(ratio * 100), maxclients=maxclients, threshold=threshold), - )) - - if cmd in ("CLIENT", "CONFIG") and len(args) >= 1: - subcmd = args[0].upper() - if subcmd in ("LIST", "GET", "INFO"): - ratio = rule.config.get("connection_threshold_ratio", 0.8) - maxclients = rule.config.get("maxclients_default", 10000) - threshold = int(maxclients * ratio) - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message=f"连接管理:建议监控连接数不超过 maxclients 的 {int(ratio * 100)}%(maxclients={maxclients},警戒值={threshold}),使用连接池复用连接", - )) - - -@register_rule(AuditRuleV2( - code="REDIS-PERF-008", - name="避免内存溢出风险命令", - category=RuleCategory.PERFORMANCE, - severity=Severity.ERROR, - db_type="redis", - description="检测可能导致 OOM 的高风险命令", - config={"oom_warning_threshold_mb": 100}, - message_template="{cmd} 操作存在内存溢出风险,可能导致 Redis OOM,请确保内存充足并配置 maxmemory 和淘汰策略", -)) -def check_redis_perf_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """避免内存溢出风险命令检测。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - # 检测可能导致 OOM 的操作模式 - oom_risk_cmds = { - "SUNIONSTORE": "SUNIONSTORE 结果集可能远大于输入集", - "SINTERSTORE": "SINTERSTORE 大量集合求交", - "ZUNIONSTORE": "ZUNIONSTORE 大量集合合并", - "PFMERGE": "PFMERGE HyperLogLog 合并", - "BITOP": "BITOP 位图操作可能产生大结果", - } - - if cmd in oom_risk_cmds: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(cmd=cmd) + f",{oom_risk_cmds[cmd]}", - )) - - # 检测无 MAXLEN 的大 Stream XADD(已被 REDIS-DS-006 覆盖,这里补充 OOM 角度) - if cmd == "XADD" and len(args) >= 2: - has_maxlen = any(arg.upper() == "MAXLEN" for arg in args) - if not has_maxlen: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message="XADD 未限制 MAXLEN,Stream 无限增长可能导致内存溢出,建议设置 MAXLEN 或定期 XTRIM", - )) - - -# ═══════════════════════════════════════════════ -# 安全规则 (5条) -# ═══════════════════════════════════════════════ - -@register_rule(AuditRuleV2( - code="REDIS-SEC-001", - name="敏感管理命令需要二次确认", - category=RuleCategory.SECURITY, - severity=Severity.WARNING, - db_type="redis", - description="CONFIG/ACL/SHUTDOWN 等管理级命令属于高危操作,建议限制执行", - config={"admin_cmds": ["CONFIG", "ACL", "SHUTDOWN", "DEBUG", "MODULE", "REPLICAOF", "SLAVEOF", "SAVE", "BGSAVE", "BGREWRITEAOF"]}, - message_template="命令 '{cmd}' 属于 Redis 管理级操作,请确认业务必要性后再执行", -)) -def check_redis_sec_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """管理级命令检查。 - - 仅对 CONFIG/ACL/SHUTDOWN 等管理级命令产生警告, - 常规数据操作命令(SET/DEL/HSET 等)不触发—— - 平台已通过实例配置管理认证,无需额外提示 AUTH。 - """ - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - admin_cmds = rule.config.get("admin_cmds", []) - if cmd in admin_cmds: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(cmd=cmd), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-SEC-002", - name="命令中不得包含密码", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="Redis 命令中不得明文包含密码,存在安全风险", - config={}, - message_template="命令中包含明文密码,存在安全风险,请通过配置文件或环境变量传递密码,禁止在命令行中硬编码", -)) -def check_redis_sec_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """命令中不得包含密码检查。""" - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - # 检查 AUTH 命令中的密码 - if cmd == "AUTH" and len(args) >= 1: - password = args[-1] # 最后一个参数是密码 - # 检查密码强度(简单密码检测) - if password and len(password) < 8: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message="AUTH 密码长度过短(< 8 字符),安全性不足,请使用强密码(>= 16 字符,包含大小写+数字+特殊字符)", - )) - - # 检查命令中是否包含常见密码模式 - password_patterns = [ - r"password[=:]\S+", - r"pwd[=:]\S+", - r"passwd[=:]\S+", - r"secret[=:]\S+", - ] - for pattern in password_patterns: - if re.search(pattern, sql_clean, re.IGNORECASE): - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - return - - -@register_rule(AuditRuleV2( - code="REDIS-SEC-003", - name="危险命令重命名建议", - category=RuleCategory.SECURITY, - severity=Severity.INFO, - db_type="redis", - description="建议将 FLUSHDB/FLUSHALL/DEBUG/SHUTDOWN 通过 rename-command 重命名", - config={"dangerous_commands": ["DEBUG", "SHUTDOWN"]}, - message_template="建议将 '{cmd}' 通过 rename-command 重命名,防止误操作", -)) -def check_redis_sec_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """危险命令重命名建议。 - - FLUSHDB/FLUSHALL 已由 CMD-001 硬拦截,此处仅对 DEBUG/SHUTDOWN - 等管理命令给出 rename-command 配置建议,避免与 CMD-001 重复。 - """ - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - # 排除已由 CMD-001 拦截的 FLUSHDB/FLUSHALL/CONFIG - dangerous = rule.config.get("dangerous_commands", ["DEBUG", "SHUTDOWN"]) - if cmd in dangerous: - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template.format(cmd=cmd), - )) - - -@register_rule(AuditRuleV2( - code="REDIS-SEC-004", - name="禁止绑定0.0.0.0(无密码时)", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="Redis 无密码时不应绑定 0.0.0.0,必须绑定指定内网 IP", - message_template="Redis 配置安全风险:未设置密码时不应绑定 0.0.0.0,请绑定指定内网 IP 并设置强密码(requirepass + rename-command)", -)) -def check_redis_sec_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """禁止绑定 0.0.0.0 检查。 - - 检测 CONFIG SET bind 命令。 - """ - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - if cmd == "CONFIG" and len(args) >= 3: - subcmd = args[0].upper() - param = args[1].upper() - if subcmd == "SET" and param == "BIND": - bind_value = args[2] - if bind_value == "0.0.0.0": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - -@register_rule(AuditRuleV2( - code="REDIS-SEC-005", - name="必须开启保护模式", - category=RuleCategory.SECURITY, - severity=Severity.ERROR, - db_type="redis", - description="Redis 必须开启保护模式(protected-mode yes),防止未授权访问", - message_template="Redis 安全:未开启保护模式存在严重安全风险,请在配置中设置 protected-mode yes 并配置 bind 内网 IP", -)) -def check_redis_sec_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """保护模式检查。 - - 检测 CONFIG SET protected-mode 命令。 - """ - command, args = _parse_redis_command(sql_clean) - if not command: - return - cmd = command.upper() - - if cmd == "CONFIG" and len(args) >= 3: - subcmd = args[0].upper() - param = args[1].upper() - if subcmd == "SET" and param == "PROTECTED-MODE": - value = args[2].upper() - if value == "NO": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=rule.severity, - message=rule.message_template, - )) - - # 如果检测到 AUTH 失败后的操作,也提示保护模式 - if cmd == "AUTH": - report.findings.append(AuditFindingV2( - rule_code=rule.code, - rule_name=rule.name, - level=Severity.INFO, - message="认证操作:请确保 Redis 已开启 protected-mode yes 并配置了强密码", - )) - - -# ═══════════════════════════════════════════════ -# 规则统计与导出 -# ═══════════════════════════════════════════════ - - -def get_redis_rule_stats() -> Dict[str, Any]: - """获取 Redis 审核规则的统计信息。 - - Returns: - 包含规则总数和各分类数量的字典 - """ - # 导入注册表获取已注册规则 - from app.services.audit_engine_v2 import _RULE_REGISTRY, AuditRuleV2 - - redis_rules = [ - (code, rule, func) - for code, (rule, func) in _RULE_REGISTRY.items() - if code.startswith("REDIS-") - ] - - categories: Dict[str, int] = {} - severities: Dict[str, int] = {} - - for code, rule, _ in redis_rules: - cat = rule.category.value if hasattr(rule.category, "value") else str(rule.category) - categories[cat] = categories.get(cat, 0) + 1 - sev = rule.severity.value if hasattr(rule.severity, "value") else str(rule.severity) - severities[sev] = severities.get(sev, 0) + 1 - - return { - "total": len(redis_rules), - "by_category": categories, - "by_severity": severities, - "rules": sorted([code for code, _, _ in redis_rules]), - } - - -def print_redis_rule_summary() -> None: - """打印 Redis 审核规则汇总信息。""" - stats = get_redis_rule_stats() - print("=" * 60) - print("Redis 审核规则引擎 - 规则汇总") - print("=" * 60) - print(f"规则总数: {stats['total']} 条") - print() - print("按分类统计:") - for cat, count in sorted(stats["by_category"].items()): - print(f" {cat}: {count} 条") - print() - print("按严重级别统计:") - for sev, count in sorted(stats["by_severity"].items()): - print(f" {sev}: {count} 条") - print() - print("规则编码列表:") - for code in stats["rules"]: - print(f" {code}") - print("=" * 60) - - -# 模块加载时自动打印规则摘要(仅在直接运行时) -if __name__ == "__main__": - print_redis_rule_summary() diff --git a/backend/app/services/redis_connector.py b/backend/app/services/redis_connector.py deleted file mode 100644 index 6490099..0000000 --- a/backend/app/services/redis_connector.py +++ /dev/null @@ -1,269 +0,0 @@ -"""Redis connector — async interface for Redis instance management. - -Provides connection, info retrieval, key CRUD, command execution, and -performance monitoring via redis.asyncio. -""" -import json -from typing import Any, Dict, List, Optional, Tuple - -import redis.asyncio as aioredis - -from app.core.crypto import decrypt_password - - -class RedisConnector: - """Async Redis connector for instance operations.""" - - @staticmethod - async def get_connection(instance) -> aioredis.Redis: - """Create an async Redis connection from an Instance model.""" - pwd = decrypt_password(instance.password) - return aioredis.Redis( - host=instance.host, - port=instance.port, - password=pwd or None, - decode_responses=True, - ) - - @staticmethod - async def get_info(instance) -> dict: - """Get Redis INFO ALL parsed into a structured dict.""" - conn = await RedisConnector.get_connection(instance) - try: - raw: str = await conn.info("all") - return raw - finally: - await conn.close() - - @staticmethod - async def get_dbsize(instance, db: int = 0) -> int: - """Get number of keys in a database.""" - conn = await RedisConnector.get_connection(instance) - try: - await conn.select(db) - return await conn.dbsize() - finally: - await conn.close() - - @staticmethod - async def scan_keys( - instance, - db: int = 0, - pattern: str = "*", - type_filter: Optional[str] = None, - cursor: int = 0, - count: int = 50, - ) -> Tuple[int, List[Dict[str, Any]]]: - """SCAN keys with optional type filter. Returns (next_cursor, key_details).""" - conn = await RedisConnector.get_connection(instance) - try: - await conn.select(db) - next_cursor, keys = await conn.scan( - cursor=cursor, match=pattern, count=count - ) - result = [] - for key in keys: - key_type = await conn.type(key) - if type_filter and key_type != type_filter: - continue - ttl = await conn.ttl(key) - result.append( - {"key": key, "type": key_type, "ttl": ttl} - ) - return next_cursor, result - finally: - await conn.close() - - @staticmethod - async def get_key_value( - instance, db: int, key: str, key_type: Optional[str] = None - ) -> Any: - """Get value for a key based on its type.""" - conn = await RedisConnector.get_connection(instance) - try: - await conn.select(db) - if key_type is None: - key_type = await conn.type(key) - - if key_type == "string": - value = await conn.get(key) - elif key_type == "hash": - value = await conn.hgetall(key) - elif key_type == "list": - value = await conn.lrange(key, 0, -1) - elif key_type == "set": - value = list(await conn.smembers(key)) - elif key_type == "zset": - raw = await conn.zrange(key, 0, -1, withscores=True) - value = [{"member": m, "score": s} for m, s in raw] - else: - value = None - - ttl = await conn.ttl(key) - return {"key": key, "type": key_type, "value": value, "ttl": ttl} - finally: - await conn.close() - - @staticmethod - async def set_key_value( - instance, - db: int, - key: str, - value: Any, - type_: str = "string", - ttl: Optional[int] = None, - ) -> str: - """Set a key value. Type determines how value is stored.""" - conn = await RedisConnector.get_connection(instance) - try: - await conn.select(db) - - if type_ == "string": - await conn.set(key, value) - elif type_ == "hash": - # value should be a dict - if isinstance(value, str): - value = json.loads(value) - await conn.hset(key, mapping=value) - elif type_ == "list": - if isinstance(value, str): - value = json.loads(value) - await conn.delete(key) - if value: - await conn.rpush(key, *value) - elif type_ == "set": - if isinstance(value, str): - value = json.loads(value) - await conn.delete(key) - if value: - await conn.sadd(key, *value) - elif type_ == "zset": - if isinstance(value, str): - value = json.loads(value) - await conn.delete(key) - if isinstance(value, list): - for item in value: - member = item.get("member", "") - score = item.get("score", 0) - await conn.zadd(key, {member: score}) - - if ttl and ttl > 0: - await conn.expire(key, ttl) - - return "OK" - finally: - await conn.close() - - @staticmethod - async def del_key(instance, db: int, key: str) -> int: - """Delete a key. Returns number of keys deleted.""" - conn = await RedisConnector.get_connection(instance) - try: - await conn.select(db) - return await conn.delete(key) - finally: - await conn.close() - - @staticmethod - async def set_ttl(instance, db: int, key: str, seconds: int) -> bool: - """Set TTL on a key.""" - conn = await RedisConnector.get_connection(instance) - try: - await conn.select(db) - return await conn.expire(key, seconds) - finally: - await conn.close() - - @staticmethod - async def get_ttl(instance, db: int, key: str) -> int: - """Get TTL of a key.""" - conn = await RedisConnector.get_connection(instance) - try: - await conn.select(db) - return await conn.ttl(key) - finally: - await conn.close() - - @staticmethod - async def execute_command(instance, db: int, command: str) -> str: - """Execute a raw Redis command.""" - conn = await RedisConnector.get_connection(instance) - try: - await conn.select(db) - parts = command.strip().split() - if not parts: - return "" - cmd = parts[0].upper() - args = parts[1:] - result = await conn.execute_command(cmd, *args) - if result is None: - return "(nil)" - if isinstance(result, bytes): - return result.decode("utf-8", errors="replace") - if isinstance(result, (list, set)): - formatted = [] - for item in result: - if isinstance(item, bytes): - formatted.append(item.decode("utf-8", errors="replace")) - else: - formatted.append(str(item)) - return "\n".join(formatted) - return str(result) - finally: - await conn.close() - - @staticmethod - async def flush_db(instance, db: int = 0) -> str: - """FLUSHDB — dangerous! Admin only.""" - conn = await RedisConnector.get_connection(instance) - try: - await conn.select(db) - await conn.flushdb() - return "OK" - finally: - await conn.close() - - @staticmethod - async def get_slowlog(instance, count: int = 10) -> List[dict]: - """Get slowlog entries.""" - conn = await RedisConnector.get_connection(instance) - try: - raw = await conn.slowlog_get(count) - result = [] - for entry in raw: - result.append( - { - "id": entry.get("id", entry.get(0)), - "timestamp": entry.get("start_time", entry.get(1)), - "duration_us": entry.get("duration", entry.get(2)), - "command": " ".join( - str(c) if not isinstance(c, bytes) else c.decode("utf-8", errors="replace") - for c in entry.get("command", entry.get(3, [])) - ), - } - ) - return result - finally: - await conn.close() - - @staticmethod - async def get_clients(instance) -> List[dict]: - """Get client list.""" - conn = await RedisConnector.get_connection(instance) - try: - raw: str = await conn.client_list() - clients = [] - for line in raw.split("\n"): - line = line.strip() - if not line: - continue - client = {} - for pair in line.split(" "): - if "=" in pair: - k, v = pair.split("=", 1) - client[k] = v - if client: - clients.append(client) - return clients - finally: - await conn.close() diff --git a/backend/app/services/redis_validator.py b/backend/app/services/redis_validator.py deleted file mode 100644 index 3bdcbb0..0000000 --- a/backend/app/services/redis_validator.py +++ /dev/null @@ -1,280 +0,0 @@ -"""Redis 命令安全验证器 — 区分只读命令和危险命令。 - -只读命令白名单(查询模式允许): -- 字符串: GET, MGET, STRLEN, GETRANGE, GETBIT, SUBSTR -- 哈希: HGET, HGETALL, HKEYS, HVALS, HLEN, HEXISTS, HMGET, HSCAN, HRANDFIELD -- 列表: LRANGE, LLEN, LINDEX, LPOS -- 集合: SMEMBERS, SCARD, SISMEMBER, SRANDMEMBER, SUNION, SINTER, SDIFF, SSCAN, SMISSING -- 有序集合: ZRANGE, ZREVRANGE, ZRANGEBYSCORE, ZREVRANGEBYSCORE, ZCARD, ZSCORE, ZRANK, ZREVRANK, ZCOUNT, ZLEXCOUNT, ZSCAN, ZRANGEBYLEX, ZMPOP -- 流: XREAD, XREVRANGE, XRANGE, XLEN, XINFO -- 通用: EXISTS, TTL, PTTL, TYPE, DBSIZE, SCAN, INFO, CONFIG GET, COMMAND INFO, COMMAND DOCS, MEMORY USAGE, OBJECT, CLIENT LIST, CLIENT INFO, SLOWLOG GET, TIME, ECHO, PING, LOLWUT -- Geo: GEOPOS, GEODIST, GEORADIUS, GEORADIUSBYMEMBER, GEOSEARCH, GEOHASH -- Bitmap: BITCOUNT, BITPOS, GETBIT -- HyperLogLog: PFCOUNT - -危险命令(查询模式禁止): -- 写: SET, MSET, HSET, LPUSH, RPUSH, SADD, ZADD, DEL, FLUSHDB, FLUSHALL -- 管理: SHUTDOWN, CONFIG SET, DEBUG, SAVE, BGSAVE, BGREWRITEAOF, ACL, REPLICAOF, SLAVEOF -- 执行: EVAL, EVALSHA, FCALL, FCALL_RO -""" - -from typing import Set, Tuple - - -class RedisCommandValidator: - """Redis 命令安全验证器。 - - 通过 READONLY_COMMANDS 白名单和 DANGEROUS_COMMANDS 黑名单 - 对 Redis 命令进行双重校验,确保查询模式下只能执行安全的只读操作。 - """ - - # ── 只读命令白名单 ────────────────────────────────────────── - - READONLY_COMMANDS: Set[str] = { - # 字符串操作 - "GET", - "MGET", - "STRLEN", - "GETRANGE", - "GETBIT", - "SUBSTR", - # 哈希操作 - "HGET", - "HGETALL", - "HKEYS", - "HVALS", - "HLEN", - "HEXISTS", - "HMGET", - "HSCAN", - "HRANDFIELD", - # 列表操作 - "LRANGE", - "LLEN", - "LINDEX", - "LPOS", - # 集合操作 - "SMEMBERS", - "SCARD", - "SISMEMBER", - "SRANDMEMBER", - "SUNION", - "SINTER", - "SDIFF", - "SSCAN", - "SMISMEMBER", - # 有序集合操作 - "ZRANGE", - "ZREVRANGE", - "ZRANGEBYSCORE", - "ZREVRANGEBYSCORE", - "ZCARD", - "ZSCORE", - "ZRANK", - "ZREVRANK", - "ZCOUNT", - "ZLEXCOUNT", - "ZSCAN", - "ZRANGEBYLEX", - # 流操作 - "XREAD", - "XREVRANGE", - "XRANGE", - "XLEN", - "XINFO", - # 通用命令 - "EXISTS", - "TTL", - "PTTL", - "TYPE", - "DBSIZE", - "SCAN", - "INFO", - "TIME", - "ECHO", - "PING", - "LOLWUT", - "COMMAND", - "MEMORY", - "OBJECT", - "CLIENT", - "SLOWLOG", - # Geo 空间 - "GEOPOS", - "GEODIST", - "GEORADIUS", - "GEORADIUSBYMEMBER", - "GEOSEARCH", - "GEOHASH", - "GEOSEARCHSTORE", - # Bitmap - "BITCOUNT", - "BITPOS", - # HyperLogLog - "PFCOUNT", - } - - # ── 危险命令黑名单 ────────────────────────────────────────── - - DANGEROUS_COMMANDS: Set[str] = { - # 写操作 - "SET", - "MSET", - "MSETNX", - "SETEX", - "PSETEX", - "SETNX", - "GETSET", - "INCR", - "INCRBY", - "INCRBYFLOAT", - "DECR", - "DECRBY", - "APPEND", - "SETBIT", - "SETRANGE", - "HSET", - "HMSET", - "HSETNX", - "HDEL", - "HINCRBY", - "HINCRBYFLOAT", - "LPUSH", - "RPUSH", - "LPUSHX", - "RPUSHX", - "LPOP", - "RPOP", - "BLPOP", - "BRPOP", - "LREM", - "LSET", - "LTRIM", - "RPOPLPUSH", - "BRPOPLPUSH", - "SADD", - "SREM", - "SPOP", - "SMOVE", - "SDIFFSTORE", - "SINTERSTORE", - "SUNIONSTORE", - "ZADD", - "ZREM", - "ZINCRBY", - "ZREMRANGEBYRANK", - "ZREMRANGEBYSCORE", - "ZREMRANGEBYLEX", - "ZUNIONSTORE", - "ZINTERSTORE", - "ZDIFFSTORE", - "ZMPOP", - "ZPOPMAX", - "ZPOPMIN", - "BZPOPMAX", - "BZPOPMIN", - "DEL", - "UNLINK", - "FLUSHDB", - "FLUSHALL", - "RENAME", - "RENAMENX", - "MOVE", - # 管理命令 - "SHUTDOWN", - "CONFIG", - "DEBUG", - "SAVE", - "BGSAVE", - "BGREWRITEAOF", - "ACL", - "REPLICAOF", - "SLAVEOF", - "SYNC", - "PSYNC", - "MONITOR", - "KEYS", - "MIGRATE", - "RESTORE", - # 脚本执行 - "EVAL", - "EVALSHA", - "FCALL", - "FCALL_RO", - "SCRIPT", - # 事务 - "MULTI", - "EXEC", - "DISCARD", - "WATCH", - "UNWATCH", - # 发布/订阅 - "PUBLISH", - "SUBSCRIBE", - "UNSUBSCRIBE", - "PSUBSCRIBE", - "PUNSUBSCRIBE", - # 模块 - "MODULE", - } - - # ── 验证方法 ──────────────────────────────────────────────── - - @classmethod - def validate(cls, command: str) -> Tuple[bool, str]: - """验证 Redis 命令是否只读安全。 - - 验证逻辑: - 1. 提取命令关键字(首个单词,转大写)。 - 2. 若在 DANGEROUS_COMMANDS 中,直接拒绝。 - 3. 若在 READONLY_COMMANDS 中,允许执行。 - 4. 否则视为未知命令,拒绝执行。 - - Args: - command: 完整的 Redis 命令字符串,如 "GET mykey" 或 "HGETALL users"。 - - Returns: - (is_safe, error_message): - - is_safe: True 表示命令安全,可执行。 - - error_message: 空字符串表示无错误,否则包含拒绝原因。 - """ - if not command or not command.strip(): - return False, "命令不能为空" - - cmd = command.strip().split()[0].upper() - - if cmd in cls.DANGEROUS_COMMANDS: - return False, f"Redis 命令 '{cmd}' 为危险命令,禁止在查询模式下执行" - - if cmd not in cls.READONLY_COMMANDS: - return False, f"Redis 命令 '{cmd}' 不在查询白名单中,禁止执行" - - return True, "" - - @classmethod - def is_dangerous(cls, command: str) -> bool: - """判断命令是否为危险命令。 - - Args: - command: 完整的 Redis 命令字符串。 - - Returns: - True 表示命令属于危险命令。 - """ - if not command or not command.strip(): - return False - cmd = command.strip().split()[0].upper() - return cmd in cls.DANGEROUS_COMMANDS - - @classmethod - def is_readonly(cls, command: str) -> bool: - """判断命令是否为只读命令。 - - Args: - command: 完整的 Redis 命令字符串。 - - Returns: - True 表示命令在白名单中且安全。 - """ - is_safe, _ = cls.validate(command) - return is_safe diff --git a/backend/app/services/risk_evaluator.py b/backend/app/services/risk_evaluator.py deleted file mode 100644 index 0b862e8..0000000 --- a/backend/app/services/risk_evaluator.py +++ /dev/null @@ -1,198 +0,0 @@ -"""工单风险等级自动评估。 - -根据 SQL 类型 + 操作对象 + 审核结果,自动计算 risk_level: - - low: SELECT / 简单 DML (有 WHERE + LIMIT) - - medium: 普通 DML / 低风险 DDL (CREATE TABLE) - - high: 大表 ALTER / 无 WHERE 的 UPDATE/DELETE / 多表 JOIN - - critical: DROP TABLE / TRUNCATE / FLUSHDB / FLUSHALL / dropDatabase - -评估时机:工单创建时自动计算,写入 ticket.risk_level。 -""" - -import re -from typing import Optional - - -def evaluate_risk_level( - sql_content: str, - db_type: str = "mysql", - sql_type: str = "", - audit_findings: Optional[list] = None, - statement_count: int = 1, - table_sizes: Optional[dict] = None, -) -> str: - """评估 SQL 的风险等级。 - - Args: - sql_content: SQL 文本(可能包含多条语句) - db_type: 数据库类型 mysql/postgres/redis/mongodb - sql_type: 前端传入的 SQL 类型标注 DDL/DML/DQL 等 - audit_findings: 审核引擎返回的 findings 列表 - statement_count: SQL 语句条数 - table_sizes: EXPLAIN 预检返回的表大小 {table_name: row_count} - - Returns: - risk_level: low / medium / high / critical - """ - sql_upper = sql_content.strip().upper() - - # ── Redis 特殊处理 ── - if db_type == "redis": - return _evaluate_redis_risk(sql_upper) - - # ── MongoDB 特殊处理 ── - if db_type == "mongodb": - return _evaluate_mongo_risk(sql_upper) - - # ── MySQL / PostgreSQL ── - - # 1. critical 级别 — 不可逆危险操作 - critical_patterns = [ - r"\bDROP\s+TABLE\b", - r"\bDROP\s+DATABASE\b", - r"\bDROP\s+SCHEMA\b", - r"\bTRUNCATE\s", - ] - for pattern in critical_patterns: - if re.search(pattern, sql_upper): - return "critical" - - # 2. high 级别 — 高风险操作 - high_indicators = 0 - - # ALTER 大操作(但不包括 ADD INDEX 这类较小操作) - if re.search(r"\bALTER\s+TABLE\b", sql_upper): - alter_high = [ - r"\bDROP\s+COLUMN\b", - r"\bMODIFY\s+COLUMN\b", - r"\bCHANGE\s+COLUMN\b", - r"\bDROP\s+INDEX\b", - r"\bDROP\s+PRIMARY\s+KEY\b", - r"\bCONVERT\s+TO\s+CHARACTER\s+SET\b", - r"\bRENAME\s+(TO|AS)\b", - ] - for p in alter_high: - if re.search(p, sql_upper): - high_indicators += 1 - break - - # 无 WHERE 的 UPDATE / DELETE - if re.search(r"\bUPDATE\s", sql_upper) and not re.search(r"\bWHERE\b", sql_upper): - high_indicators += 1 - if re.search(r"\bDELETE\s+FROM\b", sql_upper) and not re.search(r"\bWHERE\b", sql_upper): - high_indicators += 1 - - # 大量语句(>5条) - if statement_count > 5: - high_indicators += 1 - - # 审核结果有 ERROR 级别 - if audit_findings: - errors = [f for f in audit_findings if f.get("level") == "error"] - if len(errors) >= 2: - high_indicators += 1 - - if high_indicators > 0: - return "high" - - # 2.5 大表检测 — EXPLAIN 预检返回的 table_sizes - # ALTER/UPDATE/DELETE 涉及大表(>10万行)时升级为 high - if table_sizes: - max_rows = max(table_sizes.values()) if table_sizes else 0 - if max_rows > 100_000 and re.search(r"\b(ALTER\s+TABLE|UPDATE|DELETE\s+FROM)\b", sql_upper): - return "high" - - # 3. medium 级别 — 常规变更 - medium_types = {"DDL", "DML", "SET", "HSET", "HMSET"} - if sql_type in medium_types: - return "medium" - - # 有 WHERE 但没有 LIMIT 的 DELETE - if re.search(r"\bDELETE\s+FROM\b", sql_upper): - if not re.search(r"\bLIMIT\b", sql_upper): - return "medium" - - # 任何 DDL - ddl_keywords = ["CREATE ", "ALTER ", "RENAME ", "GRANT ", "REVOKE ", "CREATE INDEX", "CREATE UNIQUE"] - for kw in ddl_keywords: - if kw in sql_upper: - return "medium" - - # 4. low 级别 — 只读 / 安全操作 - return "low" - - -def _evaluate_redis_risk(sql_upper: str) -> str: - """Redis 命令风险评估。""" - critical_cmds = ["FLUSHDB", "FLUSHALL"] - for cmd in critical_cmds: - if sql_upper.startswith(cmd): - return "critical" - - # 危险写操作 - high_cmds = ["DEL ", "UNLINK ", "RENAME ", "FLUSHDB", "FLUSHALL"] - for cmd in high_cmds: - if sql_upper.startswith(cmd): - return "high" - - # 一般写操作 - write_cmds = ["SET ", "HSET ", "HMSET ", "LPUSH ", "RPUSH ", "SADD ", "ZADD ", - "INCR", "DECR", "EXPIRE ", "PEXPIRE "] - for cmd in write_cmds: - if sql_upper.startswith(cmd): - return "medium" - - # 只读命令 - return "low" - - -def _evaluate_mongo_risk(sql_upper: str) -> str: - """MongoDB 操作风险评估。""" - sql_lower = sql_upper.lower() - critical_ops = ["dropdatabase", "drop database"] - for op in critical_ops: - if op in sql_lower: - return "critical" - - high_ops = ["drop(", "dropcollection", "remove(", "deletemany"] - for op in high_ops: - if op in sql_lower: - return "high" - - write_ops = ["insertone", "insertmany", "updateone", "updatemany", "deleteone", - "createindex", "create_collection"] - for op in write_ops: - if op in sql_lower: - return "medium" - - return "low" - - -# ── 风险等级配置(用于前端展示 / 审批流匹配)── - -RISK_LEVEL_CONFIG = { - "low": { - "label": "低风险", - "color": "#52c41a", - "description": "只读查询 / 安全操作", - "min_approvers": 1, - }, - "medium": { - "label": "中风险", - "color": "#faad14", - "description": "常规数据变更 / DDL 操作", - "min_approvers": 1, - }, - "high": { - "label": "高风险", - "color": "#fa8c16", - "description": "无 WHERE 删除/更新 / 大表 ALTER / 批量操作", - "min_approvers": 2, - }, - "critical": { - "label": "极高风险", - "color": "#f5222d", - "description": "DROP TABLE / TRUNCATE / FLUSHDB 等不可逆操作", - "min_approvers": 2, - }, -} diff --git a/backend/app/services/role_permissions.py b/backend/app/services/role_permissions.py deleted file mode 100644 index 09881a1..0000000 --- a/backend/app/services/role_permissions.py +++ /dev/null @@ -1,279 +0,0 @@ -"""角色权限服务 — 基于角色的权限查询。 - -权限逻辑: -- admin/dba/auditor 权限由角色硬编码决定,不需要查权限组 -- developer 权限由关联的权限组决定 -- 工单操作权限完全由角色决定 - -权限组类型 (access_type): -- readonly: 只读查看 — 关联只读库/从库,用于查询数据 -- write: 写入变更 — 关联读写库/主库,用于数据变更工单 -""" - -from __future__ import annotations - -import json -import logging -from typing import Dict, List, Optional - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.user import User -from app.models.instance import Instance -from app.models.instance_group import InstanceGroup, user_groups, instance_group_members - -logger = logging.getLogger(__name__) - -# ============================================================ -# 角色权限映射(硬编码) -# ============================================================ -# access_type 值: -# "all" — 全部实例全部库 -# "groups" — 由权限组决定 -# None — 无权限 -# ticket 值: list of allowed ticket actions -ROLE_PERMISSIONS: Dict[str, dict] = { - "admin": { - "readonly": "all", - "write": "all", - "ticket": ["create", "approve", "reject", "execute", "cancel"], - }, - "dba": { - "readonly": "all", - "write": "all", - "ticket": ["approve", "reject", "execute"], - }, - "auditor": { - "readonly": "all", - "write": None, - "ticket": ["approve", "reject"], - }, - "developer": { - "readonly": "groups", - "write": "groups", - "ticket": ["create", "cancel_own"], - }, -} - - -def _get_role_config(role: str) -> dict: - """获取角色配置,未知角色按最小权限处理。""" - return ROLE_PERMISSIONS.get(role, {"readonly": None, "write": None, "ticket": []}) - - -# ============================================================ -# Public API -# ============================================================ - - -async def get_user_permissions(db: AsyncSession, user: User) -> dict: - """返回用户的实际权限概览。""" - role_config = _get_role_config(user.role) - result = { - "role": user.role, - "access": { - "readonly": role_config.get("readonly"), - "write": role_config.get("write"), - }, - "ticket": role_config.get("ticket", []), - "groups": [], - } - - has_group_access = any( - v == "groups" for k, v in role_config.items() if k in ("readonly", "write") - ) - if has_group_access: - result["groups"] = await _get_user_groups_detail(db, user) - - return result - - -async def can_access_database( - db: AsyncSession, - user: User, - instance_id: int, - database: str, - access_type: str, -) -> bool: - """判断用户是否可以对指定实例的指定库执行指定类型的操作。 - - Args: - access_type: "readonly" / "write" - """ - if access_type not in ("readonly", "write"): - return False - - role_config = _get_role_config(user.role) - access_value = role_config.get(access_type) - - if access_value == "all": - return True - if access_value is None: - return False - if access_value == "groups": - return await _check_group_access(db, user, instance_id, database, access_type) - - return False - - -def can_ticket_action(user: User, action: str) -> bool: - """判断用户是否可以执行指定工单操作。""" - role_config = _get_role_config(user.role) - return action in role_config.get("ticket", []) - - -async def get_accessible_instances( - db: AsyncSession, - user: User, - access_type: str = "readonly", -) -> List[Instance]: - """获取用户可访问的实例列表(按 access_type 过滤)。 - - - admin/dba: 返回所有实例 - - auditor: readonly 返回所有,write 返回空 - - developer: 返回其权限组关联的实例(通过 instance_group_members) - """ - role_config = _get_role_config(user.role) - access_value = role_config.get(access_type) - - if access_value == "all": - result = await db.execute(select(Instance)) - return list(result.scalars().all()) - - if access_value is None: - return [] - - # "groups" — 查询权限组关联的实例 via instance_group_members - group_ids = await _get_user_group_ids(db, user) - if not group_ids: - return [] - - # Query instance_group_members for the matching access_type - 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, - ) - ) - instance_ids = list({row[0] for row in result.all()}) - - if not instance_ids: - return [] - - inst_result = await db.execute( - select(Instance).where(Instance.id.in_(instance_ids)) - ) - return list(inst_result.scalars().all()) - - -# ============================================================ -# Internal helpers -# ============================================================ - - -async def _get_user_group_ids(db: AsyncSession, user: User) -> List[int]: - """获取用户关联的所有权限组 ID。""" - 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_user_groups_detail(db: AsyncSession, user: User) -> List[dict]: - """获取用户关联权限组的详细信息。""" - group_ids = await _get_user_group_ids(db, user) - if not group_ids: - return [] - - result = await db.execute( - select(InstanceGroup).where(InstanceGroup.id.in_(group_ids)) - ) - groups = result.scalars().all() - - details = [] - for g in groups: - # Get instance IDs by access_type from instance_group_members - ro_result = await db.execute( - select(instance_group_members.c.instance_id).where( - instance_group_members.c.group_id == g.id, - instance_group_members.c.access_type == "readonly", - ) - ) - wr_result = await db.execute( - select(instance_group_members.c.instance_id).where( - instance_group_members.c.group_id == g.id, - instance_group_members.c.access_type == "write", - ) - ) - ro_ids = [row[0] for row in ro_result.all()] - wr_ids = [row[0] for row in wr_result.all()] - details.append({ - "group_id": g.id, - "group_name": g.name, - "readonly_instance_ids": ro_ids, - "write_instance_ids": wr_ids, - "allowed_databases": g.get_allowed_databases(), - }) - return details - - -async def _check_group_access( - db: AsyncSession, - user: User, - instance_id: int, - database: str, - access_type: str, -) -> bool: - """检查用户通过权限组是否能访问指定实例+库。""" - inst_result = await db.execute( - select(Instance).where(Instance.id == instance_id) - ) - instance = inst_result.scalar_one_or_none() - if not instance: - return False - - # Public instance (no group_id) — readonly allowed - if instance.group_id is None: - return access_type == "readonly" - - group_ids = await _get_user_group_ids(db, user) - - # Check if any of the user's groups contain this instance with matching access_type - result = await db.execute( - select(instance_group_members).where( - instance_group_members.c.instance_id == instance_id, - instance_group_members.c.group_id.in_(group_ids), - instance_group_members.c.access_type == access_type, - ) - ) - member_row = result.first() - if not member_row: - # For readonly access, write access_type also grants readonly - if access_type == "readonly": - wr_result = await db.execute( - select(instance_group_members).where( - instance_group_members.c.instance_id == instance_id, - instance_group_members.c.group_id.in_(group_ids), - instance_group_members.c.access_type == "write", - ) - ) - member_row = wr_result.first() - - if not member_row: - return False - - # Check allowed_databases from the group - group_result = await db.execute( - select(InstanceGroup).where(InstanceGroup.id == member_row.group_id) - ) - group = group_result.scalar_one_or_none() - if not group: - return False - - allowed_dbs = group.get_allowed_databases() - if not allowed_dbs: - return True - - return database in allowed_dbs diff --git a/backend/app/services/rollback_generator.py b/backend/app/services/rollback_generator.py deleted file mode 100644 index 3bf2940..0000000 --- a/backend/app/services/rollback_generator.py +++ /dev/null @@ -1,836 +0,0 @@ -"""自动回滚 SQL 生成器 — 为 DML 操作生成可执行的回滚语句。 - -支持: -- INSERT → DELETE (通过主键) -- UPDATE → UPDATE (恢复旧值) -- DELETE → INSERT (重新插入被删数据) - -生成策略: -1. 对于 INSERT: 查询刚插入行的主键,生成 DELETE WHERE pk IN (...) -2. 对于 UPDATE: 在执行前捕获旧值,生成反向 UPDATE -3. 对于 DELETE: 在执行前备份被删数据,生成 INSERT 语句 - -使用方式: - generator = RollbackGenerator() - rollback_sql = await generator.generate(instance, database, original_sql) -""" -import re -import asyncio -from typing import Any, Dict, List, Optional, Tuple - -import sqlparse -from sqlparse.sql import Identifier, Where, Comparison -from sqlparse.tokens import Keyword, DML - -from app.models.instance import Instance -from app.services.sql_analyzer import SQLAnalyzer, SQLType - - -class RollbackGeneratorError(Exception): - """回滚 SQL 生成异常。""" - pass - - -class RollbackGenerator: - """自动回滚 SQL 生成器。 - - 为 INSERT/UPDATE/DELETE 语句自动生成对应的回滚 SQL。 - 生成回滚 SQL 需要连接目标数据库查询元数据和现有数据。 - - Attributes: - _analyzer: SQL 分析器,用于解析 SQL 类型 - """ - - def __init__(self) -> None: - """初始化回滚生成器。""" - self._analyzer = SQLAnalyzer() - - # ===== 公共接口 ===== - - async def generate( - self, instance: Instance, database: str, sql: str - ) -> str: - """为给定的 DML 语句生成回滚 SQL。 - - 主入口方法。根据 SQL 类型自动分派到对应的生成器。 - - Args: - instance: 数据源实例(包含连接信息) - database: 目标数据库名 - sql: 原始 DML 语句 - - Returns: - 可执行的回滚 SQL 字符串 - - Raises: - RollbackGeneratorError: 生成失败时抛出 - """ - sql = sql.strip() - if not sql: - raise RollbackGeneratorError("SQL 语句不能为空") - - # 解析 SQL 类型 - sql_type = self._analyzer._detect_sql_type(sql) - - if sql_type not in (SQLType.INSERT, SQLType.UPDATE, SQLType.DELETE): - return ( - f"-- 不支持为 {sql_type.value} 类型生成回滚 SQL\n" - f"-- 仅支持 INSERT / UPDATE / DELETE\n" - f"-- 原始 SQL: {sql[:200]}" - ) - - try: - if sql_type == SQLType.INSERT: - return await self._generate_insert_rollback(instance, database, sql) - elif sql_type == SQLType.UPDATE: - return await self._generate_update_rollback(instance, database, sql) - elif sql_type == SQLType.DELETE: - return await self._generate_delete_rollback(instance, database, sql) - except RollbackGeneratorError: - raise - except Exception as e: - raise RollbackGeneratorError(f"生成回滚 SQL 失败: {e}") - - async def preview( - self, instance: Instance, database: str, sql: str - ) -> str: - """预览回滚 SQL(不执行实际的数据库修改)。 - - 此方法是 generate 的只读包装,仅生成和返回回滚 SQL 文本, - 不会修改任何数据。 - - Args: - instance: 数据源实例 - database: 目标数据库名 - sql: 原始 DML 语句 - - Returns: - 回滚 SQL 字符串(预览模式,不会执行) - """ - try: - return await self.generate(instance, database, sql) - except RollbackGeneratorError as e: - return ( - f"-- 回滚 SQL 预览失败: {e}\n" - f"-- 原始 SQL: {sql[:200]}" - ) - - # ===== INSERT 回滚生成 ===== - - async def _generate_insert_rollback( - self, instance: Instance, database: str, sql: str - ) -> str: - """为 INSERT 语句生成回滚 SQL (DELETE)。 - - 策略: 提取表名和值,生成对应的 DELETE 语句。 - 如果表有主键,尝试精确定位插入的行。 - - Args: - instance: 数据源实例 - database: 目标数据库名 - sql: INSERT 语句 - - Returns: - DELETE 形式的回滚 SQL - """ - table_name = self._extract_table_from_insert(sql) - if not table_name: - return ( - f"-- 无法从 INSERT 语句中提取表名\n" - f"-- 原始 SQL: {sql[:200]}" - ) - - # 获取主键列名 - pk_columns = await self._get_primary_key(instance, database, table_name) - - if pk_columns: - # 有主键时,尝试构建基于主键的 DELETE - # 由于 INSERT 尚未执行,我们无法预知主键值(尤其是自增主键) - # 因此生成带有注释说明的回滚 SQL - pk_str = ", ".join(pk_columns) - rollback_sql = ( - f"-- 回滚 INSERT 操作: 删除表 {table_name} 中刚插入的行\n" - f"-- 主键列: {pk_str}\n" - f"-- 注意: 如果主键是自增的,需要在执行 INSERT 后获取主键值再执行以下 DELETE\n" - f"DELETE FROM `{table_name}` WHERE /* 请补充 WHERE 条件 */ 1=0;\n" - f"-- 示例: DELETE FROM `{table_name}` WHERE {pk_columns[0]} = <插入后的主键值>;" - ) - else: - # 无主键表:根据 INSERT 的列值构建 DELETE - columns, values = self._parse_insert_values(sql) - if columns and values: - conditions = " AND ".join( - f"`{col}` = {self._quote_value(val)}" - for col, val in zip(columns, values) - ) - rollback_sql = ( - f"-- 回滚 INSERT 操作: 表 {table_name} 无主键,使用全字段匹配删除\n" - f"-- 注意: 如果有重复数据,可能误删多行\n" - f"DELETE FROM `{table_name}` WHERE {conditions};" - ) - else: - rollback_sql = ( - f"-- 无法解析 INSERT 的列和值,请手动编写 DELETE 回滚\n" - f"-- 表名: {table_name}\n" - f"DELETE FROM `{table_name}` WHERE /* 请手动补充条件 */ 1=0;" - ) - - return rollback_sql - - # ===== UPDATE 回滚生成 ===== - - async def _generate_update_rollback( - self, instance: Instance, database: str, sql: str - ) -> str: - """为 UPDATE 语句生成回滚 SQL (反向 UPDATE)。 - - 策略: 在执行 UPDATE 前查询将被更新的行的当前值, - 生成能够恢复到这些旧值的 UPDATE 语句。 - - Args: - instance: 数据源实例 - database: 目标数据库名 - sql: UPDATE 语句 - - Returns: - 反向 UPDATE 形式的回滚 SQL - """ - table_name = self._extract_table_from_update(sql) - if not table_name: - return ( - f"-- 无法从 UPDATE 语句中提取表名\n" - f"-- 原始 SQL: {sql[:200]}" - ) - - # 提取 UPDATE 的 SET 子句和 WHERE 条件 - set_columns = self._extract_set_columns(sql) - where_clause = self._extract_where_clause(sql) - - if not set_columns: - return ( - f"-- 无法解析 UPDATE 的 SET 子句\n" - f"-- 原始 SQL: {sql[:200]}" - ) - - # 获取将被更新的行的当前值 - old_rows = await self._fetch_current_values( - instance, database, table_name, set_columns, where_clause - ) - - if not old_rows: - return ( - f"-- 未找到将被更新的行(可能 WHERE 条件不匹配任何数据)\n" - f"-- 表: {table_name}\n" - f"-- WHERE: {where_clause or '无(全表更新)'}\n" - f"-- 无需生成回滚 SQL" - ) - - # 获取主键用于精确定位每行 - pk_columns = await self._get_primary_key(instance, database, table_name) - - # 构建反向 UPDATE 语句 - rollback_statements: List[str] = [] - - for i, row in enumerate(old_rows): - if pk_columns and all(pk in row for pk in pk_columns): - # 使用主键定位 - pk_conditions = " AND ".join( - f"`{pk}` = {self._quote_value(row[pk])}" - for pk in pk_columns - ) - set_parts = ", ".join( - f"`{col}` = {self._quote_value(row.get(col))}" - for col in set_columns if col in row - ) - if set_parts: - rollback_statements.append( - f"UPDATE `{table_name}` SET {set_parts} WHERE {pk_conditions};" - ) - else: - # 无定位条件时使用行号注释 - set_parts = ", ".join( - f"`{col}` = {self._quote_value(row.get(col))}" - for col in set_columns if col in row - ) - if set_parts: - rollback_statements.append( - f"-- 第 {i + 1} 行: 无法精确定位,请手动补充 WHERE 条件\n" - f"-- UPDATE `{table_name}` SET {set_parts} WHERE /* 手动补充 */;" - ) - - if not rollback_statements: - return ( - f"-- 无法构建有效的 UPDATE 回滚语句\n" - f"-- 表: {table_name}" - ) - - rollback_sql = ( - f"-- 回滚 UPDATE 操作: 恢复表 {table_name} 的旧值\n" - f"-- 将影响 {len(old_rows)} 行\n" - f"-- 原 WHERE 条件: {where_clause or '无(全表更新)'}\n" - + "\n".join(rollback_statements) - ) - - return rollback_sql - - # ===== DELETE 回滚生成 ===== - - async def _generate_delete_rollback( - self, instance: Instance, database: str, sql: str - ) -> str: - """为 DELETE 语句生成回滚 SQL (INSERT)。 - - 策略: 在执行 DELETE 前查询将被删除的行的完整数据, - 生成能够重新插入这些数据的 INSERT 语句。 - - Args: - instance: 数据源实例 - database: 目标数据库名 - sql: DELETE 语句 - - Returns: - INSERT 形式的回滚 SQL - """ - table_name = self._extract_table_from_delete(sql) - if not table_name: - return ( - f"-- 无法从 DELETE 语句中提取表名\n" - f"-- 原始 SQL: {sql[:200]}" - ) - - # 获取将被删除的行数据 - where_clause = self._extract_where_clause(sql) - rows = await self._fetch_rows_to_delete( - instance, database, table_name, where_clause - ) - - if not rows: - return ( - f"-- 未找到将被删除的行(可能 WHERE 条件不匹配任何数据)\n" - f"-- 表: {table_name}\n" - f"-- WHERE: {where_clause or '无(全表删除)'}\n" - f"-- 无需生成回滚 SQL" - ) - - # 构建 INSERT 回滚语句 - rollback_statements: List[str] = [] - for row in rows: - insert_sql = self._build_insert_sql(table_name, row) - rollback_statements.append(insert_sql) - - rollback_sql = ( - f"-- 回滚 DELETE 操作: 重新插入表 {table_name} 的数据\n" - f"-- 将重新插入 {len(rows)} 行\n" - f"-- 原 WHERE 条件: {where_clause or '无(全表删除)'}\n" - + "\n".join(rollback_statements) - ) - - return rollback_sql - - # ===== 数据库元数据查询 ===== - - async def _get_primary_key( - self, instance: Instance, database: str, table_name: str - ) -> List[str]: - """获取表的主键列名列表。 - - Args: - instance: 数据源实例 - database: 目标数据库名 - table_name: 表名 - - Returns: - 主键列名列表,如果表没有主键则返回空列表 - - Raises: - RollbackGeneratorError: 查询失败时抛出 - """ - try: - if instance.db_type == "mysql": - return await self._get_pk_mysql(instance, database, table_name) - elif instance.db_type in ("postgres", "postgresql"): - return await self._get_pk_postgres(instance, database, table_name) - elif instance.db_type == "sqlite": - return await self._get_pk_sqlite(instance, table_name) - else: - raise RollbackGeneratorError( - f"暂不支持 {instance.db_type} 类型的主键查询" - ) - except RollbackGeneratorError: - raise - except Exception as e: - raise RollbackGeneratorError(f"获取主键失败: {e}") - - async def _get_pk_mysql( - self, instance: Instance, database: str, table_name: str - ) -> List[str]: - """获取 MySQL 表的主键列。""" - import aiomysql - - conn = await aiomysql.connect( - host=instance.host, port=instance.port, - user=instance.username, password=instance.password, - db=database, - ) - try: - async with conn.cursor() as cur: - sql = ( - "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE " - "WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s " - "AND CONSTRAINT_NAME = 'PRIMARY' " - "ORDER BY ORDINAL_POSITION" - ) - await cur.execute(sql, (database, table_name)) - rows = await cur.fetchall() - return [row[0] for row in rows] - finally: - conn.close() - - async def _get_pk_postgres( - self, instance: Instance, database: str, table_name: str - ) -> List[str]: - """获取 PostgreSQL 表的主键列。""" - import asyncpg - - dsn = ( - f"postgresql://{instance.username}:{instance.password}" - f"@{instance.host}:{instance.port}/{database}" - ) - conn = await asyncpg.connect(dsn) - try: - sql = ( - "SELECT a.attname FROM pg_index i " - "JOIN pg_attribute a ON a.attrelid = i.indrelid " - "AND a.attnum = ANY(i.indkey) " - "WHERE i.indrelid = $1::regclass AND i.indisprimary " - "ORDER BY array_position(i.indkey, a.attnum)" - ) - rows = await conn.fetch(sql, table_name) - return [row["attname"] for row in rows] - finally: - await conn.close() - - async def _get_pk_sqlite( - self, instance: Instance, table_name: str - ) -> List[str]: - """获取 SQLite 表的主键列。""" - loop = asyncio.get_event_loop() - - def _query() -> List[str]: - import sqlite3 - - conn = sqlite3.connect(instance.host) - cur = conn.execute(f"PRAGMA table_info({table_name})") - rows = cur.fetchall() - conn.close() - # 返回 pk > 0 的列名 - return [row[1] for row in rows if row[5] > 0] # cid, name, type, notnull, dflt, pk - - return await loop.run_in_executor(None, _query) - - # ===== 数据查询 ===== - - async def _fetch_current_values( - self, instance: Instance, database: str, table_name: str, - columns: List[str], where_clause: Optional[str], - ) -> List[Dict[str, Any]]: - """获取 UPDATE 操作前将被更新的行的当前值。 - - Args: - instance: 数据源实例 - database: 目标数据库名 - table_name: 表名 - columns: 将被更新的列名列表 - where_clause: UPDATE 的 WHERE 子句 - - Returns: - 行数据列表,每行包含主键列和将被更新的列的旧值 - """ - # 查询所有相关列的当前值 - col_list = ", ".join(f"`{c}`" for c in columns) - - # 同时获取主键列用于精确定位 - try: - pk_columns = await self._get_primary_key(instance, database, table_name) - select_cols = ", ".join( - f"`{c}`" for c in (list(dict.fromkeys(pk_columns + columns))) - ) - except Exception: - select_cols = col_list - pk_columns = [] - - sql = f"SELECT {select_cols} FROM `{table_name}`" - if where_clause: - sql += f" WHERE {where_clause}" - sql += " LIMIT 10000" - - if instance.db_type == "mysql": - return await self._query_rows_mysql(instance, database, sql) - elif instance.db_type in ("postgres", "postgresql"): - return await self._query_rows_postgres(instance, database, sql) - elif instance.db_type == "sqlite": - return await self._query_rows_sqlite(instance, sql) - else: - raise RollbackGeneratorError(f"不支持的数据库类型: {instance.db_type}") - - async def _fetch_rows_to_delete( - self, instance: Instance, database: str, table_name: str, - where_clause: Optional[str], - ) -> List[Dict[str, Any]]: - """获取将被 DELETE 的行数据。 - - Args: - instance: 数据源实例 - database: 目标数据库名 - table_name: 表名 - where_clause: DELETE 的 WHERE 子句 - - Returns: - 将被删除的完整行数据列表 - """ - sql = f"SELECT * FROM `{table_name}`" - if where_clause: - sql += f" WHERE {where_clause}" - sql += " LIMIT 10000" - - if instance.db_type == "mysql": - return await self._query_rows_mysql(instance, database, sql) - elif instance.db_type in ("postgres", "postgresql"): - return await self._query_rows_postgres(instance, database, sql) - elif instance.db_type == "sqlite": - return await self._query_rows_sqlite(instance, sql) - else: - raise RollbackGeneratorError(f"不支持的数据库类型: {instance.db_type}") - - # ===== 查询执行 ===== - - async def _query_rows_mysql( - self, instance: Instance, database: str, sql: str - ) -> List[Dict[str, Any]]: - """在 MySQL 上执行查询并返回行数据。""" - import aiomysql - - conn = await aiomysql.connect( - host=instance.host, port=instance.port, - user=instance.username, password=instance.password, - db=database, - ) - try: - async with conn.cursor(aiomysql.DictCursor) as cur: - await cur.execute(sql) - rows = await cur.fetchall() - return [dict(r) for r in rows] if rows else [] - finally: - conn.close() - - async def _query_rows_postgres( - self, instance: Instance, database: str, sql: str - ) -> List[Dict[str, Any]]: - """在 PostgreSQL 上执行查询并返回行数据。""" - import asyncpg - - dsn = ( - f"postgresql://{instance.username}:{instance.password}" - f"@{instance.host}:{instance.port}/{database}" - ) - conn = await asyncpg.connect(dsn) - try: - rows = await conn.fetch(sql) - return [dict(r) for r in rows] if rows else [] - finally: - await conn.close() - - async def _query_rows_sqlite( - self, instance: Instance, sql: str - ) -> List[Dict[str, Any]]: - """在 SQLite 上执行查询并返回行数据。""" - import sqlite3 - - loop = asyncio.get_event_loop() - - def _query() -> List[Dict[str, Any]]: - conn = sqlite3.connect(instance.host) - conn.row_factory = sqlite3.Row - cur = conn.execute(sql) - rows = [dict(r) for r in cur.fetchall()] - conn.close() - return rows - - return await loop.run_in_executor(None, _query) - - # ===== SQL 构建 ===== - - def _build_insert_sql(self, table_name: str, row: Dict[str, Any]) -> str: - """从行数据构建 INSERT 语句。 - - Args: - table_name: 表名 - row: 行数据字典 {column: value} - - Returns: - INSERT 语句字符串 - """ - if not row: - return f"-- 空行数据,无法构建 INSERT 语句" - - columns = list(row.keys()) - values = [self._quote_value(row[col]) for col in columns] - - col_str = ", ".join(f"`{c}`" for c in columns) - val_str = ", ".join(values) - - return f"INSERT INTO `{table_name}` ({col_str}) VALUES ({val_str});" - - def _build_update_sql( - self, table_name: str, row: Dict[str, Any], - columns: List[str], pk_columns: List[str], - ) -> str: - """构建反向 UPDATE 语句。 - - Args: - table_name: 表名 - row: 行数据字典 - columns: 要恢复的列名列表 - pk_columns: 主键列名列表 - - Returns: - UPDATE 语句字符串 - """ - set_parts = ", ".join( - f"`{col}` = {self._quote_value(row.get(col))}" - for col in columns if col in row - ) - - if pk_columns and all(pk in row for pk in pk_columns): - where = " AND ".join( - f"`{pk}` = {self._quote_value(row[pk])}" - for pk in pk_columns - ) - return f"UPDATE `{table_name}` SET {set_parts} WHERE {where};" - - return f"-- UPDATE `{table_name}` SET {set_parts} WHERE /* 手动补充条件 */;" - - # ===== SQL 解析工具 ===== - - def _extract_table_from_insert(self, sql: str) -> Optional[str]: - """从 INSERT 语句中提取表名。 - - 支持格式: - - INSERT INTO table_name ... - - INSERT INTO `table_name` ... - - INSERT IGNORE INTO table_name ... - - Args: - sql: INSERT 语句 - - Returns: - 表名,解析失败返回 None - """ - # 使用正则表达式提取表名 - patterns = [ - r"INSERT\s+IGNORE\s+INTO\s+`?([^`\s(]+)`?", - r"INSERT\s+INTO\s+`?([^`\s(]+)`?", - r"INSERT\s+`?([^`\s(]+)`?", - ] - for pattern in patterns: - match = re.search(pattern, sql, re.IGNORECASE) - if match: - return match.group(1).strip() - return None - - def _extract_table_from_update(self, sql: str) -> Optional[str]: - """从 UPDATE 语句中提取表名。 - - Args: - sql: UPDATE 语句 - - Returns: - 表名,解析失败返回 None - """ - patterns = [ - r"UPDATE\s+`?([^`\s,]+)`?", - ] - for pattern in patterns: - match = re.search(pattern, sql, re.IGNORECASE) - if match: - return match.group(1).strip() - return None - - def _extract_table_from_delete(self, sql: str) -> Optional[str]: - """从 DELETE 语句中提取表名。 - - 支持格式: - - DELETE FROM table_name ... - - DELETE table_name FROM ... - - Args: - sql: DELETE 语句 - - Returns: - 表名,解析失败返回 None - """ - patterns = [ - r"DELETE\s+FROM\s+`?([^`\s(]+)`?", - r"DELETE\s+`?([^`\s(]+)`?\s+FROM", - ] - for pattern in patterns: - match = re.search(pattern, sql, re.IGNORECASE) - if match: - return match.group(1).strip() - return None - - def _extract_set_columns(self, sql: str) -> List[str]: - """从 UPDATE 语句的 SET 子句中提取列名。 - - Args: - sql: UPDATE 语句 - - Returns: - SET 子句中的列名列表 - """ - columns: List[str] = [] - # 匹配 SET col = val, col2 = val2 格式 - match = re.search(r"SET\s+(.+?)(?:\s+WHERE\s+|$)", sql, re.IGNORECASE | re.DOTALL) - if match: - set_clause = match.group(1) - # 提取每个赋值左侧的列名 - assignments = re.split(r",\s*(?=`?\w+`?\s*=)", set_clause) - for assignment in assignments: - col_match = re.match(r"`?(\w+)`?\s*[=<>!]+", assignment.strip()) - if col_match: - columns.append(col_match.group(1)) - return columns - - def _extract_where_clause(self, sql: str) -> Optional[str]: - """从 SQL 语句中提取 WHERE 子句。 - - Args: - sql: SQL 语句 - - Returns: - WHERE 条件字符串,没有 WHERE 返回 None - """ - # 使用 sqlparse 提取 WHERE 子句 - parsed = sqlparse.parse(sql) - if not parsed: - return None - - statement = parsed[0] - where_tokens = [] - in_where = False - - for token in statement.tokens: - if isinstance(token, Where): - return str(token).replace("WHERE", "", 1).strip() - if token.ttype is Keyword and token.normalized == "WHERE": - in_where = True - continue - if in_where: - if token.ttype is Keyword and token.normalized in ("ORDER", "GROUP", "HAVING", "LIMIT"): - break - where_tokens.append(str(token)) - - if where_tokens: - return "".join(where_tokens).strip() - return None - - def _parse_insert_values(self, sql: str) -> Tuple[List[str], List[str]]: - """解析 INSERT 语句的列名和值。 - - 支持格式: - - INSERT INTO table (col1, col2) VALUES (val1, val2) - - INSERT INTO table SET col1=val1, col2=val2 - - Args: - sql: INSERT 语句 - - Returns: - (列名列表, 值列表) - """ - columns: List[str] = [] - values: List[str] = [] - - # 尝试匹配 INSERT ... (col1, col2) VALUES (val1, val2) - col_match = re.search(r"\(([^)]+)\)\s+VALUES\s*\(([^)]+)\)", sql, re.IGNORECASE | re.DOTALL) - if col_match: - col_str = col_match.group(1) - val_str = col_match.group(2) - columns = [c.strip().strip("`'") for c in col_str.split(",")] - # 简单值解析(不考虑复杂表达式) - values = self._split_values(val_str) - return columns, values - - # 尝试匹配 INSERT ... SET col1=val1, col2=val2 - set_match = re.search(r"SET\s+(.+)$", sql, re.IGNORECASE | re.DOTALL) - if set_match: - set_clause = set_match.group(1) - assignments = set_clause.split(",") - for assignment in assignments: - parts = assignment.split("=", 1) - if len(parts) == 2: - columns.append(parts[0].strip().strip("`")) - values.append(parts[1].strip().strip("'\"")) - - return columns, values - - @staticmethod - def _split_values(val_str: str) -> List[str]: - """分割 INSERT 的 VALUES 子句中的值。 - - 处理简单值,跳过括号内的逗号。 - - Args: - val_str: VALUES 子句中的值字符串 - - Returns: - 值列表 - """ - values: List[str] = [] - current = [] - in_string = False - string_char = None - paren_depth = 0 - - for ch in val_str: - if ch in ("'", '"') and not in_string: - in_string = True - string_char = ch - elif ch == string_char and in_string: - in_string = False - string_char = None - elif ch == "(" and not in_string: - paren_depth += 1 - elif ch == ")" and not in_string: - paren_depth -= 1 - - if ch == "," and not in_string and paren_depth == 0: - values.append("".join(current).strip()) - current = [] - else: - current.append(ch) - - if current: - values.append("".join(current).strip()) - - return values - - @staticmethod - def _quote_value(value: Any) -> str: - """将值转换为 SQL 安全的字符串表示。 - - Args: - value: 任意值 - - Returns: - SQL 安全的值表示 - """ - if value is None: - return "NULL" - if isinstance(value, (int, float)): - return str(value) - if isinstance(value, bool): - return "1" if value else "0" - - # 字符串值: 转义单引号 - val = str(value).replace("'", "\\'") - return f"'{val}'" diff --git a/backend/app/services/schema_drift_detector.py b/backend/app/services/schema_drift_detector.py deleted file mode 100644 index 497feaa..0000000 --- a/backend/app/services/schema_drift_detector.py +++ /dev/null @@ -1,314 +0,0 @@ -"""Schema 漂移检测 — 检测数据库 Schema 的意外变更。 - -工作原理: -1. 定期捕获数据库 Schema 快照(表结构、索引、约束) -2. 将快照与基线比较 -3. 发现差异时生成漂移报告 -4. 发送通知告警 - -支持的检测类型: -- 新增表/字段 -- 删除表/字段 -- 字段类型变更 -- 索引变更 -""" -import hashlib -import json -from dataclasses import dataclass, field -from datetime import datetime -from typing import List, Optional, Dict, Any - -from app.models.instance import Instance -from app.services.db_connector import DBConnector - - -@dataclass -class ColumnSnapshot: - """列定义快照。""" - table_name: str - column_name: str - column_type: str - is_nullable: bool - column_default: Optional[str] - extra: str - character_set: Optional[str] - collation: Optional[str] - checksum: str - - def to_dict(self) -> dict: - return { - "table_name": self.table_name, - "column_name": self.column_name, - "column_type": self.column_type, - "is_nullable": self.is_nullable, - "column_default": self.column_default, - "extra": self.extra, - "checksum": self.checksum, - } - - -@dataclass -class DriftItem: - """漂移项。""" - drift_type: str # added / removed / modified - table_name: str - column_name: str - before: Optional[str] - after: Optional[str] - severity: str # low / medium / high - - def to_dict(self) -> dict: - return { - "drift_type": self.drift_type, - "table_name": self.table_name, - "column_name": self.column_name, - "before": self.before, - "after": self.after, - "severity": self.severity, - } - - -@dataclass -class DriftReport: - """漂移检测报告。""" - instance_id: int - database: str - checked_at: str - total_columns: int - drift_count: int - drifts: List[DriftItem] - - def to_dict(self) -> dict: - return { - "instance_id": self.instance_id, - "database": self.database, - "checked_at": self.checked_at, - "total_columns": self.total_columns, - "drift_count": self.drift_count, - "drifts": [d.to_dict() for d in self.drifts], - } - - -class SchemaDriftDetector: - """Schema 漂移检测器。""" - - # 内存中的基线存储: {(instance_id, database): [ColumnSnapshot, ...]} - _baselines: Dict[tuple, List[ColumnSnapshot]] = {} - - async def capture_snapshot(self, instance: Instance, - database: str) -> List[ColumnSnapshot]: - """捕获数据库 Schema 快照。""" - if instance.db_type == "mysql": - return await self._capture_mysql_schema(instance, database) - elif instance.db_type in ("postgres", "postgresql"): - return await self._capture_postgres_schema(instance, database) - else: - raise ValueError(f"不支持的 Schema 捕获类型: {instance.db_type}") - - async def save_baseline(self, instance_id: int, - database: str, - snapshot: List[ColumnSnapshot]) -> None: - """保存 Schema 基线到内存。""" - self._baselines[(instance_id, database)] = snapshot - - async def detect_drift(self, instance: Instance, - database: str) -> DriftReport: - """检测 Schema 漂移,返回漂移报告。""" - current = await self.capture_snapshot(instance, database) - baseline = await self._get_baseline(instance.id, database) - - checked_at = datetime.now().isoformat() - - if not baseline: - # 首次检测,保存为基线 - await self.save_baseline(instance.id, database, current) - return DriftReport( - instance_id=instance.id, - database=database, - checked_at=checked_at, - total_columns=len(current), - drift_count=0, - drifts=[], - ) - - drifts = self._compare_schemas(current, baseline) - - # 更新基线为当前状态 - await self.save_baseline(instance.id, database, current) - - return DriftReport( - instance_id=instance.id, - database=database, - checked_at=checked_at, - total_columns=len(current), - drift_count=len(drifts), - drifts=drifts, - ) - - async def _capture_mysql_schema(self, instance: Instance, - database: str) -> List[ColumnSnapshot]: - """从 MySQL INFORMATION_SCHEMA 捕获 Schema。""" - password = instance.password # should be decrypted before calling - sql = """ - SELECT - TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, - COLUMN_DEFAULT, EXTRA, CHARACTER_SET_NAME, COLLATION_NAME - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = %s - AND TABLE_NAME NOT LIKE '\\_%%' -- exclude internal tables - ORDER BY TABLE_NAME, ORDINAL_POSITION - """ - - from app.core.crypto import decrypt_password - instance.password = decrypt_password(instance.password) - result = await DBConnector._query_mysql(instance, "information_schema", sql) - - snapshots = [] - for row in result[0]: - # 计算列定义的校验和 - content = ( - f"{row.get('COLUMN_TYPE', '')}|" - f"{row.get('IS_NULLABLE', '')}|" - f"{row.get('COLUMN_DEFAULT', '') or ''}|" - f"{row.get('EXTRA', '')}" - ) - checksum = hashlib.md5(content.encode()).hexdigest() - - snapshots.append(ColumnSnapshot( - table_name=row.get("TABLE_NAME", ""), - column_name=row.get("COLUMN_NAME", ""), - column_type=row.get("COLUMN_TYPE", ""), - is_nullable=row.get("IS_NULLABLE", "YES") == "YES", - column_default=str(row.get("COLUMN_DEFAULT", "")) if row.get("COLUMN_DEFAULT") is not None else None, - extra=row.get("EXTRA", ""), - character_set=row.get("CHARACTER_SET_NAME"), - collation=row.get("COLLATION_NAME"), - checksum=checksum, - )) - - return snapshots - - async def _capture_postgres_schema(self, instance: Instance, - database: str) -> List[ColumnSnapshot]: - """从 PostgreSQL information_schema 捕获 Schema。""" - sql = """ - SELECT - c.table_name, - c.column_name, - CASE - WHEN c.data_type = 'character varying' AND c.character_maximum_length IS NOT NULL - THEN 'varchar(' || c.character_maximum_length || ')' - WHEN c.data_type = 'character' AND c.character_maximum_length IS NOT NULL - THEN 'char(' || c.character_maximum_length || ')' - WHEN c.data_type = 'numeric' AND c.numeric_precision IS NOT NULL - THEN 'numeric(' || c.numeric_precision || ',' || COALESCE(c.numeric_scale, 0) || ')' - ELSE c.data_type - END as column_type, - c.is_nullable, - c.column_default, - '' as extra, - NULL as character_set_name, - NULL as collation_name - FROM information_schema.columns c - WHERE c.table_schema = 'public' - AND c.table_catalog = %s - ORDER BY c.table_name, c.ordinal_position - """ - - from app.core.crypto import decrypt_password - instance.password = decrypt_password(instance.password) - result = await DBConnector._query_postgres(instance, database, sql) - - snapshots = [] - for row in result[0]: - content = ( - f"{row.get('column_type', '')}|" - f"{row.get('is_nullable', '')}|" - f"{row.get('column_default', '') or ''}" - ) - checksum = hashlib.md5(content.encode()).hexdigest() - - snapshots.append(ColumnSnapshot( - table_name=row.get("table_name", ""), - column_name=row.get("column_name", ""), - column_type=row.get("column_type", ""), - is_nullable=row.get("is_nullable", "YES") == "YES", - column_default=str(row.get("column_default", "")) if row.get("column_default") is not None else None, - extra=row.get("extra", ""), - character_set=None, - collation=None, - checksum=checksum, - )) - - return snapshots - - async def _get_baseline(self, instance_id: int, - database: str) -> Optional[List[ColumnSnapshot]]: - """获取保存的基线。""" - return self._baselines.get((instance_id, database)) - - def _compare_schemas(self, current: List[ColumnSnapshot], - baseline: List[ColumnSnapshot]) -> List[DriftItem]: - """比较当前 Schema 与基线,返回漂移项。""" - drifts = [] - current_dict = {(s.table_name, s.column_name): s for s in current} - baseline_dict = {(s.table_name, s.column_name): s for s in baseline} - - # 检查新增列 - for key, snapshot in current_dict.items(): - if key not in baseline_dict: - drifts.append(DriftItem( - drift_type="added", - table_name=key[0], - column_name=key[1], - before=None, - after=f"{snapshot.column_type} NULL={snapshot.is_nullable}", - severity="medium", - )) - - # 检查删除列 - for key, snapshot in baseline_dict.items(): - if key not in current_dict: - drifts.append(DriftItem( - drift_type="removed", - table_name=key[0], - column_name=key[1], - before=f"{snapshot.column_type} NULL={snapshot.is_nullable}", - after=None, - severity="high", # 删除列是高风险操作 - )) - - # 检查修改列 - for key, current_snap in current_dict.items(): - if key in baseline_dict: - baseline_snap = baseline_dict[key] - if current_snap.checksum != baseline_snap.checksum: - severity = "medium" - # 类型变更是高风险 - if current_snap.column_type != baseline_snap.column_type: - severity = "high" - - drifts.append(DriftItem( - drift_type="modified", - table_name=key[0], - column_name=key[1], - before=f"{baseline_snap.column_type} (default: {baseline_snap.column_default or 'None'})", - after=f"{current_snap.column_type} (default: {current_snap.column_default or 'None'})", - severity=severity, - )) - - # 按严重性排序 - severity_order = {"high": 0, "medium": 1, "low": 2} - drifts.sort(key=lambda x: severity_order.get(x.severity, 3)) - - return drifts - - def clear_baseline(self, instance_id: int, database: str) -> None: - """清除指定实例的基线。""" - key = (instance_id, database) - if key in self._baselines: - del self._baselines[key] - - def clear_all_baselines(self) -> None: - """清除所有基线。""" - self._baselines.clear() diff --git a/backend/app/services/settings_seeds.py b/backend/app/services/settings_seeds.py deleted file mode 100644 index ed35dbc..0000000 --- a/backend/app/services/settings_seeds.py +++ /dev/null @@ -1,109 +0,0 @@ -"""系统设置种子数据 — 首次启动自动初始化。""" - -SEED_SETTINGS = [ - # ── 查询控制 ── - { - "key": "query_max_limit", - "value": "10000", - "value_type": "integer", - "group": "query", - "label": "查询 LIMIT 上限", - "description": "SELECT 查询返回的最大行数。范围: 100~100000", - "default_value": "10000", - "sort_order": 1, - }, - { - "key": "query_timeout", - "value": "30", - "value_type": "integer", - "group": "query", - "label": "查询超时(秒)", - "description": "单次查询最大执行时间,超时自动终止。范围: 5~300", - "default_value": "30", - "sort_order": 2, - }, - { - "key": "query_auto_limit", - "value": "true", - "value_type": "boolean", - "group": "query", - "label": "自动添加 LIMIT", - "description": "查询页 SELECT 无 LIMIT 时自动追加,防止返回海量数据(仅查询页,不影响工单)", - "default_value": "true", - "sort_order": 3, - }, - - # ── 安全控制 ── - { - "key": "security_max_login_attempts", - "value": "5", - "value_type": "integer", - "group": "security", - "label": "最大登录失败次数", - "description": "连续登录失败达到次数后锁定账号(0 表示不限制)", - "default_value": "5", - "sort_order": 10, - }, - { - "key": "security_password_min_length", - "value": "6", - "value_type": "integer", - "group": "security", - "label": "密码最小长度", - "description": "用户密码的最小字符数", - "default_value": "6", - "sort_order": 11, - }, - - # ── 日志清理 ── - { - "key": "cleanup_ticket_days", - "value": "90", - "value_type": "integer", - "group": "cleanup", - "label": "工单保留天数", - "description": "已完成/已拒绝的工单超过保留天数将被自动清理", - "default_value": "90", - "sort_order": 30, - }, - { - "key": "cleanup_audit_log_days", - "value": "180", - "value_type": "integer", - "group": "cleanup", - "label": "审计日志保留天数", - "description": "审计日志超过保留天数将被自动清理", - "default_value": "180", - "sort_order": 31, - }, - { - "key": "cleanup_query_history_days", - "value": "30", - "value_type": "integer", - "group": "cleanup", - "label": "查询历史保留天数", - "description": "查询历史超过保留天数将被自动清理", - "default_value": "30", - "sort_order": 32, - }, - - # ── 执行器 ── - { - "key": "executor_sql_timeout", - "value": "60", - "value_type": "integer", - "group": "executor", - "label": "SQL 执行超时(秒)", - "description": "MySQL/PostgreSQL 工单执行单条 SQL 最大时间", - "default_value": "60", - "sort_order": 40, - }, -] - -# 分组元信息 -SETTING_GROUPS = { - "query": {"label": "查询控制", "icon": "🔍", "description": "控制查询行为、LIMIT、超时和脱敏策略"}, - "security": {"label": "安全设置", "icon": "🔒", "description": "登录安全、密码策略"}, - "cleanup": {"label": "日志清理", "icon": "🧹", "description": "工单、审计日志、查询历史的自动清理策略"}, - "executor": {"label": "执行器", "icon": "⚙️", "description": "SQL/Redis 执行超时、批量限制、连接参数"}, -} diff --git a/backend/app/services/sql_analyzer.py b/backend/app/services/sql_analyzer.py deleted file mode 100644 index 832cffb..0000000 --- a/backend/app/services/sql_analyzer.py +++ /dev/null @@ -1,255 +0,0 @@ -"""SQL AST Analyzer — parse SQL using sqlparse for safe query validation. - -Used by query endpoint to determine if a SQL statement is safe for read-only execution. -Separate from audit_engine.py (which is for ticket review scoring). -""" -import sqlparse -from sqlparse.sql import IdentifierList, Identifier, Where, Comparison -from sqlparse.tokens import Keyword, DML, DDL, Punctuation -from dataclasses import dataclass -from typing import List, Optional -from enum import Enum - - -class SQLType(str, Enum): - SELECT = "SELECT" - INSERT = "INSERT" - UPDATE = "UPDATE" - DELETE = "DELETE" - CREATE = "CREATE" - ALTER = "ALTER" - DROP = "DROP" - TRUNCATE = "TRUNCATE" - UNKNOWN = "UNKNOWN" - - -class RiskLevel(str, Enum): - SAFE = "safe" # SELECT only - LOW = "low" # SELECT with functions - MEDIUM = "medium" # DML with WHERE - HIGH = "high" # DML without WHERE - DANGEROUS = "dangerous" # DDL operations - - -@dataclass -class SQLAnalysis: - sql_type: SQLType - risk_level: RiskLevel - is_readonly: bool - has_where: bool - tables: List[str] - warnings: List[str] - errors: List[str] - - def to_dict(self) -> dict: - return { - "sql_type": self.sql_type.value, - "risk_level": self.risk_level.value, - "is_readonly": self.is_readonly, - "has_where": self.has_where, - "tables": self.tables, - "warnings": self.warnings, - "errors": self.errors, - } - - -class SQLAnalyzer: - """Parse and analyze SQL statements using sqlparse AST.""" - - # Statements that are always read-only - READONLY_TYPES = {SQLType.SELECT} - - # Statements that modify data - DML_TYPES = {SQLType.INSERT, SQLType.UPDATE, SQLType.DELETE} - - # Statements that change schema - DDL_TYPES = {SQLType.CREATE, SQLType.ALTER, SQLType.DROP, SQLType.TRUNCATE} - - def analyze(self, sql: str) -> SQLAnalysis: - """Analyze a SQL statement and return detailed analysis.""" - sql = sql.strip() - - # Detect SQL type from the first meaningful token - sql_type = self._detect_sql_type(sql) - - # Parse with sqlparse - parsed = sqlparse.parse(sql) - if not parsed: - return SQLAnalysis( - sql_type=SQLType.UNKNOWN, risk_level=RiskLevel.SAFE, - is_readonly=True, has_where=False, tables=[], - warnings=[], errors=["无法解析 SQL 语句"], - ) - - statement = parsed[0] - tokens = list(statement.flatten()) - - # Extract tables - tables = self._extract_tables(statement) - - # Check for WHERE clause - has_where = self._has_where(statement) - - # Check for multiple statements (SQL injection vector) - multi_errors = self._check_multi_statement(sql) - - # Determine risk level and generate warnings/errors - warnings = [] - errors = list(multi_errors) - - if sql_type in self.DDL_TYPES: - risk_level = RiskLevel.DANGEROUS - is_readonly = False - errors.append(f"DDL 操作 ({sql_type.value}) 在查询模式下被禁止") - - elif sql_type in self.DML_TYPES: - is_readonly = False - if has_where: - risk_level = RiskLevel.MEDIUM - warnings.append(f"{sql_type.value} 操作包含 WHERE 条件,但查询模式为只读") - else: - risk_level = RiskLevel.HIGH - errors.append(f"{sql_type.value} 缺少 WHERE 条件,影响全表") - errors.append(f"查询模式禁止执行 {sql_type.value} 操作") - - elif sql_type == SQLType.SELECT: - is_readonly = True - risk_level = RiskLevel.SAFE - # Check for INTO OUTFILE (writes to filesystem) - if self._has_into_outfile(statement): - risk_level = RiskLevel.DANGEROUS - is_readonly = False - errors.append("SELECT INTO OUTFILE 被禁止(文件写入风险)") - - # Check for subqueries that modify data - sub_type = self._check_subquery_dml(sql) - if sub_type: - is_readonly = False - risk_level = RiskLevel.DANGEROUS - errors.append(f"子查询包含 {sub_type} 操作,被禁止") - else: - is_readonly = True - risk_level = RiskLevel.LOW - - return SQLAnalysis( - sql_type=sql_type, - risk_level=risk_level, - is_readonly=is_readonly, - has_where=has_where, - tables=tables, - warnings=warnings, - errors=errors, - ) - - def validate_readonly(self, sql: str) -> tuple[bool, str]: - """Validate SQL is read-only safe. Returns (is_safe, error_message).""" - analysis = self.analyze(sql) - if analysis.errors: - return False, "; ".join(analysis.errors) - if not analysis.is_readonly: - return False, f"查询模式仅允许 SELECT 语句,当前为 {analysis.sql_type.value}" - return True, "" - - def _detect_sql_type(self, sql: str) -> SQLType: - """Detect the SQL statement type from the first keyword.""" - upper = sql.strip().upper() - type_map = { - "SELECT": SQLType.SELECT, - "INSERT": SQLType.INSERT, - "UPDATE": SQLType.UPDATE, - "DELETE": SQLType.DELETE, - "CREATE": SQLType.CREATE, - "ALTER": SQLType.ALTER, - "DROP": SQLType.DROP, - "TRUNCATE": SQLType.TRUNCATE, - "WITH": SQLType.SELECT, # CTE is still SELECT - "EXPLAIN": SQLType.SELECT, - } - for keyword, sql_type in type_map.items(): - if upper.startswith(keyword): - return sql_type - return SQLType.UNKNOWN - - def _extract_tables(self, statement) -> List[str]: - """Extract table names from the statement.""" - tables = [] - seen = set() - - # Get tokens after FROM or JOIN or INTO or UPDATE - tokens = list(statement.tokens) - capture = False - for token in tokens: - if token.ttype in (Keyword,) and token.normalized in ("FROM", "INTO", "UPDATE", "JOIN", "TABLE"): - capture = True - continue - if capture: - if isinstance(token, IdentifierList): - for ident in token.get_identifiers(): - name = self._get_identifier_name(ident) - if name and name not in seen: - seen.add(name) - tables.append(name) - elif isinstance(token, Identifier): - name = self._get_identifier_name(token) - if name and name not in seen: - seen.add(name) - tables.append(name) - capture = False - - return tables - - def _get_identifier_name(self, ident) -> Optional[str]: - """Get the table name from an identifier.""" - if isinstance(ident, Identifier): - # Use the real name (not alias) - return ident.get_real_name() - return None - - def _has_where(self, statement) -> bool: - """Check if the statement has a WHERE clause.""" - for token in statement.tokens: - if isinstance(token, Where): - return True - # Also check flattened tokens - for token in statement.flatten(): - if token.ttype is Keyword and token.normalized == "WHERE": - return True - return False - - def _has_into_outfile(self, statement) -> bool: - """Check for INTO OUTFILE (filesystem write).""" - upper = str(statement).upper() - return "INTO OUTFILE" in upper or "INTO DUMPFILE" in upper - - def _check_multi_statement(self, sql: str) -> List[str]: - """Check for multiple statements (semicolons inside the SQL).""" - errors = [] - stripped = sql.strip().rstrip(";") - # Count semicolons not inside strings - in_string = False - string_char = None - semi_count = 0 - for ch in stripped: - if ch in ("'", '"') and not in_string: - in_string = True - string_char = ch - elif ch == string_char and in_string: - in_string = False - string_char = None - elif ch == ";" and not in_string: - semi_count += 1 - - if semi_count > 0: - errors.append("查询不允许包含多条 SQL 语句(分号分隔)") - return errors - - def _check_subquery_dml(self, sql: str) -> Optional[str]: - """Check if a SELECT contains DML subqueries.""" - upper = sql.upper() - # Simple heuristic: look for INSERT/UPDATE/DELETE after a parenthesis - for kw in ("INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP"): - # Check if keyword appears inside parentheses - pattern = f"({kw} " - if pattern in upper: - return kw - return None diff --git a/backend/app/services/sql_assistant.py b/backend/app/services/sql_assistant.py deleted file mode 100644 index 2de2b6f..0000000 --- a/backend/app/services/sql_assistant.py +++ /dev/null @@ -1,342 +0,0 @@ -"""SQL助手 — 人性化辅助功能。 - -提供便捷的数据操作辅助: -- SQL格式化: 统一SQL代码风格 -- SQL语法高亮标记: 标记关键字、表名、字段名 -- 表结构速查: 快速获取表列信息 -- 智能补全建议: 基于表结构生成字段列表 -- SQL Explain解析: 将执行计划转为人类可读格式 -- 查询风险提示: 基于规则标记潜在风险 - -人性化设计原则: -- 减少重复操作 -- 降低SQL编写门槛 -- 提供即时反馈 -- 统一操作体验 -""" -import re -from typing import List, Dict, Optional, Any -from dataclasses import dataclass - - -# SQL关键字(用于语法高亮) -SQL_KEYWORDS = { - "SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", - "DROP", "ALTER", "TABLE", "INDEX", "JOIN", "LEFT", "RIGHT", "INNER", - "OUTER", "ON", "AND", "OR", "NOT", "NULL", "IS", "IN", "EXISTS", - "BETWEEN", "LIKE", "GROUP", "BY", "ORDER", "HAVING", "LIMIT", "OFFSET", - "UNION", "ALL", "DISTINCT", "AS", "ASC", "DESC", "COUNT", "SUM", - "AVG", "MAX", "MIN", "IF", "CASE", "WHEN", "THEN", "ELSE", "END", - "SHOW", "DESCRIBE", "EXPLAIN", "USE", "SET", "COMMIT", "ROLLBACK", -} - -# 高风险操作标记 -RISK_MARKERS = { - "DELETE": {"level": "high", "message": "删除操作不可逆,请确认WHERE条件正确"}, - "DROP": {"level": "critical", "message": "DROP操作会永久删除对象,请谨慎操作"}, - "TRUNCATE": {"level": "critical", "message": "TRUNCATE会清空全表且不可回滚"}, - "UPDATE": {"level": "medium", "message": "UPDATE可能影响多行数据,建议先SELECT验证"}, - "ALTER": {"level": "medium", "message": "ALTER TABLE可能锁表,建议在低峰期执行"}, -} - - -@dataclass -class SQLFormatResult: - """SQL格式化结果。""" - formatted: str - original: str - is_valid: bool - error: Optional[str] = None - - -@dataclass -class SQLHighlightResult: - """SQL语法高亮结果。""" - highlighted: str # HTML格式的语法高亮 - keywords: List[str] # 提取的关键字 - tables: List[str] # 提取的表名 - columns: List[str] # 提取的列名 - risks: List[Dict[str, str]] # 风险提示 - - -@dataclass -class ExplainResult: - """Explain解析结果。""" - is_simple: bool - has_index: bool - has_full_scan: bool - estimated_rows: int - warnings: List[str] - human_readable: str # 人类可读的执行计划描述 - - -class SQLAssistant: - """SQL助手 — 人性化辅助功能。""" - - # ── SQL格式化 ── - - @staticmethod - def format_sql(sql: str) -> SQLFormatResult: - """格式化SQL语句。 - - - 关键字大写 - - 合理换行和缩进 - - 统一空格 - """ - if not sql or not sql.strip(): - return SQLFormatResult(formatted="", original=sql, is_valid=False, error="SQL为空") - - try: - import sqlparse - formatted = sqlparse.format( - sql, - keyword_case="upper", - identifier_case="lower", - strip_comments=False, - reindent=True, - indent_width=4, - wrap_after=80, - ) - return SQLFormatResult(formatted=formatted, original=sql, is_valid=True) - except ImportError: - # 如果没有sqlparse,使用简单格式化 - return SQLAssistant._simple_format(sql) - except Exception as e: - return SQLFormatResult(formatted=sql, original=sql, is_valid=False, error=str(e)) - - @staticmethod - def _simple_format(sql: str) -> SQLFormatResult: - """简单SQL格式化(不依赖sqlparse)。""" - # 去除多余空格 - formatted = " ".join(sql.split()) - - # 关键字大写 - for kw in SQL_KEYWORDS: - pattern = re.compile(r"\b" + kw + r"\b", re.IGNORECASE) - formatted = pattern.sub(kw, formatted) - - # 在主要关键字前换行 - line_breaks = [" SELECT ", " FROM ", " WHERE ", " GROUP BY ", - " ORDER BY ", " HAVING ", " LIMIT ", " JOIN ", - " LEFT JOIN ", " RIGHT JOIN ", " INNER JOIN ", " UNION "] - for br in line_breaks: - formatted = formatted.replace(br, "\n" + br.strip().ljust(8)) - - return SQLFormatResult(formatted=formatted, original=sql, is_valid=True) - - # ── 语法高亮与风险分析 ── - - @staticmethod - def highlight_sql(sql: str) -> SQLHighlightResult: - """SQL语法高亮和风险分析。 - - 返回HTML格式的语法高亮文本和风险提示。 - """ - upper = sql.upper().strip() - - # 提取关键字 - words = re.findall(r"\b\w+\b", upper) - keywords = [w for w in words if w in SQL_KEYWORDS] - - # 提取表名(简单规则) - tables = [] - table_patterns = [ - r"FROM\s+(\w+)", - r"JOIN\s+(\w+)", - r"INTO\s+(\w+)", - r"UPDATE\s+(\w+)", - r"TABLE\s+(\w+)", - ] - for pattern in table_patterns: - matches = re.findall(pattern, upper, re.IGNORECASE) - tables.extend(matches) - tables = list(set(t.lower() for t in tables)) - - # 风险提示 - risks = [] - for op, info in RISK_MARKERS.items(): - if re.search(r"\b" + op + r"\b", upper): - # 检查是否有WHERE - if op in ("UPDATE", "DELETE") and "WHERE" not in upper: - risks.append({ - "level": "critical", - "operation": op, - "message": f"{op} 缺少 WHERE 条件,将操作全表数据!", - }) - else: - risks.append({"level": info["level"], "operation": op, "message": info["message"]}) - - # 构建HTML高亮(简化版) - highlighted = sql - for kw in SQL_KEYWORDS: - highlighted = re.sub( - r"\b" + kw + r"\b", - f'{kw}', - highlighted, - flags=re.IGNORECASE, - ) - - return SQLHighlightResult( - highlighted=highlighted, - keywords=list(set(keywords)), - tables=tables, - columns=[], # 简单实现不提取列 - risks=risks, - ) - - # ── Explain解析 ── - - @staticmethod - def parse_explain(explain_rows: List[Dict[str, Any]]) -> ExplainResult: - """解析MySQL EXPLAIN结果为人类可读格式。 - - Args: - explain_rows: EXPLAIN输出结果行列表 - """ - if not explain_rows: - return ExplainResult( - is_simple=True, has_index=False, has_full_scan=False, - estimated_rows=0, warnings=["EXPLAIN结果为空"], - human_readable="无法解析执行计划", - ) - - warnings = [] - total_rows = 0 - has_full_scan = False - has_index = False - parts = [] - - for i, row in enumerate(explain_rows): - table = row.get("table") or row.get("TABLE", "unknown") - select_type = row.get("select_type") or row.get("SELECT_TYPE", "SIMPLE") - scan_type = row.get("type") or row.get("TYPE", "ALL") - possible_keys = row.get("possible_keys") or row.get("POSSIBLE_KEYS") - key_used = row.get("key") or row.get("KEY") - rows_est = row.get("rows") or row.get("ROWS", 0) or row.get("EST_ROWS", 0) - extra = row.get("Extra") or row.get("EXTRA", "") - - total_rows += int(rows_est) if rows_est else 0 - - if scan_type in ("ALL", "index") and select_type == "SIMPLE": - has_full_scan = True - warnings.append(f"表 '{table}' 使用了全表扫描({scan_type}),建议添加索引") - - if key_used: - has_index = True - parts.append(f"表 '{table}': 使用了索引 {key_used},预估扫描 {rows_est} 行") - else: - parts.append(f"表 '{table}': 未使用索引(类型: {scan_type}),预估扫描 {rows_est} 行") - - if "Using filesort" in str(extra): - warnings.append(f"表 '{table}' 使用了文件排序,建议优化ORDER BY") - if "Using temporary" in str(extra): - warnings.append(f"表 '{table}' 使用了临时表,建议优化GROUP BY") - - human_readable = "\n".join(parts) - - if total_rows > 100000: - warnings.append(f"预估扫描总行数 {total_rows} 较大,查询可能较慢") - - return ExplainResult( - is_simple=len(explain_rows) == 1, - has_index=has_index, - has_full_scan=has_full_scan, - estimated_rows=total_rows, - warnings=warnings, - human_readable=human_readable, - ) - - # ── 查询建议 ── - - @staticmethod - def get_query_suggestions(sql: str, db_type: str = "mysql") -> List[Dict[str, str]]: - """基于SQL内容提供人性化建议。 - - 帮助用户写出更好的SQL。 - """ - suggestions = [] - upper = sql.upper().strip() - - # SELECT * 建议 - if re.search(r"SELECT\s+\*", upper): - suggestions.append({ - "type": "optimization", - "message": "使用 SELECT * 会返回所有列,建议只选择需要的字段以减少网络传输", - }) - - # 缺少LIMIT - if "SELECT" in upper and "LIMIT" not in upper and db_type in ("mysql", "postgres"): - suggestions.append({ - "type": "safety", - "message": "建议添加 LIMIT 子句限制返回行数,避免大数据量查询", - }) - - # ORDER BY RAND() - if "ORDER BY RAND()" in upper or "ORDER BY RANDOM()" in upper: - suggestions.append({ - "type": "optimization", - "message": "ORDER BY RAND() 在大表上性能极差,建议使用其他随机化方案", - }) - - # LIKE %xxx% - if re.search(r"LIKE\s+'%.", upper): - suggestions.append({ - "type": "optimization", - "message": "LIKE '%xxx%' 无法使用索引,如数据量大请考虑全文索引或其他方案", - }) - - # 多表JOIN - join_count = len(re.findall(r"\bJOIN\b", upper)) - if join_count > 3: - suggestions.append({ - "type": "optimization", - "message": f"当前有 {join_count} 个 JOIN,建议考虑子查询或视图简化", - }) - - # 没有WHERE的UPDATE/DELETE(前面已标记为critical) - # 这里给出更详细的建议 - if re.search(r"\bUPDATE\b", upper) and "WHERE" not in upper: - suggestions.append({ - "type": "danger", - "message": "UPDATE 缺少 WHERE 将修改全表!建议先用 SELECT 验证影响范围", - }) - - if re.search(r"\bDELETE\b", upper) and "WHERE" not in upper: - suggestions.append({ - "type": "danger", - "message": "DELETE 缺少 WHERE 将删除全表!建议先用 SELECT 验证影响范围", - }) - - # 缺少注释的复杂查询 - if len(sql) > 500 and "--" not in sql and "/*" not in sql: - suggestions.append({ - "type": "style", - "message": "复杂查询建议添加注释,方便后续维护和理解", - }) - - return suggestions - - # ── 统一辅助入口 ── - - @staticmethod - def analyze(sql: str, db_type: str = "mysql") -> Dict[str, Any]: - """全面分析SQL,返回格式化+高亮+风险+建议的完整结果。 - - 人性化入口,一次调用获取所有辅助信息。 - """ - format_result = SQLAssistant.format_sql(sql) - highlight_result = SQLAssistant.highlight_sql(sql) - suggestions = SQLAssistant.get_query_suggestions(sql, db_type) - - return { - "formatted": format_result.formatted if format_result.is_valid else sql, - "is_valid": format_result.is_valid, - "format_error": format_result.error, - "highlighted": highlight_result.highlighted, - "keywords": highlight_result.keywords, - "tables": highlight_result.tables, - "risks": highlight_result.risks, - "suggestions": suggestions, - "risk_count": len(highlight_result.risks), - "suggestion_count": len(suggestions), - } diff --git a/backend/app/services/ticket_executor.py b/backend/app/services/ticket_executor.py deleted file mode 100644 index 89fbb48..0000000 --- a/backend/app/services/ticket_executor.py +++ /dev/null @@ -1,1705 +0,0 @@ -"""多数据库工单执行引擎 — 支持 MySQL/PostgreSQL/Redis/MongoDB 的变更执行。 - -执行流程: -1. 接收审批通过的工单 -2. 按 db_type 分发到对应执行器 -3. 并发锁检查(防止同一工单并发执行) -4. 执行前数据备份/快照(用于回滚和验证) -5. 显式事务中执行变更 -6. 执行后验证 -7. 成功则提交,失败则自动回滚 -8. 生成执行报告 - -验证机制: -- SQL数据库: 检查影响行数是否符合预期、语法合法性、事务回滚 -- Redis: 检查命令结果、键是否存在/已被修改 -- MongoDB: 检查修改文档数、文档内容验证 -- 通用: 执行超时控制、连接健康检查、并发执行锁 - -安全增强: -- 并发执行锁: 防止同一工单被并发执行 -- 事务管理: SQL数据库使用显式事务,失败自动回滚 -- 数据备份: 执行前自动备份受影响数据 -- 超时控制: 可配置的超时,防止长时间阻塞 -""" - -import asyncio -import hashlib -import json -import logging -import re -import time -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Set - -import sqlparse - -from app.models.instance import Instance -from app.models.ticket import Ticket -from app.services.sql_analyzer import SQLAnalyzer, SQLType - -logger = logging.getLogger(__name__) - -# 超时配置(秒)—— 默认值,优先从 system_settings 读取 -SQL_TIMEOUT = 60 -REDIS_TIMEOUT = 30 -MONGO_TIMEOUT = 60 - - -def _get_executor_timeout(db_type: str) -> int: - """从 query_service 配置缓存获取执行超时。""" - from app.services.query_service import current_config - cfg = current_config() - key = {"mysql": "sql_timeout", "postgres": "sql_timeout", "redis": "redis_timeout", "mongodb": "mongo_timeout"}.get(db_type, "sql_timeout") - return cfg.get(key, SQL_TIMEOUT) - -# 并发执行锁: 防止同一工单被并发执行 -_ticket_execution_locks: Set[int] = set() -_lock_lock = asyncio.Lock() - -# Redis 写命令集合(用于验证) -REDIS_WRITE_COMMANDS = { - "SET", "SETEX", "SETNX", "MSET", "HSET", "HMSET", "HINCRBY", - "LPUSH", "RPUSH", "LPOP", "RPOP", "LREM", "LSET", "LTRIM", - "SADD", "SREM", "SPOP", "SMOVE", "ZADD", "ZREM", "ZINCRBY", - "ZREMRANGEBYSCORE", "ZREMRANGEBYRANK", "DEL", "HDEL", - "EXPIRE", "EXPIREAT", "PERSIST", "RENAME", "RENAMENX", - "INCR", "INCRBY", "DECR", "DECRBY", "APPEND", "SETBIT", - "FLUSHDB", "FLUSHALL", -} - -# MongoDB 支持的操作类型 -MONGO_SUPPORTED_OPERATIONS = { - "insertOne", "insertMany", - "updateOne", "updateMany", - "deleteOne", "deleteMany", - "replaceOne", -} - - -@dataclass -class ExecutionReport: - """执行报告,记录工单执行的完整结果。""" - - status: str = "success" # success / failed / partial - affected_rows: int = 0 # 影响行数 - duration_ms: int = 0 # 执行耗时 - before_snapshot: str = "" # 执行前快照 - after_snapshot: str = "" # 执行后快照 - validation_passed: bool = True # 验证是否通过 - warnings: List[str] = field(default_factory=list) # 警告信息 - errors: List[str] = field(default_factory=list) # 错误信息 - raw_output: str = "" # 原始输出 - - def to_json(self) -> str: - """将执行报告序列化为 JSON 字符串。""" - return json.dumps( - { - "status": self.status, - "affected_rows": self.affected_rows, - "duration_ms": self.duration_ms, - "before_snapshot": self.before_snapshot, - "after_snapshot": self.after_snapshot, - "validation_passed": self.validation_passed, - "warnings": self.warnings, - "errors": self.errors, - "raw_output": self.raw_output, - }, - ensure_ascii=False, - default=str, - ) - - @classmethod - def from_exception(cls, exc: Exception, stage: str = "执行") -> "ExecutionReport": - """从异常创建失败的执行报告。""" - return cls( - status="failed", - errors=[f"{stage}失败: {type(exc).__name__}: {exc}"], - duration_ms=0, - validation_passed=False, - ) - - -class TicketExecutorError(Exception): - """工单执行引擎异常。""" - pass - - -class SyntaxValidationError(Exception): - """语法预校验异常。""" - pass - - -class ExecutionLockError(Exception): - """工单并发执行锁冲突异常。""" - pass - - -class TicketExecutor: - """多数据库工单执行引擎。 - - 支持 MySQL、PostgreSQL、Redis 和 MongoDB 四种类型的工单执行。 - 提供执行前快照、执行后验证、超时控制、并发锁、事务管理等安全机制。 - - Attributes: - _analyzer: SQL 分析器,用于解析 SQL 类型和语法验证 - """ - - def __init__(self) -> None: - """初始化工单执行引擎。""" - self._analyzer = SQLAnalyzer() - - # ===== 并发执行锁 ===== - - @staticmethod - async def _acquire_execution_lock(ticket_id: int) -> None: - """获取工单执行锁。 - - 防止同一工单被并发执行多次。 - - Args: - ticket_id: 工单ID - - Raises: - ExecutionLockError: 工单正在执行中 - """ - async with _lock_lock: - if ticket_id in _ticket_execution_locks: - raise ExecutionLockError(f"工单 {ticket_id} 正在执行中,请勿重复提交") - _ticket_execution_locks.add(ticket_id) - logger.info("工单 %d 获取执行锁", ticket_id) - - @staticmethod - async def _release_execution_lock(ticket_id: int) -> None: - """释放工单执行锁。 - - Args: - ticket_id: 工单ID - """ - async with _lock_lock: - _ticket_execution_locks.discard(ticket_id) - logger.info("工单 %d 释放执行锁", ticket_id) - - # ===== 公共接口 ===== - - async def execute(self, ticket: Ticket, instance: Instance) -> ExecutionReport: - """执行工单。 - - 根据实例的数据库类型分发到对应的执行器。 - 使用并发锁防止同一工单被并发执行。 - - Args: - ticket: 工单对象,包含要执行的 SQL/命令内容 - instance: 数据源实例,包含连接信息 - - Returns: - ExecutionReport: 执行报告 - - Raises: - ExecutionLockError: 工单正在执行中 - ValueError: 不支持的数据库类型 - """ - # 1. 获取并发执行锁 - await self._acquire_execution_lock(ticket.id) - - try: - if instance.db_type == "mysql": - return await self._execute_mysql(ticket, instance) - elif instance.db_type in ("postgres", "postgresql"): - return await self._execute_postgres(ticket, instance) - elif instance.db_type == "redis": - return await self._execute_redis(ticket, instance) - elif instance.db_type == "mongo": - return await self._execute_mongo(ticket, instance) - else: - raise ValueError(f"不支持的执行类型: {instance.db_type}") - finally: - # 确保锁一定会被释放 - await self._release_execution_lock(ticket.id) - - # ===== MySQL 执行 ===== - - async def _execute_mysql(self, ticket: Ticket, instance: Instance) -> ExecutionReport: - """执行 MySQL 工单。""" - import aiomysql - - report = ExecutionReport() - start_time = time.time() - sql_content = ticket.sql_content.strip() - exec_strategy = getattr(ticket, "exec_strategy", "stop_on_error") or "stop_on_error" - - # 获取超时配置(从 ticket 或选项中) - custom_timeout = getattr(ticket, 'options', None) or {} - _default_timeout = _get_executor_timeout('mysql') - timeout = custom_timeout.get('timeout', _default_timeout) if isinstance(custom_timeout, dict) else _default_timeout - - try: - # 1. 语法预校验 - self._validate_mysql_syntax(sql_content) - - # 2. 建立连接(显式事务,autocommit=False) - conn = await aiomysql.connect( - host=instance.host, - port=instance.port, - user=instance.username, - password=instance.password, - db=ticket.target_database, - autocommit=False, - connect_timeout=10, - ) - - try: - # 3. 执行前快照和数据备份 - report.before_snapshot = await self._capture_sql_before_snapshot( - conn, sql_content, "mysql" - ) - - # 执行前数据备份(用于回滚) - backup_data = await self._backup_mysql_data(conn, sql_content, "mysql") - if backup_data: - report.before_snapshot += f"\n[数据备份] {backup_data}" - - # 4. 在事务中执行 SQL - total_affected = 0 - output_lines: List[str] = [] - execution_failed = False - failed_count = 0 - - statements = self._split_statements(sql_content) - for stmt in statements: - stmt = stmt.strip() - if not stmt: - continue - - try: - async with conn.cursor() as cur: - await asyncio.wait_for(cur.execute(stmt), timeout=timeout) - affected = cur.rowcount - total_affected += max(0, affected) if affected is not None else 0 - output_lines.append(f"[{stmt[:50]}...] 影响 {affected} 行") - except Exception as stmt_error: - failed_count += 1 - execution_failed = True - output_lines.append(f"[{stmt[:50]}...] 执行失败: {stmt_error}") - report.errors.append(f"语句执行失败: {stmt_error}") - - if exec_strategy == "stop_on_error": - break - # continue: 跳过失败继续执行 - # transaction: 任何失败都回滚(默认行为,break 后走 rollback) - - report.affected_rows = total_affected - report.raw_output = "\n".join(output_lines) - - # 5. 执行后快照 - report.after_snapshot = await self._capture_sql_after_snapshot( - conn, sql_content, "mysql" - ) - - # 6. 验证执行结果 - report.validation_passed = self._validate_sql_execution( - sql_content, report - ) - - # 事务决策:根据执行策略处理 - if exec_strategy == "continue" and failed_count > 0 and failed_count < len(statements): - # continue 模式:部分失败也算成功,提交已执行的语句 - await conn.commit() - report.status = "partial_success" - report.errors.insert(0, f"部分失败: {failed_count}/{len(statements)} 条语句失败") - logger.warning("工单 %d MySQL 部分执行成功: %d 条失败", ticket.id, failed_count) - elif not execution_failed and report.validation_passed: - await conn.commit() - report.status = "success" - logger.info("工单 %d MySQL 执行成功,事务已提交", ticket.id) - else: - await conn.rollback() - report.status = "failed" - if not report.errors: - report.errors.append("执行结果验证未通过,事务已回滚") - logger.warning("工单 %d MySQL 执行失败,事务已回滚", ticket.id) - - except asyncio.TimeoutError: - report.status = "failed" - report.errors.append(f"执行超时({timeout}秒)") - report.validation_passed = False - try: - await conn.rollback() - logger.info("工单 %d 超时后事务已回滚", ticket.id) - except Exception: - pass - except Exception as e: - report.status = "failed" - report.errors.append(f"执行异常: {e}") - report.validation_passed = False - try: - await conn.rollback() - logger.info("工单 %d 异常后事务已回滚", ticket.id) - except Exception: - pass - finally: - conn.close() - - except SyntaxValidationError as e: - report.status = "failed" - report.errors.append(f"语法校验失败: {e}") - report.validation_passed = False - except Exception as e: - report.status = "failed" - report.errors.append(f"连接异常: {e}") - report.validation_passed = False - - report.duration_ms = int((time.time() - start_time) * 1000) - return report - - # ===== PostgreSQL 执行 ===== - - async def _execute_postgres(self, ticket: Ticket, instance: Instance) -> ExecutionReport: - """执行 PostgreSQL 工单。 - - 执行流程与 MySQL 类似,使用 asyncpg 驱动,支持显式事务管理。 - - Args: - ticket: 工单对象 - instance: PostgreSQL 实例连接信息 - - Returns: - ExecutionReport: 执行报告 - """ - import asyncpg - - report = ExecutionReport() - start_time = time.time() - sql_content = ticket.sql_content.strip() - database = ticket.target_database or instance.database or "postgres" - - # 获取超时配置 - custom_timeout = getattr(ticket, 'options', None) or {} - _default_timeout = _get_executor_timeout('postgres') - timeout = custom_timeout.get('timeout', _default_timeout) if isinstance(custom_timeout, dict) else _default_timeout - - try: - # 1. 语法预校验 - self._validate_postgres_syntax(sql_content) - - # 2. 建立连接(显式事务) - dsn = ( - f"postgresql://{instance.username}:{instance.password}" - f"@{instance.host}:{instance.port}/{database}" - ) - conn = await asyncpg.connect( - dsn, - command_timeout=timeout, - server_settings={ - "application_name": "sql_platform_executor", - "statement_timeout": str(timeout * 1000), # PostgreSQL 使用毫秒 - }, - ) - - try: - # 开始显式事务 - transaction = conn.transaction() - await transaction.start() - - try: - # 3. 执行前快照和数据备份 - report.before_snapshot = await self._capture_sql_before_snapshot( - conn, sql_content, "postgres" - ) - - # 执行前数据备份 - backup_data = await self._backup_mysql_data(conn, sql_content, "postgres") - if backup_data: - report.before_snapshot += f"\n[数据备份] {backup_data}" - - # 4. 在事务中执行 SQL - total_affected = 0 - output_lines: List[str] = [] - execution_failed = False - - statements = self._split_statements(sql_content) - for stmt in statements: - stmt = stmt.strip() - if not stmt: - continue - - try: - status = await asyncio.wait_for( - conn.execute(stmt), timeout=timeout - ) - # status like "UPDATE 3" or "INSERT 0 1" - parts = status.split() - affected = 0 - if len(parts) >= 2 and parts[1].isdigit(): - affected = int(parts[1]) - total_affected += affected - output_lines.append(f"[{stmt[:50]}...] 状态: {status}") - except Exception as stmt_error: - execution_failed = True - output_lines.append(f"[{stmt[:50]}...] 执行失败: {stmt_error}") - report.errors.append(f"语句执行失败: {stmt_error}") - break - - report.affected_rows = total_affected - report.raw_output = "\n".join(output_lines) - - # 5. 执行后快照 - report.after_snapshot = await self._capture_sql_after_snapshot( - conn, sql_content, "postgres" - ) - - # 6. 验证执行结果 - report.validation_passed = self._validate_sql_execution( - sql_content, report - ) - - # 事务决策 - if not execution_failed and report.validation_passed: - await transaction.commit() - report.status = "success" - logger.info("工单 %d PostgreSQL 执行成功,事务已提交", ticket.id) - else: - await transaction.rollback() - report.status = "failed" - if not report.errors: - report.errors.append("执行结果验证未通过,事务已回滚") - logger.warning("工单 %d PostgreSQL 执行失败,事务已回滚", ticket.id) - - except asyncio.TimeoutError: - await transaction.rollback() - report.status = "failed" - report.errors.append(f"执行超时({timeout}秒),事务已回滚") - report.validation_passed = False - logger.warning("工单 %d PostgreSQL 超时,事务已回滚", ticket.id) - except Exception as e: - await transaction.rollback() - report.status = "failed" - report.errors.append(f"执行异常: {e},事务已回滚") - report.validation_passed = False - logger.warning("工单 %d PostgreSQL 异常,事务已回滚: %s", ticket.id, e) - - finally: - await conn.close() - - except SyntaxValidationError as e: - report.status = "failed" - report.errors.append(f"语法校验失败: {e}") - report.validation_passed = False - except Exception as e: - report.status = "failed" - report.errors.append(f"连接异常: {e}") - report.validation_passed = False - - report.duration_ms = int((time.time() - start_time) * 1000) - return report - - # ===== Redis 执行 ===== - - async def _execute_redis(self, ticket: Ticket, instance: Instance) -> ExecutionReport: - """执行 Redis 工单。 - - Redis 工单格式支持: - - JSON 格式: {"commands": [{"command": "SET key value", "db": 0}]} - - 简单文本格式: 每行一个命令 - - 增强功能: - - 命令注入防护(shell字符检测) - - 连接池管理 - - 可配置超时 - - 原子性执行(全部成功或记录失败) - - Args: - ticket: 工单对象 - instance: Redis 实例连接信息 - - Returns: - ExecutionReport: 执行报告 - """ - import redis.asyncio as aioredis - - report = ExecutionReport() - start_time = time.time() - - # 获取超时配置 - custom_timeout = getattr(ticket, 'options', None) or {} - _default_timeout = _get_executor_timeout('redis') - timeout = custom_timeout.get('timeout', _default_timeout) if isinstance(custom_timeout, dict) else _default_timeout - - try: - # 解析命令 - commands = self._parse_redis_commands(ticket.sql_content, ticket.target_database) - if not commands: - report.status = "failed" - report.errors.append("未解析到有效的 Redis 命令") - report.validation_passed = False - return report - - # 语法预校验 - self._validate_redis_commands(commands) - - # 命令注入防护检查 - for cmd_info in commands: - command = cmd_info.get("command", "") - shell_chars = ["|", ";", "&", "$", "`", "$(", ">", "<"] - for char in shell_chars: - if char in command: - raise SyntaxValidationError( - f"Redis 命令包含非法字符 '{char}': {command}" - ) - - # 建立连接(使用连接池) - conn = aioredis.Redis( - host=instance.host, - port=instance.port, - password=instance.password or None, - decode_responses=True, - socket_timeout=timeout, - socket_connect_timeout=10, - health_check_interval=30, - ) - - # 连接健康检查 - try: - await asyncio.wait_for(conn.ping(), timeout=10) - except Exception as e: - report.status = "failed" - report.errors.append(f"Redis 连接健康检查失败: {e}") - report.validation_passed = False - return report - - try: - # 执行前快照 - report.before_snapshot = await self._capture_redis_before_snapshot( - conn, commands - ) - - # 执行命令(带重试机制) - output_lines: List[str] = [] - success_count = 0 - max_retries = 1 - - for cmd_info in commands: - command = cmd_info["command"] - db = cmd_info.get("db", 0) - - # 验证 DB 号范围 - if not isinstance(db, int) or db < 0 or db > 15: - report.errors.append(f"非法的 DB 号: {db}(必须在 0-15 之间)") - continue - - # 选择数据库 - if db != 0: - await conn.select(db) - - parts = command.strip().split() - if not parts: - continue - - cmd_name = parts[0].upper() - args = parts[1:] - - # 执行命令(带重试) - retry_count = 0 - executed = False - last_error = None - - while retry_count <= max_retries and not executed: - try: - result = await asyncio.wait_for( - conn.execute_command(cmd_name, *args), - timeout=timeout, - ) - success_count += 1 - output_lines.append( - f"[DB{db}] {command} => {self._format_redis_result(result)}" - ) - executed = True - except asyncio.TimeoutError: - retry_count += 1 - last_error = f"超时({timeout}秒)" - if retry_count > max_retries: - output_lines.append( - f"[DB{db}] {command} => 超时({timeout}秒,已重试{max_retries}次)" - ) - report.errors.append(f"命令超时: {command}") - except Exception as e: - retry_count += 1 - last_error = str(e) - if retry_count > max_retries: - output_lines.append(f"[DB{db}] {command} => 错误: {e}") - report.errors.append(f"命令执行失败 '{command}': {e}") - - report.affected_rows = success_count - report.raw_output = "\n".join(output_lines) - - # 执行后快照 - report.after_snapshot = await self._capture_redis_after_snapshot( - conn, commands - ) - - # 验证 - report.validation_passed = self._validate_redis_execution( - commands, report - ) - - if report.errors: - report.status = "partial" if success_count > 0 else "failed" - else: - report.status = "success" - - finally: - await conn.close() - - except SyntaxValidationError as e: - report.status = "failed" - report.errors.append(f"语法校验失败: {e}") - report.validation_passed = False - except Exception as e: - report.status = "failed" - report.errors.append(f"连接异常: {e}") - report.validation_passed = False - - report.duration_ms = int((time.time() - start_time) * 1000) - return report - - # ===== MongoDB 执行 ===== - - async def _execute_mongo(self, ticket: Ticket, instance: Instance) -> ExecutionReport: - """执行 MongoDB 工单。 - - MongoDB 工单格式(JSON): - { - "collection": "users", - "operation": "updateMany", - "filter": {"status": "inactive"}, - "update": {"$set": {"status": "active"}} - } - - 增强功能: - - 可配置超时 - - 连接池管理 - - 操作验证增强 - - writeConcern 配置(数据持久化保证) - - Args: - ticket: 工单对象 - instance: MongoDB 实例连接信息 - - Returns: - ExecutionReport: 执行报告 - """ - report = ExecutionReport() - start_time = time.time() - - # 获取超时配置 - custom_timeout = getattr(ticket, 'options', None) or {} - _default_timeout = _get_executor_timeout('mongodb') - timeout = custom_timeout.get('timeout', _default_timeout) if isinstance(custom_timeout, dict) else _default_timeout - - try: - # 解析工单内容 - operation_spec = self._parse_mongo_operation(ticket.sql_content) - collection_name = operation_spec["collection"] - operation = operation_spec["operation"] - - # 语法预校验 - self._validate_mongo_operation(operation_spec) - - # 额外的安全验证:禁止危险操作 - forbidden_ops = {"drop", "dropIndex", "renameCollection"} - if operation in forbidden_ops: - raise SyntaxValidationError(f"MongoDB 操作 '{operation}' 被禁止") - - # 导入 motor(异步 MongoDB 驱动) - try: - from motor.motor_asyncio import AsyncIOMotorClient - except ImportError: - report.status = "failed" - report.errors.append("缺少 motor 依赖,请安装: pip install motor") - report.validation_passed = False - return report - - # 建立连接(增强配置) - database = ticket.target_database or instance.database or "admin" - conn_str = ( - f"mongodb://{instance.username}:{instance.password}" - f"@{instance.host}:{instance.port}/{database}" - ) - client = AsyncIOMotorClient( - conn_str, - serverSelectionTimeoutMS=5000, - connectTimeoutMS=10000, - socketTimeoutMS=timeout * 1000, - maxPoolSize=10, - minPoolSize=1, - maxIdleTimeMS=60000, - waitQueueTimeoutMS=5000, - ) - - # 连接健康检查 - try: - await asyncio.wait_for(client.admin.command("ping"), timeout=5) - except Exception as e: - report.status = "failed" - report.errors.append(f"MongoDB 连接健康检查失败: {e}") - report.validation_passed = False - client.close() - return report - - try: - db = client[database] - collection = db[collection_name] - - # 执行前快照 - report.before_snapshot = await self._capture_mongo_before_snapshot( - collection, operation_spec - ) - - # 执行操作(带超时和错误处理) - filter_doc = operation_spec.get("filter", {}) - update_doc = operation_spec.get("update", {}) - doc = operation_spec.get("document", {}) - options = operation_spec.get("options", {}) - # 强制 majority write concern 保证数据持久化 - exec_options = {"w": "majority", "j": True} - if options: - exec_options.update(options) - - result_obj: Any = None - - if operation == "insertOne": - result_obj = await asyncio.wait_for( - collection.with_options(write_concern=exec_options.get("w")).insert_one(doc), - timeout=timeout, - ) - report.affected_rows = 1 if result_obj.inserted_id else 0 - report.raw_output = f"插入成功,ID: {result_obj.inserted_id}" - - elif operation == "insertMany": - docs = operation_spec.get("documents", []) - if not docs: - raise SyntaxValidationError("insertMany 操作需要非空的 'documents' 数组") - if len(docs) > 10000: - raise SyntaxValidationError(f"insertMany 最多支持 10000 条文档,当前 {len(docs)}") - result_obj = await asyncio.wait_for( - collection.insert_many(docs), - timeout=timeout, - ) - report.affected_rows = len(result_obj.inserted_ids) - report.raw_output = f"批量插入成功,共 {len(result_obj.inserted_ids)} 条" - - elif operation == "updateOne": - result_obj = await asyncio.wait_for( - collection.update_one(filter_doc, update_doc, **options), - timeout=timeout, - ) - report.affected_rows = result_obj.modified_count - report.raw_output = ( - f"匹配 {result_obj.matched_count} 条," - f"修改 {result_obj.modified_count} 条" - ) - - elif operation == "updateMany": - result_obj = await asyncio.wait_for( - collection.update_many(filter_doc, update_doc, **options), - timeout=timeout, - ) - report.affected_rows = result_obj.modified_count - report.raw_output = ( - f"匹配 {result_obj.matched_count} 条," - f"修改 {result_obj.modified_count} 条" - ) - - elif operation == "deleteOne": - result_obj = await asyncio.wait_for( - collection.delete_one(filter_doc), - timeout=timeout, - ) - report.affected_rows = result_obj.deleted_count - report.raw_output = f"删除 {result_obj.deleted_count} 条" - - elif operation == "deleteMany": - # deleteMany 安全限制:必须先查询确认影响范围 - pre_count = await collection.count_documents(filter_doc) - if pre_count > 10000: - raise SyntaxValidationError( - f"deleteMany 将影响 {pre_count} 条文档,超过安全限制 10000" - ) - result_obj = await asyncio.wait_for( - collection.delete_many(filter_doc), - timeout=timeout, - ) - report.affected_rows = result_obj.deleted_count - report.raw_output = f"批量删除 {result_obj.deleted_count} 条" - - elif operation == "replaceOne": - result_obj = await asyncio.wait_for( - collection.replace_one(filter_doc, doc, **options), - timeout=timeout, - ) - report.affected_rows = result_obj.modified_count - report.raw_output = ( - f"匹配 {result_obj.matched_count} 条," - f"替换 {result_obj.modified_count} 条" - ) - - # 执行后快照 - report.after_snapshot = await self._capture_mongo_after_snapshot( - collection, operation_spec - ) - - # 验证 - report.validation_passed = self._validate_mongo_execution( - operation_spec, report - ) - - if report.validation_passed: - report.status = "success" - logger.info("工单 %d MongoDB %s 执行成功", ticket.id, operation) - else: - report.status = "partial" - logger.warning("工单 %d MongoDB %s 验证未通过", ticket.id, operation) - - except asyncio.TimeoutError: - report.status = "failed" - report.errors.append(f"执行超时({timeout}秒)") - report.validation_passed = False - logger.warning("工单 %d MongoDB 执行超时", ticket.id) - except SyntaxValidationError as e: - report.status = "failed" - report.errors.append(f"验证失败: {e}") - report.validation_passed = False - except Exception as e: - report.status = "failed" - report.errors.append(f"执行异常: {e}") - report.validation_passed = False - logger.exception("工单 %d MongoDB 执行异常", ticket.id) - finally: - client.close() - - except SyntaxValidationError as e: - report.status = "failed" - report.errors.append(f"语法校验失败: {e}") - report.validation_passed = False - except Exception as e: - report.status = "failed" - report.errors.append(f"连接异常: {e}") - report.validation_passed = False - - report.duration_ms = int((time.time() - start_time) * 1000) - return report - - # ===== 数据备份 ===== - - async def _backup_mysql_data( - self, conn: Any, sql_content: str, db_type: str - ) -> str: - """执行前备份将被修改的数据。 - - 对 UPDATE/DELETE 操作,将被影响的数据行导出为 JSON 格式备份。 - - Args: - conn: 数据库连接 - sql_content: SQL 内容 - db_type: 数据库类型(mysql/postgres) - - Returns: - 备份信息的字符串描述 - """ - backup_summaries: List[str] = [] - - statements = self._split_statements(sql_content) - for stmt in statements: - stmt = stmt.strip() - if not stmt: - continue - - sql_type = self._analyzer._detect_sql_type(stmt) - table_name = self._extract_table_name(stmt, sql_type) - - if sql_type == SQLType.UPDATE and table_name: - where_clause = self._extract_where_clause_text(stmt) - if where_clause: - try: - row_count = await self._get_affected_count( - conn, table_name, where_clause, db_type - ) - if row_count > 0: - backup_summaries.append( - f"UPDATE `{table_name}` 将影响 {row_count} 行数据" - ) - except Exception: - pass - - elif sql_type == SQLType.DELETE and table_name: - where_clause = self._extract_where_clause_text(stmt) - if where_clause: - try: - row_count = await self._get_affected_count( - conn, table_name, where_clause, db_type - ) - if row_count > 0: - backup_summaries.append( - f"DELETE `{table_name}` 将删除 {row_count} 行数据" - ) - except Exception: - pass - - return "; ".join(backup_summaries) if backup_summaries else "无需备份" - - # ===== 执行前快照 ===== - - async def _capture_sql_before_snapshot( - self, conn: Any, sql_content: str, db_type: str - ) -> str: - """执行前捕获 SQL 数据库的数据快照。 - - 对 UPDATE/DELETE 操作,查询将被影响的数据行。 - 对 INSERT 操作,记录表当前行数。 - - Args: - conn: 数据库连接 - sql_content: SQL 内容 - db_type: 数据库类型(mysql/postgres) - - Returns: - 快照信息的字符串表示 - """ - snapshots: List[str] = [] - - statements = self._split_statements(sql_content) - for stmt in statements: - stmt = stmt.strip() - if not stmt: - continue - - sql_type = self._analyzer._detect_sql_type(stmt) - table_name = self._extract_table_name(stmt, sql_type) - - if sql_type == SQLType.UPDATE: - where_clause = self._extract_where_clause_text(stmt) - if table_name: - try: - count = await self._get_affected_count( - conn, table_name, where_clause, db_type - ) - snapshots.append( - f"UPDATE 表 `{table_name}`: 将有 {count} 行被更新" - f"{' (WHERE: ' + where_clause + ')' if where_clause else ' (全表更新)' }" - ) - except Exception as e: - snapshots.append(f"UPDATE 表 `{table_name}`: 快照获取失败 - {e}") - - elif sql_type == SQLType.DELETE: - where_clause = self._extract_where_clause_text(stmt) - if table_name: - try: - count = await self._get_affected_count( - conn, table_name, where_clause, db_type - ) - snapshots.append( - f"DELETE 表 `{table_name}`: 将有 {count} 行被删除" - f"{' (WHERE: ' + where_clause + ')' if where_clause else ' (全表删除)' }" - ) - except Exception as e: - snapshots.append(f"DELETE 表 `{table_name}`: 快照获取失败 - {e}") - - elif sql_type == SQLType.INSERT: - if table_name: - try: - current_count = await self._get_table_row_count( - conn, table_name, db_type - ) - snapshots.append( - f"INSERT 表 `{table_name}`: 当前 {current_count} 行" - ) - except Exception as e: - snapshots.append(f"INSERT 表 `{table_name}`: 行数获取失败 - {e}") - - return "\n".join(snapshots) if snapshots else "无快照信息" - - async def _capture_sql_after_snapshot( - self, conn: Any, sql_content: str, db_type: str - ) -> str: - """执行后捕获 SQL 数据库的数据快照。 - - Args: - conn: 数据库连接 - sql_content: SQL 内容 - db_type: 数据库类型 - - Returns: - 执行后的快照信息 - """ - snapshots: List[str] = [] - - statements = self._split_statements(sql_content) - for stmt in statements: - stmt = stmt.strip() - if not stmt: - continue - - sql_type = self._analyzer._detect_sql_type(stmt) - table_name = self._extract_table_name(stmt, sql_type) - - if table_name and sql_type in (SQLType.UPDATE, SQLType.DELETE, SQLType.INSERT): - try: - current_count = await self._get_table_row_count( - conn, table_name, db_type - ) - snapshots.append(f"表 `{table_name}`: 当前 {current_count} 行") - except Exception as e: - snapshots.append(f"表 `{table_name}`: 行数获取失败 - {e}") - - return "\n".join(snapshots) if snapshots else "无快照信息" - - async def _capture_redis_before_snapshot( - self, conn: Any, commands: List[Dict[str, Any]] - ) -> str: - """执行前捕获 Redis 键的快照。 - - 记录将被修改的键的当前值和 TTL。 - - Args: - conn: Redis 连接 - commands: 命令列表 - - Returns: - 键快照信息的字符串表示 - """ - snapshots: List[str] = [] - keys_to_capture: set = set() - - # 提取可能被修改的键 - for cmd_info in commands: - command = cmd_info["command"] - db = cmd_info.get("db", 0) - parts = command.strip().split() - if len(parts) >= 2: - cmd_name = parts[0].upper() - if cmd_name in REDIS_WRITE_COMMANDS: - key = parts[1] - keys_to_capture.add((key, db)) - - # 查询每个键的当前值 - for key, db in keys_to_capture: - try: - if db != 0: - await conn.select(db) - key_type = await conn.type(key) - ttl = await conn.ttl(key) - - if key_type == "none": - snapshots.append(f"[DB{db}] 键 '{key}': 不存在") - else: - # 获取值(限制长度) - value_preview = await self._get_redis_value_preview(conn, key, key_type) - ttl_str = f"TTL={ttl}s" if ttl > 0 else "无过期时间" - snapshots.append( - f"[DB{db}] 键 '{key}' ({key_type}, {ttl_str}): {value_preview}" - ) - except Exception as e: - snapshots.append(f"[DB{db}] 键 '{key}': 快照获取失败 - {e}") - - return "\n".join(snapshots) if snapshots else "无键被修改" - - async def _capture_redis_after_snapshot( - self, conn: Any, commands: List[Dict[str, Any]] - ) -> str: - """执行后捕获 Redis 键的快照。""" - return await self._capture_redis_before_snapshot(conn, commands) - - async def _capture_mongo_before_snapshot( - self, collection: Any, operation_spec: Dict[str, Any] - ) -> str: - """执行前捕获 MongoDB 文档快照。 - - 记录将被修改的文档数量和示例。 - - Args: - collection: MongoDB 集合对象 - operation_spec: 操作规格 - - Returns: - 文档快照信息的字符串表示 - """ - operation = operation_spec.get("operation", "") - filter_doc = operation_spec.get("filter", {}) - - try: - if operation in ("updateOne", "updateMany", "deleteOne", "deleteMany", "replaceOne"): - count = await collection.count_documents(filter_doc) - return f"操作 '{operation}': 匹配 {count} 条文档\n筛选条件: {json.dumps(filter_doc, ensure_ascii=False)}" - elif operation in ("insertOne", "insertMany"): - return f"操作 '{operation}': 将插入新文档" - return f"操作 '{operation}': 无前置快照" - except Exception as e: - return f"快照获取失败: {e}" - - async def _capture_mongo_after_snapshot( - self, collection: Any, operation_spec: Dict[str, Any] - ) -> str: - """执行后捕获 MongoDB 文档快照。""" - operation = operation_spec.get("operation", "") - filter_doc = operation_spec.get("filter", {}) - - try: - if operation in ("updateOne", "updateMany", "replaceOne"): - count = await collection.count_documents(filter_doc) - return f"操作 '{operation}' 完成后: 匹配 {count} 条文档" - elif operation in ("deleteOne", "deleteMany"): - return f"操作 '{operation}' 已完成" - elif operation in ("insertOne", "insertMany"): - total = await collection.estimated_document_count() - return f"操作 '{operation}' 完成后: 集合约 {total} 条文档" - return f"操作 '{operation}': 无后置快照" - except Exception as e: - return f"快照获取失败: {e}" - - # ===== 验证方法 ===== - - def _validate_sql_execution(self, sql_content: str, report: ExecutionReport) -> bool: - """验证 SQL 执行结果。 - - 验证项: - 1. 影响行数 >= 0 - 2. UPDATE/DELETE 有 WHERE 条件时,影响行数应合理 - 3. 无错误信息 - - Args: - sql_content: SQL 内容 - report: 执行报告 - - Returns: - 验证是否通过 - """ - passed = True - - statements = self._split_statements(sql_content) - for stmt in statements: - stmt = stmt.strip() - if not stmt: - continue - - sql_type = self._analyzer._detect_sql_type(stmt) - - # 检查 UPDATE/DELETE 是否缺少 WHERE - if sql_type in (SQLType.UPDATE, SQLType.DELETE): - has_where = self._extract_where_clause_text(stmt) is not None - if not has_where: - report.warnings.append( - f"{sql_type.value} 操作缺少 WHERE 条件,影响全表" - ) - - # 检查是否有执行错误 - if report.errors: - passed = False - - return passed - - def _validate_redis_execution( - self, commands: List[Dict[str, Any]], report: ExecutionReport - ) -> bool: - """验证 Redis 执行结果。 - - 验证项: - 1. 所有命令都已执行 - 2. 写命令返回预期结果 - - Args: - commands: 命令列表 - report: 执行报告 - - Returns: - 验证是否通过 - """ - passed = True - - # 检查是否有错误 - if report.errors: - passed = False - - # 检查是否所有命令都执行成功 - if report.affected_rows != len(commands): - report.warnings.append( - f"仅 {report.affected_rows}/{len(commands)} 条命令成功执行" - ) - - return passed - - def _validate_mongo_execution( - self, operation_spec: Dict[str, Any], report: ExecutionReport - ) -> bool: - """验证 MongoDB 执行结果。 - - 验证项: - 1. 修改文档数符合预期 - 2. 无错误信息 - - Args: - operation_spec: 操作规格 - report: 执行报告 - - Returns: - 验证是否通过 - """ - passed = True - operation = operation_spec.get("operation", "") - - # 检查写操作是否有影响 - if operation in ("updateOne", "updateMany", "replaceOne"): - if report.affected_rows == 0: - report.warnings.append(f"{operation} 未修改任何文档") - - elif operation in ("deleteOne", "deleteMany"): - if report.affected_rows == 0: - report.warnings.append(f"{operation} 未删除任何文档") - - elif operation in ("insertOne",): - if report.affected_rows != 1: - report.errors.append(f"insertOne 应插入 1 条文档,实际 {report.affected_rows}") - passed = False - - # 检查错误 - if report.errors: - passed = False - - return passed - - # ===== 语法预校验 ===== - - def _validate_mysql_syntax(self, sql_content: str) -> None: - """预校验 MySQL 语法。 - - 使用 sqlparse 解析验证 SQL 语句的基本合法性。 - - Args: - sql_content: SQL 内容 - - Raises: - SyntaxValidationError: 语法校验失败 - """ - if not sql_content or not sql_content.strip(): - raise SyntaxValidationError("SQL 内容不能为空") - - statements = self._split_statements(sql_content) - for stmt in statements: - stmt = stmt.strip() - if not stmt: - continue - - # 使用 sqlparse 解析 - parsed = sqlparse.parse(stmt) - if not parsed or not parsed[0].tokens: - raise SyntaxValidationError(f"无法解析 SQL: {stmt[:100]}") - - # 检查是否为合法的 SQL 语句(DML + DDL) - sql_type = self._analyzer._detect_sql_type(stmt) - allowed_types = ( - SQLType.INSERT, SQLType.UPDATE, SQLType.DELETE, SQLType.SELECT, - SQLType.CREATE, SQLType.ALTER, SQLType.DROP, SQLType.TRUNCATE, - ) - if sql_type not in allowed_types: - raise SyntaxValidationError( - f"不支持的 SQL 类型: {sql_type.value}, 仅支持 INSERT/UPDATE/DELETE/CREATE/ALTER/DROP/TRUNCATE" - ) - - def _validate_postgres_syntax(self, sql_content: str) -> None: - """预校验 PostgreSQL 语法。 - - PostgreSQL 语法校验与 MySQL 类似,使用 sqlparse 进行基础解析。 - - Args: - sql_content: SQL 内容 - - Raises: - SyntaxValidationError: 语法校验失败 - """ - # PostgreSQL 基础语法校验与 MySQL 相同 - self._validate_mysql_syntax(sql_content) - - def _validate_redis_commands(self, commands: List[Dict[str, Any]]) -> None: - """预校验 Redis 命令格式。 - - 验证命令名和参数数量是否合法。 - - Args: - commands: 命令列表 - - Raises: - SyntaxValidationError: 语法校验失败 - """ - for cmd_info in commands: - command = cmd_info.get("command", "").strip() - if not command: - raise SyntaxValidationError("命令不能为空") - - parts = command.split() - cmd_name = parts[0].upper() - - # 检查命令名是否合法(基本检查) - if not re.match(r"^[A-Z]+$", cmd_name): - raise SyntaxValidationError(f"非法的命令名: {cmd_name}") - - # 检查参数数量(基本检查) - if len(parts) < 2 and cmd_name not in ( - "PING", "INFO", "DBSIZE", "FLUSHDB", "FLUSHALL", "SAVE", - "BGSAVE", "LASTSAVE", "MONITOR", "SYNC", "SHUTDOWN", - ): - raise SyntaxValidationError(f"命令 {cmd_name} 缺少参数") - - def _validate_mongo_operation(self, operation_spec: Dict[str, Any]) -> None: - """预校验 MongoDB 操作。 - - 验证 JSON 格式和操作类型是否合法。 - - Args: - operation_spec: 操作规格字典 - - Raises: - SyntaxValidationError: 语法校验失败 - """ - if not isinstance(operation_spec, dict): - raise SyntaxValidationError("MongoDB 工单必须是 JSON 对象") - - collection = operation_spec.get("collection") - if not collection or not isinstance(collection, str): - raise SyntaxValidationError("缺少或非法的 'collection' 字段") - - operation = operation_spec.get("operation") - if operation not in MONGO_SUPPORTED_OPERATIONS: - raise SyntaxValidationError( - f"不支持的操作类型: {operation}, " - f"支持: {', '.join(MONGO_SUPPORTED_OPERATIONS)}" - ) - - # 检查各操作必需参数 - if operation in ("insertOne",) and "document" not in operation_spec: - raise SyntaxValidationError("insertOne 操作需要 'document' 字段") - - if operation in ("insertMany",) and "documents" not in operation_spec: - raise SyntaxValidationError("insertMany 操作需要 'documents' 字段") - - if operation in ("updateOne", "updateMany", "replaceOne"): - if "filter" not in operation_spec: - raise SyntaxValidationError(f"{operation} 操作需要 'filter' 字段") - if "update" not in operation_spec and operation != "replaceOne": - raise SyntaxValidationError(f"{operation} 操作需要 'update' 字段") - if operation == "replaceOne" and "document" not in operation_spec: - raise SyntaxValidationError("replaceOne 操作需要 'document' 字段") - - if operation in ("deleteOne", "deleteMany"): - if "filter" not in operation_spec: - raise SyntaxValidationError(f"{operation} 操作需要 'filter' 字段") - - # ===== 工具方法 ===== - - @staticmethod - def _split_statements(sql_content: str) -> List[str]: - """将 SQL 内容按分号分割为多条语句。 - - 智能处理字符串内的分号。 - - Args: - sql_content: SQL 内容 - - Returns: - 语句列表 - """ - if not sql_content: - return [] - - # 使用 sqlparse 进行更智能的分割 - parsed = sqlparse.split(sql_content) - return [p.strip() for p in parsed if p.strip()] - - def _extract_table_name(self, sql: str, sql_type: SQLType) -> Optional[str]: - """从 SQL 语句中提取表名。 - - Args: - sql: SQL 语句 - sql_type: SQL 类型 - - Returns: - 表名,解析失败返回 None - """ - sql = sql.strip() - upper = sql.upper() - - if sql_type == SQLType.INSERT: - match = re.search(r"INSERT\s+(?:IGNORE\s+)?INTO\s+`?([^`\s(]+)`?", upper) - if match: - return match.group(1).strip() - - elif sql_type == SQLType.UPDATE: - match = re.search(r"UPDATE\s+`?([^`\s,]+)`?", upper) - if match: - return match.group(1).strip() - - elif sql_type == SQLType.DELETE: - match = re.search(r"DELETE\s+FROM\s+`?([^`\s(]+)`?", upper) - if not match: - match = re.search(r"DELETE\s+`?([^`\s(]+)`?\s+FROM", upper) - if match: - return match.group(1).strip() - - return None - - @staticmethod - def _extract_where_clause_text(sql: str) -> Optional[str]: - """从 SQL 语句中提取 WHERE 子句文本。 - - Args: - sql: SQL 语句 - - Returns: - WHERE 条件文本,没有则返回 None - """ - parsed = sqlparse.parse(sql) - if not parsed: - return None - - statement = parsed[0] - - # 使用 sqlparse 的 Where 对象 - from sqlparse.sql import Where - for token in statement.tokens: - if isinstance(token, Where): - where_text = str(token).replace("WHERE", "", 1).strip() - return where_text - - # 回退:检查关键字 - from sqlparse.tokens import Keyword - where_tokens = [] - in_where = False - for token in statement.tokens: - if token.ttype is Keyword and token.normalized == "WHERE": - in_where = True - continue - if in_where: - if token.ttype is Keyword and token.normalized in ( - "ORDER", "GROUP", "HAVING", "LIMIT", - ): - break - where_tokens.append(str(token)) - - if where_tokens: - return "".join(where_tokens).strip() - return None - - async def _get_affected_count( - self, conn: Any, table_name: str, where_clause: Optional[str], db_type: str - ) -> int: - """获取将被影响的数据行数。 - - Args: - conn: 数据库连接 - table_name: 表名 - where_clause: WHERE 条件 - db_type: 数据库类型 - - Returns: - 行数 - """ - # 安全过滤表名(只允许字母数字下划线) - safe_table = re.sub(r"[^\w]", "", table_name) - if not safe_table: - raise ValueError("非法的表名") - - if db_type == "postgres": - sql = f'SELECT COUNT(*) FROM "{safe_table}"' - else: - sql = f"SELECT COUNT(*) FROM `{safe_table}`" - - if where_clause: - sql += f" WHERE {where_clause}" - sql += " LIMIT 10001" - - # 根据连接类型执行查询 - if db_type == "mysql": - async with conn.cursor() as cur: - await cur.execute(sql) - row = await cur.fetchone() - return row[0] if row else 0 - else: - # PostgreSQL (asyncpg) - row = await conn.fetchval(sql) - return row or 0 - - async def _get_table_row_count( - self, conn: Any, table_name: str, db_type: str - ) -> int: - """获取表的当前行数。 - - Args: - conn: 数据库连接 - table_name: 表名 - db_type: 数据库类型 - - Returns: - 行数 - """ - safe_table = re.sub(r"[^\w]", "", table_name) - if not safe_table: - raise ValueError("非法的表名") - - if db_type == "postgres": - sql = f'SELECT COUNT(*) FROM "{safe_table}"' - else: - sql = f"SELECT COUNT(*) FROM `{safe_table}`" - - sql += " LIMIT 10001" - - if db_type == "mysql": - async with conn.cursor() as cur: - await cur.execute(sql) - row = await cur.fetchone() - return row[0] if row else 0 - else: - row = await conn.fetchval(sql) - return row or 0 - - @staticmethod - def _parse_redis_commands(content: str, target_database: str = "0") -> List[Dict[str, Any]]: - """解析 Redis 工单内容为命令列表。 - - 支持 JSON 格式和简单文本格式。 - - Args: - content: 工单内容 - target_database: 目标 DB 编号(来自 ticket.target_database) - - Returns: - 命令列表,每项为 {"command": str, "db": int} - """ - content = content.strip() - if not content: - return [] - - # 解析默认 db 编号 - try: - default_db = int(target_database) - except (ValueError, TypeError): - default_db = 0 - - # 尝试 JSON 解析 - if content.startswith("{"): - try: - data = json.loads(content) - if isinstance(data, dict) and "commands" in data: - commands = [] - for cmd in data["commands"]: - if isinstance(cmd, dict) and "command" in cmd: - commands.append({ - "command": cmd["command"], - "db": cmd.get("db", default_db), - }) - elif isinstance(cmd, str): - commands.append({"command": cmd, "db": default_db}) - return commands - # 可能是单条命令的 JSON - if "command" in data: - return [{"command": data["command"], "db": data.get("db", default_db)}] - except json.JSONDecodeError: - pass # 不是 JSON,尝试文本格式 - - # 简单文本格式:每行一个命令 - commands = [] - for line in content.split("\n"): - line = line.strip() - if line and not line.startswith("#"): - commands.append({"command": line, "db": default_db}) - - return commands - - @staticmethod - def _parse_mongo_operation(content: str) -> Dict[str, Any]: - """解析 MongoDB 工单内容为操作规格。 - - Args: - content: 工单内容(JSON 字符串) - - Returns: - 操作规格字典 - - Raises: - SyntaxValidationError: JSON 解析失败 - """ - content = content.strip() - if not content: - raise SyntaxValidationError("MongoDB 工单内容不能为空") - - try: - data = json.loads(content) - if not isinstance(data, dict): - raise SyntaxValidationError("MongoDB 工单必须是 JSON 对象") - return data - except json.JSONDecodeError as e: - raise SyntaxValidationError(f"JSON 解析失败: {e}") - - @staticmethod - async def _get_redis_value_preview( - conn: Any, key: str, key_type: str, max_len: int = 200 - ) -> str: - """获取 Redis 键值的预览。 - - Args: - conn: Redis 连接 - key: 键名 - key_type: 键类型 - max_len: 最大预览长度 - - Returns: - 值预览字符串 - """ - try: - if key_type == "string": - value = await conn.get(key) - preview = str(value)[:max_len] - return f"'{preview}'" - elif key_type == "hash": - value = await conn.hgetall(key) - preview = str(value)[:max_len] - return f"{preview}" - elif key_type == "list": - length = await conn.llen(key) - return f"" - elif key_type == "set": - length = await conn.scard(key) - return f"" - elif key_type == "zset": - length = await conn.zcard(key) - return f"" - else: - return f"<{key_type}>" - except Exception: - return "<无法读取>" - - @staticmethod - def _format_redis_result(result: Any) -> str: - """格式化 Redis 命令执行结果。 - - Args: - result: 原始结果 - - Returns: - 格式化后的字符串 - """ - if result is None: - return "(nil)" - if isinstance(result, bytes): - return result.decode("utf-8", errors="replace") - if isinstance(result, bool): - return "1" if result else "0" - if isinstance(result, (list, set, tuple)): - items = [] - for item in result: - if isinstance(item, bytes): - items.append(item.decode("utf-8", errors="replace")) - else: - items.append(str(item)) - return "[" + ", ".join(items[:10]) + ("..." if len(items) > 10 else "") + "]" - if isinstance(result, dict): - preview = str(result)[:200] - return preview - return str(result) diff --git a/backend/app/services/ticket_scheduler.py b/backend/app/services/ticket_scheduler.py deleted file mode 100644 index 5360dc6..0000000 --- a/backend/app/services/ticket_scheduler.py +++ /dev/null @@ -1,260 +0,0 @@ -"""工单定时执行调度器 — 基于 APScheduler + SQLite 持久化。 - -优势(对比纯 asyncio): -1. 服务重启后定时任务不丢失(SQLite job store 持久化) -2. 启动时自动恢复 scheduled 状态的工单 -3. 支持任务重试、misfire_grace_time 等生产级特性 -""" -import asyncio -import logging -import os -from datetime import datetime, timezone -from typing import Optional - -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore -from apscheduler.triggers.date import DateTrigger - -logger = logging.getLogger(__name__) - -# 全局调度器实例(延迟初始化) -_scheduler: Optional[AsyncIOScheduler] = None - -DB_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'apscheduler.db') - - -def _get_scheduler() -> AsyncIOScheduler: - """获取或创建 APScheduler 实例(单例)。""" - global _scheduler - if _scheduler is not None and _scheduler.running: - return _scheduler - - jobstore_url = 'sqlite:///' + os.path.abspath(DB_DIR) - _scheduler = AsyncIOScheduler( - jobstores={'default': SQLAlchemyJobStore(url=jobstore_url)}, - job_defaults={ - 'coalesce': True, # 同一 job 只执行一次 - 'max_instances': 1, # 不并发 - 'misfire_grace_time': 300, # 5 分钟内迟到仍执行 - }, - ) - return _scheduler - - -def start_scheduler() -> None: - """启动调度器(应用启动时调用)。""" - scheduler = _get_scheduler() - if not scheduler.running: - scheduler.start() - logger.info("APScheduler 已启动 (SQLite 持久化)") - - # 注册日志自动清理 cron job(每天凌晨执行) - try: - scheduler.remove_job('log_cleanup') - except Exception: - pass - from app.services.log_cleanup import run_cleanup_sync - scheduler.add_job( - run_cleanup_sync, - 'cron', - hour=3, - minute=0, - id='log_cleanup', - replace_existing=True, - name='日志自动清理', - ) - logger.info("日志清理 cron job 已注册 (每天 03:00)") - - -def shutdown_scheduler() -> None: - """关闭调度器。""" - global _scheduler - if _scheduler and _scheduler.running: - _scheduler.shutdown(wait=False) - logger.info("APScheduler 已关闭") - - -async def schedule_ticket_execution(ticket_id: int, scheduled_at: datetime) -> None: - """注册一个定时执行任务。 - - Args: - ticket_id: 工单 ID - scheduled_at: 定时执行时间 (UTC) - """ - scheduler = _get_scheduler() - job_id = f"ticket_{ticket_id}" - - now = datetime.now(timezone.utc) - run_date = scheduled_at if scheduled_at.tzinfo else scheduled_at.replace(tzinfo=timezone.utc) - - if run_date <= now: - # 已到时间,立即执行 - logger.info("工单 %d 定时时间已到,立即执行", ticket_id) - await _execute_scheduled_ticket(ticket_id) - return - - delay = (run_date - now).total_seconds() - logger.info("工单 %d 定时执行: %s (%.1f 秒后)", ticket_id, scheduled_at, delay) - - scheduler.add_job( - _run_async_execution, - trigger=DateTrigger(run_date=run_date), - args=[ticket_id], - id=job_id, - replace_existing=True, - name=f"定时执行工单 #{ticket_id}", - ) - - -async def _run_async_execution(ticket_id: int) -> None: - """APScheduler 调用的入口(同步 wrapper → 异步执行)。""" - await _execute_scheduled_ticket(ticket_id) - - -async def _execute_scheduled_ticket(ticket_id: int) -> None: - """执行定时工单 — 状态切换后 create_task 后台执行,不阻塞 APScheduler。""" - import asyncio - from app.core.database import async_session - from app.models.ticket import Ticket as TicketModel - from app.models.instance import Instance - from app.services.ticket_executor import TicketExecutor - from app.core.security import decrypt_password - from sqlalchemy import select - import json - - logger.info("定时工单 %d 触发执行", ticket_id) - - # 先切状态为 executing - async with async_session() as db: - result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id)) - ticket = result.scalar_one_or_none() - if not ticket: - logger.error("定时工单 %d 不存在", ticket_id) - return - if ticket.status != "scheduled": - logger.warning("工单 %d 状态已变更 (%s),跳过", ticket_id, ticket.status) - return - ticket.status = "executing" - await db.commit() - - # 后台 task 执行,APScheduler 立即释放 - async def _do_exec(): - async with async_session() as db: - try: - t = (await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))).scalar_one_or_none() - if not t or t.status != "executing": - return - inst = (await db.execute(select(Instance).where(Instance.id == t.instance_id))).scalar_one_or_none() - if not inst: - t.status = "failed" - t.execute_result = json.dumps({"status": "failed", "errors": ["数据源实例不存在"]}, ensure_ascii=False) - t.execute_time = datetime.now(timezone.utc) - await db.commit() - return - inst.password = decrypt_password(inst.password) - executor = TicketExecutor() - report = await executor.execute(t, inst) - t.status = "success" if report.status == "success" else "failed" - t.execute_result = report.to_json() - t.execute_time = datetime.now(timezone.utc) - await db.commit() - logger.info("定时工单 %d 执行完成: %s", ticket_id, report.status) - except Exception as e: - logger.exception("定时工单 %d 执行异常", ticket_id) - try: - t = (await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))).scalar_one_or_none() - if t: - t.status = "failed" - t.execute_result = json.dumps({"status": "failed", "errors": [f"{type(e).__name__}: {e}"]}, ensure_ascii=False) - t.execute_time = datetime.now(timezone.utc) - await db.commit() - except Exception: - logger.exception("更新定时工单 %d 失败状态也失败", ticket_id) - - asyncio.get_event_loop().create_task(_do_exec()) - - -def cancel_ticket_execution(ticket_id: int) -> bool: - """取消定时执行任务。 - - Args: - ticket_id: 工单 ID - - Returns: - 是否成功取消 - """ - scheduler = _get_scheduler() - job_id = f"ticket_{ticket_id}" - try: - scheduler.remove_job(job_id) - logger.info("已取消工单 %d 的定时执行", ticket_id) - return True - except Exception: - return False - - -async def recover_scheduled_tickets() -> int: - """启动时恢复所有 scheduled 状态的工单。 - - 扫描 DB 中 status=scheduled 的工单,重新注册到 APScheduler。 - - Returns: - 恢复的任务数量 - """ - from app.core.database import async_session - from app.models.ticket import Ticket as TicketModel - from sqlalchemy import select - - count = 0 - async with async_session() as db: - result = await db.execute( - select(TicketModel).where(TicketModel.status == "scheduled") - ) - tickets = result.scalars().all() - - now = datetime.now(timezone.utc) - for ticket in tickets: - if not ticket.scheduled_at: - continue - - scheduled = ticket.scheduled_at - if scheduled.tzinfo is None: - scheduled = scheduled.replace(tzinfo=timezone.utc) - - if scheduled <= now: - # 已过期,立即执行 - logger.info("恢复过期定时工单 %d,立即执行", ticket.id) - asyncio.create_task(_execute_scheduled_ticket(ticket.id)) - else: - await schedule_ticket_execution(ticket.id, scheduled) - - count += 1 - - if count > 0: - logger.info("恢复了 %d 个定时执行工单", count) - return count - - -def get_active_scheduled_count() -> int: - """返回当前 APScheduler 中的定时任务数量。""" - scheduler = _get_scheduler() - if not scheduler.running: - return 0 - jobs = scheduler.get_jobs() - return len([j for j in jobs if j.id.startswith("ticket_")]) - - -def get_active_scheduled_ids() -> list[int]: - """返回当前定时工单 ID 列表。""" - scheduler = _get_scheduler() - if not scheduler.running: - return [] - jobs = scheduler.get_jobs() - ids = [] - for j in jobs: - if j.id.startswith("ticket_"): - try: - ids.append(int(j.id.split("_")[1])) - except (ValueError, IndexError): - pass - return ids diff --git a/backend/app/services/workflow_engine.py b/backend/app/services/workflow_engine.py deleted file mode 100644 index c699545..0000000 --- a/backend/app/services/workflow_engine.py +++ /dev/null @@ -1,1319 +0,0 @@ -"""多级审批流引擎 — 支持串行、并行、条件审批、超时处理与流程中断恢复。 - -对标 Yearning/Bytebase 的审批能力: -- 启动审批流程时根据工单绑定的 workflow 创建审批实例 -- 支持通过、拒绝、转交、跳过、中断、恢复等操作 -- 动态审批人分配(基于表达式、负载均衡、上级指派) -- 条件判断增强(支持复杂表达式和字段引用) -- 超时自动处理(支持多种超时策略) -- 完整的执行日志记录 -""" -import json -import asyncio -import random -import hashlib -import re -from datetime import datetime, timezone, timedelta -from typing import List, Optional, Dict, Any, Callable, Awaitable, Tuple -from dataclasses import dataclass, field -from enum import Enum - -from sqlalchemy import select, func, and_, or_, update, desc -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.models.workflow import ( - WorkflowDefinition, WorkflowStep, WorkflowVersion, - TicketApproval, WorkflowExecutionLog -) -from app.models.ticket import Ticket -from app.models.user import User - - -# ═══════════════════════════════════════════════════════════ -# 数据类定义 -# ═══════════════════════════════════════════════════════════ - - -class WorkflowStatus(str, Enum): - """工作流执行状态。""" - PENDING = "pending" - RUNNING = "running" - APPROVED = "approved" - REJECTED = "rejected" - INTERRUPTED = "interrupted" # 被中断 - TIMEOUT = "timeout" - SKIPPED = "skipped" - CANCELLED = "cancelled" - - -class ApprovalAction(str, Enum): - """审批动作类型。""" - PENDING = "pending" - APPROVED = "approved" - REJECTED = "rejected" - TRANSFERRED = "transferred" - SKIPPED = "skipped" - AUTO_APPROVED = "auto_approved" - INTERRUPTED = "interrupted" - - -class TimeoutStrategy(str, Enum): - """超时处理策略。""" - AUTO_APPROVE = "auto_approve" - AUTO_REJECT = "auto_reject" - ESCALATE = "escalate" # 升级到上级 - NOTIFY = "notify" # 仅通知 - - -@dataclass -class ApprovalProgress: - """审批进度数据。""" - ticket_id: int - current_step: int - total_steps: int - status: str - steps: List[Dict[str, Any]] - can_interrupt: bool = False - can_resume: bool = False - interrupt_reason: Optional[str] = None - - -@dataclass -class ConditionResult: - """条件判断结果。""" - passed: bool - field: str - operator: str - expected: Any - actual: Any - message: str = "" - - -@dataclass -class ApproverAssignment: - """审批人分配结果。""" - user_id: Optional[int] - assignment_type: str # role / specific / leader / load_balance / creator / expression - reason: str = "" - - -# ═══════════════════════════════════════════════════════════ -# 流程引擎核心类 -# ═══════════════════════════════════════════════════════════ - - -class WorkflowEngine: - """多级审批流引擎 — 高性能、条件化、可中断。""" - - def __init__(self, db: AsyncSession): - self.db = db - # 步骤类型处理器映射 - self._step_handlers: Dict[str, Callable[..., Awaitable[Dict[str, Any]]]] = { - "serial": self._handle_serial_step, - "parallel": self._handle_parallel_step, - "any_one": self._handle_any_one_step, - "condition": self._handle_condition_step, - } - # 超时策略处理器 - self._timeout_handlers: Dict[str, Callable[..., Awaitable[None]]] = { - TimeoutStrategy.AUTO_APPROVE.value: self._timeout_auto_approve, - TimeoutStrategy.AUTO_REJECT.value: self._timeout_auto_reject, - TimeoutStrategy.ESCALATE.value: self._timeout_escalate, - TimeoutStrategy.NOTIFY.value: self._timeout_notify, - } - - # ═══════════════════════════════════════════════════════════ - # 公共 API - # ═══════════════════════════════════════════════════════════ - - async def start_workflow( - self, - ticket_id: int, - workflow_id: Optional[int] = None, - context: Optional[Dict[str, Any]] = None, - ) -> List[TicketApproval]: - """为工单启动审批流程。 - - Args: - ticket_id: 工单ID - workflow_id: 指定流程ID,None 使用默认流程 - context: 流程上下文数据(用于条件判断) - - Returns: - 创建的审批记录列表 - """ - workflow = await self._get_workflow(workflow_id) - if not workflow: - return [] - - # 获取当前激活版本 - current_version = workflow.get_current_version() - version_id = current_version.id if current_version else None - - # 检查触发条件 - if workflow.trigger_conditions: - should_trigger = await self._evaluate_trigger_conditions( - workflow.trigger_conditions, ticket_id, context - ) - if not should_trigger: - # 条件不满足,直接通过(无需审批) - return [] - - # 创建审批记录 - approvals = [] - for step in workflow.steps: - # 检查步骤准入条件 - if step.entry_conditions: - should_enter = await self._evaluate_conditions( - step.entry_conditions, ticket_id, context - ) - if not should_enter: - continue # 跳过不满足条件的步骤 - - # 动态分配审批人 - assignment = await self._resolve_approver(step, ticket_id, context) - - approval = TicketApproval( - ticket_id=ticket_id, - workflow_id=workflow.id, - version_id=version_id, - step_number=step.step_number, - step_name=step.name, - step_type=step.step_type, - approver_role=step.approver_role, - approver_id=assignment.user_id, - action=ApprovalAction.PENDING.value, - ) - self.db.add(approval) - approvals.append(approval) - - await self.db.flush() - - # 记录流程启动日志 - await self._log_event( - workflow_id=workflow.id, - ticket_id=ticket_id, - event_type="workflow_started", - event_description=f"审批流程已启动,共 {len(approvals)} 个步骤", - context_data={"step_count": len(approvals), "context": context}, - ) - - # 预加载超时处理任务(后台执行) - if workflow.flow_config and workflow.flow_config.get("timeout_hours"): - asyncio.create_task(self._schedule_timeout_check(ticket_id)) - - return approvals - - async def approve( - self, - approval_id: int, - user_id: int, - comment: str = "", - ) -> Dict[str, Any]: - """审批通过。 - - Returns: - dict with keys: success, new_status, message, next_step, step_completed - """ - approval = await self._get_approval(approval_id) - if not approval: - return {"success": False, "message": "审批记录不存在"} - - if approval.action not in (ApprovalAction.PENDING.value,): - return {"success": False, "message": f"当前状态为 {approval.action},无法审批"} - - # 检查权限(审批人本人或有代理权限的管理员) - has_right = await self._check_approve_permission(approval, user_id) - if not has_right: - return {"success": False, "message": "您无权审批此工单"} - - # 更新审批记录 - approval.action = ApprovalAction.APPROVED.value - approval.approver_id = user_id - approval.comment = comment or "审批通过" - approval.completed_at = datetime.now(timezone.utc) - - # 获取步骤类型 - step_type = approval.step_type or "serial" - handler = self._step_handlers.get(step_type, self._handle_serial_step) - result = await handler(approval, action="approved") - - # 记录日志 - await self._log_event( - workflow_id=approval.workflow_id, - ticket_id=approval.ticket_id, - approval_id=approval.id, - event_type="approved", - event_description=f"步骤 {approval.step_number} ({approval.step_name}) 审批通过", - actor_id=user_id, - context_data={"comment": comment, "result": result}, - ) - - return result - - async def reject( - self, - approval_id: int, - user_id: int, - comment: str = "", - ) -> Dict[str, Any]: - """审批拒绝。任一拒绝则整个工单拒绝。""" - approval = await self._get_approval(approval_id) - if not approval: - return {"success": False, "message": "审批记录不存在"} - - if approval.action not in (ApprovalAction.PENDING.value,): - return {"success": False, "message": f"当前状态为 {approval.action},无法操作"} - - approval.action = ApprovalAction.REJECTED.value - approval.approver_id = user_id - approval.comment = comment or "审批拒绝" - approval.completed_at = datetime.now(timezone.utc) - - # 拒绝整个工单 - result = await self._complete_approval(approval.ticket_id, WorkflowStatus.REJECTED) - result["message"] = f"审批已拒绝: {comment}" - - # 记录日志 - await self._log_event( - workflow_id=approval.workflow_id, - ticket_id=approval.ticket_id, - approval_id=approval.id, - event_type="rejected", - event_description=f"步骤 {approval.step_number} 审批拒绝,流程终止", - actor_id=user_id, - context_data={"comment": comment}, - ) - - return result - - async def transfer( - self, - approval_id: int, - from_user: int, - to_user: int, - comment: str = "", - ) -> Dict[str, Any]: - """转交审批给其他人。""" - approval = await self._get_approval(approval_id) - if not approval: - return {"success": False, "message": "审批记录不存在"} - - # 验证转交权限 - if approval.approver_id != from_user: - from_user_obj = await self.db.get(User, from_user) - if not from_user_obj or from_user_obj.role != "admin": - return {"success": False, "message": "您无权转交此审批"} - - # 标记原审批为转交 - approval.action = ApprovalAction.TRANSFERRED.value - approval.comment = f"转交给用户 {to_user}: {comment}" - approval.completed_at = datetime.now(timezone.utc) - - # 创建新的审批记录给目标用户 - new_approval = TicketApproval( - ticket_id=approval.ticket_id, - workflow_id=approval.workflow_id, - version_id=approval.version_id, - step_number=approval.step_number, - step_name=approval.step_name, - step_type=approval.step_type, - approver_id=to_user, - approver_role=approval.approver_role, - transfer_from=from_user, - transfer_reason=comment, - action=ApprovalAction.PENDING.value, - ) - self.db.add(new_approval) - await self.db.flush() - - # 记录日志 - await self._log_event( - workflow_id=approval.workflow_id, - ticket_id=approval.ticket_id, - event_type="transferred", - event_description=f"审批从用户 {from_user} 转交给用户 {to_user}", - actor_id=from_user, - context_data={"from_user": from_user, "to_user": to_user, "reason": comment}, - ) - - return {"success": True, "message": "审批已转交", "new_approval_id": new_approval.id} - - async def skip_step( - self, - approval_id: int, - user_id: int, - reason: str = "", - ) -> Dict[str, Any]: - """管理员跳过当前步骤。""" - approval = await self._get_approval(approval_id) - if not approval: - return {"success": False, "message": "审批记录不存在"} - - # 验证管理员权限 - user = await self.db.get(User, user_id) - if not user or user.role not in ("admin", "dba"): - return {"success": False, "message": "无权跳过步骤"} - - approval.action = ApprovalAction.SKIPPED.value - approval.approver_id = user_id - approval.comment = f"跳过: {reason}" - approval.completed_at = datetime.now(timezone.utc) - - # 检查是否全部完成 - ticket_approvals = await self._get_ticket_approvals(approval.ticket_id) - current_step_num = approval.step_number - all_done = await self._check_all_steps_done(ticket_approvals, current_step_num) - - result = {"success": True, "message": "步骤已跳过", "new_status": "reviewing"} - if all_done: - complete_result = await self._complete_approval(approval.ticket_id, WorkflowStatus.APPROVED) - result.update(complete_result) - - # 记录日志 - await self._log_event( - workflow_id=approval.workflow_id, - ticket_id=approval.ticket_id, - approval_id=approval.id, - event_type="skipped", - event_description=f"步骤 {approval.step_number} 被跳过", - actor_id=user_id, - context_data={"reason": reason}, - ) - - return result - - async def interrupt_workflow( - self, - ticket_id: int, - user_id: int, - reason: str = "", - ) -> Dict[str, Any]: - """中断流程(暂停审批,可后续恢复)。 - - 用于需要补充材料或等待外部事件的场景。 - """ - ticket = await self._get_ticket(ticket_id) - if not ticket: - return {"success": False, "message": "工单不存在"} - - user = await self.db.get(User, user_id) - if not user or user.role not in ("admin", "dba"): - return {"success": False, "message": "无权中断流程"} - - # 将所有 pending 的审批标记为 interrupted - approvals = await self._get_ticket_approvals(ticket_id) - interrupted_count = 0 - for approval in approvals: - if approval.action == ApprovalAction.PENDING.value: - approval.action = ApprovalAction.INTERRUPTED.value - approval.comment = f"流程中断: {reason}" - interrupted_count += 1 - - # 更新工单状态 - ticket.status = WorkflowStatus.INTERRUPTED.value - - # 记录日志 - await self._log_event( - workflow_id=approvals[0].workflow_id if approvals else None, - ticket_id=ticket_id, - event_type="workflow_interrupted", - event_description=f"流程被中断: {reason}", - actor_id=user_id, - context_data={"reason": reason, "interrupted_steps": interrupted_count}, - ) - - await self.db.flush() - return { - "success": True, - "message": f"流程已中断,共 {interrupted_count} 个待审批步骤被暂停", - "interrupted_count": interrupted_count, - } - - async def resume_workflow( - self, - ticket_id: int, - user_id: int, - reason: str = "", - ) -> Dict[str, Any]: - """恢复被中断的流程。""" - ticket = await self._get_ticket(ticket_id) - if not ticket: - return {"success": False, "message": "工单不存在"} - - if ticket.status != WorkflowStatus.INTERRUPTED.value: - return {"success": False, "message": "当前流程状态不允许恢复"} - - user = await self.db.get(User, user_id) - if not user or user.role not in ("admin", "dba"): - return {"success": False, "message": "无权恢复流程"} - - # 将所有 interrupted 的审批恢复为 pending - approvals = await self._get_ticket_approvals(ticket_id) - resumed_count = 0 - for approval in approvals: - if approval.action == ApprovalAction.INTERRUPTED.value: - approval.action = ApprovalAction.PENDING.value - approval.comment = f"流程恢复: {reason}" - resumed_count += 1 - - # 恢复工单状态 - ticket.status = WorkflowStatus.RUNNING.value - - # 记录日志 - await self._log_event( - workflow_id=approvals[0].workflow_id if approvals else None, - ticket_id=ticket_id, - event_type="workflow_resumed", - event_description=f"流程已恢复: {reason}", - actor_id=user_id, - context_data={"reason": reason, "resumed_steps": resumed_count}, - ) - - await self.db.flush() - return { - "success": True, - "message": f"流程已恢复,共 {resumed_count} 个步骤重新激活", - "resumed_count": resumed_count, - } - - async def cancel_workflow( - self, - ticket_id: int, - user_id: int, - reason: str = "", - ) -> Dict[str, Any]: - """取消流程(不可恢复)。""" - ticket = await self._get_ticket(ticket_id) - if not ticket: - return {"success": False, "message": "工单不存在"} - - # 取消所有 pending 的审批 - approvals = await self._get_ticket_approvals(ticket_id) - for approval in approvals: - if approval.action in (ApprovalAction.PENDING.value, ApprovalAction.INTERRUPTED.value): - approval.action = ApprovalAction.REJECTED.value - approval.comment = f"流程取消: {reason}" - approval.completed_at = datetime.now(timezone.utc) - - # 更新工单状态 - ticket.status = WorkflowStatus.CANCELLED.value - - # 记录日志 - await self._log_event( - workflow_id=approvals[0].workflow_id if approvals else None, - ticket_id=ticket_id, - event_type="workflow_cancelled", - event_description=f"流程被取消: {reason}", - actor_id=user_id, - context_data={"reason": reason}, - ) - - await self.db.flush() - return {"success": True, "message": "流程已取消"} - - async def get_approval_progress(self, ticket_id: int) -> ApprovalProgress: - """获取工单的审批进度(优化版本 — 单次查询)。""" - # 使用预加载优化查询 - result = await self.db.execute( - select(TicketApproval, User.display_name.label("approver_name")) - .outerjoin(User, TicketApproval.approver_id == User.id) - .where(TicketApproval.ticket_id == ticket_id) - .order_by(TicketApproval.step_number) - ) - rows = result.all() - - ticket_result = await self.db.execute( - select(Ticket).where(Ticket.id == ticket_id) - ) - ticket = ticket_result.scalar_one_or_none() - - steps_data = [] - current_step = 1 - total_steps = len(rows) - status = ticket.status if ticket else "unknown" - - for approval, approver_name in rows: - steps_data.append({ - "step_number": approval.step_number, - "step_name": approval.step_name, - "step_type": approval.step_type, - "approver_name": approver_name or ("未分配" if not approval.approver_id else "未知"), - "approver_role": approval.approver_role, - "action": approval.action, - "comment": approval.comment, - "is_timeout": approval.is_timeout, - "created_at": approval.created_at.isoformat() if approval.created_at else None, - "updated_at": approval.updated_at.isoformat() if approval.updated_at else None, - "completed_at": approval.completed_at.isoformat() if approval.completed_at else None, - }) - if approval.action == ApprovalAction.PENDING.value and current_step <= approval.step_number: - current_step = approval.step_number - - # 是否可以中断/恢复 - can_interrupt = status in (WorkflowStatus.RUNNING.value, WorkflowStatus.PENDING.value) - can_resume = status == WorkflowStatus.INTERRUPTED.value - - return ApprovalProgress( - ticket_id=ticket_id, - current_step=current_step, - total_steps=total_steps, - status=status, - steps=steps_data, - can_interrupt=can_interrupt, - can_resume=can_resume, - ) - - async def auto_assign(self, step: WorkflowStep) -> Optional[int]: - """基于负载自动分配审批人。 - - 支持多种分配策略: - - load_balance: 选择当前待审批任务最少的用户 - - round_robin: 轮询分配 - - random: 随机分配 - """ - assignment = step.approver_assignment or {} - strategy = assignment.get("type", "load_balance") - - if step.approver_ids: - ids = step.approver_ids if isinstance(step.approver_ids, list) else json.loads(step.approver_ids) - if ids: - if strategy == "round_robin": - # 简单的轮询:选择最近被分配次数最少的 - return await self._round_robin_assign(ids) - elif strategy == "random": - return random.choice(ids) - else: - # 负载均衡(默认) - return await self._load_balance_assign(ids) - - if step.approver_role: - result = await self.db.execute( - select(User.id) - .where(User.role == step.approver_role, User.is_active == True) - ) - candidates = [r[0] for r in result.all()] - if candidates: - if strategy == "random": - return random.choice(candidates) - return await self._load_balance_assign(candidates) - - return None - - async def check_timeout_and_process(self) -> Dict[str, Any]: - """检查超时的审批并自动处理。 - - 批量处理,支持多种超时策略。 - - Returns: - 处理结果统计 - """ - stats = {"checked": 0, "auto_approved": 0, "auto_rejected": 0, "escalated": 0, "notified": 0} - - # 批量获取超时审批 - timeout_threshold = datetime.now(timezone.utc) - timedelta(hours=24) - result = await self.db.execute( - select(TicketApproval, WorkflowStep) - .outerjoin( - WorkflowStep, - and_( - WorkflowStep.workflow_id == TicketApproval.workflow_id, - WorkflowStep.step_number == TicketApproval.step_number, - ) - ) - .where( - and_( - TicketApproval.action == ApprovalAction.PENDING.value, - TicketApproval.is_timeout == False, - TicketApproval.created_at < timeout_threshold, - ) - ) - ) - timed_out_approvals = result.all() - - for approval, step_def in timed_out_approvals: - stats["checked"] += 1 - approval.is_timeout = True - approval.timeout_handled_at = datetime.now(timezone.utc) - - # 确定超时策略 - strategy = TimeoutStrategy.NOTIFY.value - if step_def and step_def.auto_approve_on_timeout: - strategy = TimeoutStrategy.AUTO_APPROVE.value - elif approval.workflow_id: - workflow = await self.db.get(WorkflowDefinition, approval.workflow_id) - if workflow and workflow.flow_config: - strategy = workflow.flow_config.get("timeout_strategy", TimeoutStrategy.NOTIFY.value) - - # 执行对应的超时处理 - handler = self._timeout_handlers.get(strategy, self._timeout_notify) - await handler(approval, step_def) - - if strategy == TimeoutStrategy.AUTO_APPROVE.value: - stats["auto_approved"] += 1 - elif strategy == TimeoutStrategy.AUTO_REJECT.value: - stats["auto_rejected"] += 1 - elif strategy == TimeoutStrategy.ESCALATE.value: - stats["escalated"] += 1 - else: - stats["notified"] += 1 - - await self.db.flush() - return stats - - # ═══════════════════════════════════════════════════════════ - # 步骤类型处理器 - # ═══════════════════════════════════════════════════════════ - - async def _handle_serial_step(self, approval: TicketApproval, action: str) -> Dict[str, Any]: - """处理串行审批步骤。""" - ticket_approvals = await self._get_ticket_approvals(approval.ticket_id) - current_step_num = approval.step_number - - all_done = await self._check_all_steps_done(ticket_approvals, current_step_num) - if all_done: - return await self._complete_approval(approval.ticket_id, WorkflowStatus.APPROVED) - - return { - "success": True, - "new_status": "reviewing", - "message": "审批已通过,等待后续步骤", - "next_step": current_step_num + 1, - "step_completed": True, - } - - async def _handle_parallel_step(self, approval: TicketApproval, action: str) -> Dict[str, Any]: - """处理并行审批步骤(会签 — 需要所有审批人都通过)。""" - ticket_approvals = await self._get_ticket_approvals(approval.ticket_id) - current_step_num = approval.step_number - step_approvals = [a for a in ticket_approvals if a.step_number == current_step_num] - - if all(a.action in (ApprovalAction.APPROVED.value, ApprovalAction.SKIPPED.value, - ApprovalAction.AUTO_APPROVED.value) for a in step_approvals): - all_done = await self._check_all_steps_done(ticket_approvals, current_step_num) - if all_done: - return await self._complete_approval(approval.ticket_id, WorkflowStatus.APPROVED) - return { - "success": True, - "new_status": "reviewing", - "message": "当前步骤并行审批已完成,等待后续步骤", - "next_step": current_step_num + 1, - "step_completed": True, - } - - pending_count = sum(1 for a in step_approvals if a.action == ApprovalAction.PENDING.value) - return { - "success": True, - "new_status": "reviewing", - "message": f"并行审批中,还需 {pending_count} 人审批", - "next_step": current_step_num, - "step_completed": False, - "pending_count": pending_count, - } - - async def _handle_any_one_step(self, approval: TicketApproval, action: str) -> Dict[str, Any]: - """处理或签审批步骤(任一审批人通过即可)。""" - ticket_approvals = await self._get_ticket_approvals(approval.ticket_id) - current_step_num = approval.step_number - - # 将同步骤其他 pending 标记为 skipped - step_approvals = [a for a in ticket_approvals if a.step_number == current_step_num] - for sa in step_approvals: - if sa.id != approval.id and sa.action == ApprovalAction.PENDING.value: - sa.action = ApprovalAction.SKIPPED.value - sa.comment = "或签模式:其他人已通过" - sa.completed_at = datetime.now(timezone.utc) - - all_done = await self._check_all_steps_done(ticket_approvals, current_step_num) - if all_done: - return await self._complete_approval(approval.ticket_id, WorkflowStatus.APPROVED) - - return { - "success": True, - "new_status": "reviewing", - "message": "当前步骤已有人审批通过,等待后续步骤", - "next_step": current_step_num + 1, - "step_completed": True, - } - - async def _handle_condition_step(self, approval: TicketApproval, action: str) -> Dict[str, Any]: - """处理条件分支步骤。""" - # 获取步骤定义 - result = await self.db.execute( - select(WorkflowStep).where(WorkflowStep.id == approval.id) - ) - step_def = result.scalar_one_or_none() - - if step_def and step_def.transition_conditions: - conditions = step_def.transition_conditions - condition_passed = await self._evaluate_conditions( - conditions, approval.ticket_id - ) - - if not condition_passed: - on_false = conditions.get("on_false_action", "skip") - if on_false == "reject": - return await self._complete_approval(approval.ticket_id, WorkflowStatus.REJECTED) - elif on_false == "goto_step": - goto_step = conditions.get("goto_step_number") - return { - "success": True, - "new_status": "reviewing", - "message": f"条件不满足,跳转至步骤 {goto_step}", - "next_step": goto_step, - "step_completed": True, - } - - return await self._handle_serial_step(approval, action) - - # ═══════════════════════════════════════════════════════════ - # 条件判断引擎 - # ═══════════════════════════════════════════════════════════ - - async def _evaluate_trigger_conditions( - self, - conditions_config: Dict[str, Any], - ticket_id: int, - context: Optional[Dict[str, Any]] = None, - ) -> bool: - """评估流程触发条件。""" - conditions = conditions_config.get("conditions", []) - logic = conditions_config.get("logic", "AND").upper() - return await self._evaluate_condition_list(conditions, logic, ticket_id, context) - - async def _evaluate_conditions( - self, - conditions_config: Dict[str, Any], - ticket_id: int, - context: Optional[Dict[str, Any]] = None, - ) -> bool: - """评估条件列表。""" - conditions = conditions_config.get("conditions", []) - logic = conditions_config.get("logic", "AND").upper() - return await self._evaluate_condition_list(conditions, logic, ticket_id, context) - - async def _evaluate_condition_list( - self, - conditions: List[Dict[str, Any]], - logic: str, - ticket_id: int, - context: Optional[Dict[str, Any]] = None, - ) -> bool: - """评估条件列表,支持 AND/OR 逻辑。""" - if not conditions: - return True - - results = [] - for cond in conditions: - result = await self._evaluate_single_condition(cond, ticket_id, context) - results.append(result.passed) - - if logic == "AND": - return all(results) - elif logic == "OR": - return any(results) - return all(results) - - async def _evaluate_single_condition( - self, - condition: Dict[str, Any], - ticket_id: int, - context: Optional[Dict[str, Any]] = None, - ) -> ConditionResult: - """评估单个条件,支持丰富的运算符和字段引用。 - - 支持的运算符: - == / != / > / >= / < / <= / in / not_in / contains / starts_with / ends_with - regex / exists / empty - """ - field = condition.get("field", "") - operator = condition.get("operator", "==") - expected = condition.get("value") - - # 获取字段实际值 - actual = await self._resolve_field_value(field, ticket_id, context) - - passed = False - message = "" - - try: - if operator == "==": - passed = actual == expected - elif operator == "!=": - passed = actual != expected - elif operator == ">": - passed = actual is not None and expected is not None and actual > expected - elif operator == ">=": - passed = actual is not None and expected is not None and actual >= expected - elif operator == "<": - passed = actual is not None and expected is not None and actual < expected - elif operator == "<=": - passed = actual is not None and expected is not None and actual <= expected - elif operator == "in": - passed = actual in expected if isinstance(expected, (list, tuple, set)) else False - elif operator == "not_in": - passed = actual not in expected if isinstance(expected, (list, tuple, set)) else True - elif operator == "contains": - passed = expected in actual if isinstance(actual, (str, list, dict)) else False - elif operator == "starts_with": - passed = str(actual).startswith(str(expected)) - elif operator == "ends_with": - passed = str(actual).endswith(str(expected)) - elif operator == "regex": - passed = bool(re.search(str(expected), str(actual))) - elif operator == "exists": - passed = actual is not None - elif operator == "empty": - passed = actual is None or (isinstance(actual, (str, list, dict)) and len(actual) == 0) - else: - message = f"未知运算符: {operator}" - passed = False - except Exception as e: - message = f"条件评估异常: {e}" - passed = False - - return ConditionResult( - passed=passed, - field=field, - operator=operator, - expected=expected, - actual=actual, - message=message, - ) - - async def _resolve_field_value( - self, - field: str, - ticket_id: int, - context: Optional[Dict[str, Any]] = None, - ) -> Any: - """解析字段引用,支持多层嵌套访问。 - - 支持格式: - - ticket.title 工单字段 - - ticket.sql_type 工单SQL类型 - - ticket.risk_level 工单风险等级 - - ticket.creator_id 工单创建者 - - database.instance_id 数据源实例ID - - database.name 数据库名称 - - context.xxx 上下文数据 - - env.xxx 环境变量 - """ - parts = field.split(".") - if len(parts) < 2: - # 尝试从上下文直接获取 - if context and field in context: - return context[field] - return None - - source = parts[0] - attr_path = parts[1:] - - if source == "ticket": - ticket = await self._get_ticket(ticket_id) - if not ticket: - return None - value = ticket - for attr in attr_path: - if value is None: - return None - if isinstance(value, dict): - value = value.get(attr) - else: - value = getattr(value, attr, None) - return value - - elif source == "context": - if context is None: - return None - value = context - for attr in attr_path: - if value is None: - return None - if isinstance(value, dict): - value = value.get(attr) - else: - return None - return value - - elif source == "env": - # 从工单获取环境相关数据 - attr_name = ".".join(attr_path) - ticket = await self._get_ticket(ticket_id) - if ticket and hasattr(ticket, attr_name): - return getattr(ticket, attr_name) - return None - - return None - - # ═══════════════════════════════════════════════════════════ - # 审批人分配 - # ═══════════════════════════════════════════════════════════ - - async def _resolve_approver( - self, - step: WorkflowStep, - ticket_id: int, - context: Optional[Dict[str, Any]] = None, - ) -> ApproverAssignment: - """解析审批人分配策略。""" - assignment = step.approver_assignment or {} - strategy = assignment.get("type", "role") - - if strategy == "specific" and step.approver_ids: - ids = step.approver_ids if isinstance(step.approver_ids, list) else json.loads(step.approver_ids) - if ids: - return ApproverAssignment(user_id=ids[0], assignment_type="specific") - - elif strategy == "role" and step.approver_role: - result = await self.db.execute( - select(User.id).where(User.role == step.approver_role, User.is_active == True) - ) - candidates = [r[0] for r in result.all()] - if candidates: - user_id = await self._load_balance_assign(candidates) - return ApproverAssignment(user_id=user_id, assignment_type="role") - - elif strategy == "load_balance" and step.approver_ids: - ids = step.approver_ids if isinstance(step.approver_ids, list) else json.loads(step.approver_ids) - if ids: - user_id = await self._load_balance_assign(ids) - return ApproverAssignment(user_id=user_id, assignment_type="load_balance") - - elif strategy == "leader": - # 基于工单创建者的上级 - ticket = await self._get_ticket(ticket_id) - if ticket and ticket.creator_id: - # 简化实现:查找同角色的 DBA/Admin - result = await self.db.execute( - select(User.id).where( - User.role.in_(["admin", "dba"]), - User.is_active == True, - ).limit(1) - ) - leader = result.scalar_one_or_none() - if leader: - return ApproverAssignment(user_id=leader, assignment_type="leader") - - elif strategy == "creator": - ticket = await self._get_ticket(ticket_id) - if ticket: - return ApproverAssignment(user_id=ticket.creator_id, assignment_type="creator") - - elif strategy == "expression": - expression = assignment.get("expression", "") - resolved = await self._resolve_expression(expression, ticket_id, context) - if resolved: - return ApproverAssignment(user_id=resolved, assignment_type="expression") - # fallback - fallback = assignment.get("fallback_user_id") - if fallback: - return ApproverAssignment(user_id=fallback, assignment_type="expression", reason="expression_fallback") - - # 默认策略:自动分配 - user_id = await self.auto_assign(step) - return ApproverAssignment(user_id=user_id, assignment_type="auto") - - async def _resolve_expression( - self, - expression: str, - ticket_id: int, - context: Optional[Dict[str, Any]] = None, - ) -> Optional[int]: - """解析表达式获取审批人ID。 - - 支持: ${ticket.creator.manager_id} 等嵌套引用 - """ - if not expression: - return None - - # 提取变量引用 ${...} - pattern = r'\$\{([^}]+)\}' - match = re.search(pattern, expression) - if not match: - # 直接返回整数值 - try: - return int(expression) - except ValueError: - return None - - var_path = match.group(1) - value = await self._resolve_field_value(var_path, ticket_id, context) - return int(value) if value is not None else None - - async def _load_balance_assign(self, user_ids: List[int]) -> Optional[int]: - """基于负载均衡选择审批人(选择待审批最少的用户)。""" - if not user_ids: - return None - if len(user_ids) == 1: - return user_ids[0] - - workloads = [] - for uid in user_ids: - count_result = await self.db.execute( - select(func.count(TicketApproval.id)) - .where( - TicketApproval.approver_id == uid, - TicketApproval.action == ApprovalAction.PENDING.value, - ) - ) - count = count_result.scalar() or 0 - workloads.append((uid, count)) - - workloads.sort(key=lambda x: x[1]) - return workloads[0][0] - - async def _round_robin_assign(self, user_ids: List[int]) -> Optional[int]: - """轮询分配审批人。""" - if not user_ids: - return None - - # 选择最近被分配次数最少的 - counts = [] - for uid in user_ids: - count_result = await self.db.execute( - select(func.count(TicketApproval.id)) - .where( - TicketApproval.approver_id == uid, - TicketApproval.created_at > datetime.now(timezone.utc) - timedelta(days=7), - ) - ) - count = count_result.scalar() or 0 - counts.append((uid, count)) - - counts.sort(key=lambda x: x[1]) - return counts[0][0] - - # ═══════════════════════════════════════════════════════════ - # 超时处理策略 - # ═══════════════════════════════════════════════════════════ - - async def _timeout_auto_approve(self, approval: TicketApproval, step_def: Optional[WorkflowStep] = None): - """超时自动通过。""" - approval.action = ApprovalAction.AUTO_APPROVED.value - approval.comment = "超时自动通过" - approval.completed_at = datetime.now(timezone.utc) - - # 触发步骤完成检查 - await self._handle_serial_step(approval, "timeout") - - await self._log_event( - workflow_id=approval.workflow_id, - ticket_id=approval.ticket_id, - approval_id=approval.id, - event_type="timeout", - event_description=f"步骤 {approval.step_number} 超时自动通过", - context_data={"strategy": "auto_approve"}, - ) - - async def _timeout_auto_reject(self, approval: TicketApproval, step_def: Optional[WorkflowStep] = None): - """超时自动拒绝。""" - approval.action = ApprovalAction.REJECTED.value - approval.comment = "超时自动拒绝" - approval.completed_at = datetime.now(timezone.utc) - - await self._complete_approval(approval.ticket_id, WorkflowStatus.REJECTED) - - await self._log_event( - workflow_id=approval.workflow_id, - ticket_id=approval.ticket_id, - approval_id=approval.id, - event_type="timeout", - event_description=f"步骤 {approval.step_number} 超时自动拒绝", - context_data={"strategy": "auto_reject"}, - ) - - async def _timeout_escalate(self, approval: TicketApproval, step_def: Optional[WorkflowStep] = None): - """超时升级处理。""" - # 将审批升级给管理员 - result = await self.db.execute( - select(User.id).where(User.role == "admin", User.is_active == True).limit(1) - ) - admin_id = result.scalar_one_or_none() - - if admin_id: - approval.approver_id = admin_id - approval.comment = f"超时升级,转交给管理员" - # 重置超时标记,给管理员新的处理时间 - approval.is_timeout = False - approval.created_at = datetime.now(timezone.utc) - - await self._log_event( - workflow_id=approval.workflow_id, - ticket_id=approval.ticket_id, - approval_id=approval.id, - event_type="escalated", - event_description=f"步骤 {approval.step_number} 超时升级给管理员 {admin_id}", - context_data={"strategy": "escalate", "escalated_to": admin_id}, - ) - - async def _timeout_notify(self, approval: TicketApproval, step_def: Optional[WorkflowStep] = None): - """超时仅通知(不自动处理)。""" - await self._log_event( - workflow_id=approval.workflow_id, - ticket_id=approval.ticket_id, - approval_id=approval.id, - event_type="timeout", - event_description=f"步骤 {approval.step_number} 审批超时,等待人工处理", - context_data={"strategy": "notify"}, - ) - - async def _schedule_timeout_check(self, ticket_id: int): - """调度超时检查(异步任务)。""" - await asyncio.sleep(3600) # 1小时后检查 - await self.check_timeout_and_process() - - # ═══════════════════════════════════════════════════════════ - # 日志记录 - # ═══════════════════════════════════════════════════════════ - - async def _log_event( - self, - event_type: str, - event_description: str = "", - workflow_id: Optional[int] = None, - ticket_id: Optional[int] = None, - step_id: Optional[int] = None, - approval_id: Optional[int] = None, - actor_id: Optional[int] = None, - context_data: Optional[Dict[str, Any]] = None, - ) -> None: - """记录流程执行日志。""" - # 获取执行者名称 - actor_name = None - if actor_id: - user = await self.db.get(User, actor_id) - if user: - actor_name = user.display_name or user.username - - log = WorkflowExecutionLog( - workflow_id=workflow_id, - ticket_id=ticket_id, - step_id=step_id, - approval_id=approval_id, - event_type=event_type, - event_description=event_description, - actor_id=actor_id, - actor_name=actor_name, - context_data=context_data, - ) - self.db.add(log) - await self.db.flush() - - # ═══════════════════════════════════════════════════════════ - # 内部辅助方法 - # ═══════════════════════════════════════════════════════════ - - async def _get_workflow(self, workflow_id: Optional[int]) -> Optional[WorkflowDefinition]: - """获取流程定义,优先使用指定ID,否则获取默认流程。""" - if workflow_id: - result = await self.db.execute( - select(WorkflowDefinition) - .options(selectinload(WorkflowDefinition.steps)) - .where(WorkflowDefinition.id == workflow_id, WorkflowDefinition.is_active == True) - ) - workflow = result.unique().scalar_one_or_none() - if workflow: - return workflow - - # 获取默认流程 - result = await self.db.execute( - select(WorkflowDefinition) - .options(selectinload(WorkflowDefinition.steps)) - .where(WorkflowDefinition.is_default == True, WorkflowDefinition.is_active == True) - ) - return result.unique().scalar_one_or_none() - - async def _get_default_workflow(self) -> Optional[WorkflowDefinition]: - """获取默认审批流程。""" - result = await self.db.execute( - select(WorkflowDefinition) - .where(WorkflowDefinition.is_default == True, WorkflowDefinition.is_active == True) - ) - return result.scalar_one_or_none() - - async def _get_approval(self, approval_id: int) -> Optional[TicketApproval]: - result = await self.db.execute( - select(TicketApproval).where(TicketApproval.id == approval_id) - ) - return result.scalar_one_or_none() - - async def _get_ticket(self, ticket_id: int) -> Optional[Ticket]: - result = await self.db.execute( - select(Ticket).where(Ticket.id == ticket_id) - ) - return result.scalar_one_or_none() - - async def _get_ticket_approvals(self, ticket_id: int) -> List[TicketApproval]: - result = await self.db.execute( - select(TicketApproval) - .where(TicketApproval.ticket_id == ticket_id) - .order_by(TicketApproval.step_number) - ) - return result.scalars().all() - - async def _check_all_steps_done( - self, - approvals: List[TicketApproval], - current_step: int, - ) -> bool: - """检查当前步骤及之前的所有步骤是否都已完成。""" - for a in approvals: - if a.step_number <= current_step and a.action == ApprovalAction.PENDING.value: - return False - return True - - async def _check_approve_permission(self, approval: TicketApproval, user_id: int) -> bool: - """检查用户是否有审批权限。""" - # 审批人本人 - if approval.approver_id == user_id: - return True - - # 管理员可以代理审批 - user = await self.db.get(User, user_id) - if user and user.role == "admin": - return True - - # 角色匹配(如果未指定具体审批人) - if approval.approver_role and not approval.approver_id: - if user and user.role == approval.approver_role: - return True - - return False - - async def _complete_approval( - self, - ticket_id: int, - final_status: WorkflowStatus, - ) -> Dict[str, Any]: - """完成审批,更新工单状态。""" - ticket = await self._get_ticket(ticket_id) - if ticket: - ticket.status = final_status.value - if final_status == WorkflowStatus.APPROVED: - ticket.review_time = datetime.now(timezone.utc) - - await self.db.flush() - - # 记录完成日志 - await self._log_event( - workflow_id=None, - ticket_id=ticket_id, - event_type="workflow_completed" if final_status == WorkflowStatus.APPROVED else f"workflow_{final_status.value}", - event_description=f"审批流程已完成,工单状态: {final_status.value}", - context_data={"final_status": final_status.value}, - ) - - return { - "success": True, - "new_status": final_status.value, - "message": f"审批流程已完成,工单状态: {final_status.value}", - "next_step": None, - "step_completed": True, - } - - @property - def step_type(self) -> str: - """获取当前步骤类型。""" - return "serial" diff --git a/backend/app/services/workflow_seeds.py b/backend/app/services/workflow_seeds.py deleted file mode 100644 index 0229fee..0000000 --- a/backend/app/services/workflow_seeds.py +++ /dev/null @@ -1,93 +0,0 @@ -"""预置默认审批流程种子数据。""" -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.workflow import WorkflowDefinition, WorkflowStep - - -async def seed_default_workflows(db: AsyncSession) -> None: - """创建默认审批流程。 - - 创建3种预置流程: - 1. 标准审批流: 开发者 → DBA审批 → 管理员终审 - 2. 简单审批流: 开发者 → DBA审批 - 3. 紧急审批流: 开发者 → 任一管理员直接审批(超时2h自动通过) - """ - # 检查是否已有流程 - result = await db.execute(select(WorkflowDefinition)) - existing = result.scalars().all() - if existing: - return # 已有流程,不再创建 - - # 1. 标准审批流 - std_workflow = WorkflowDefinition( - name="标准审批流", - description="标准三级审批:DBA审批 → 管理员终审", - is_default=True, - ) - db.add(std_workflow) - await db.flush() - - std_steps = [ - WorkflowStep( - workflow_id=std_workflow.id, - step_number=1, - name="DBA 审批", - step_type="serial", - approver_role="dba", - is_required=True, - timeout_hours=24, - ), - WorkflowStep( - workflow_id=std_workflow.id, - step_number=2, - name="管理员终审", - step_type="serial", - approver_role="admin", - is_required=True, - timeout_hours=24, - ), - ] - for step in std_steps: - db.add(step) - - # 2. 简单审批流 - simple_workflow = WorkflowDefinition( - name="简单审批流", - description="仅 DBA 一级审批", - is_default=False, - ) - db.add(simple_workflow) - await db.flush() - - db.add(WorkflowStep( - workflow_id=simple_workflow.id, - step_number=1, - name="DBA 审批", - step_type="serial", - approver_role="dba", - is_required=True, - timeout_hours=24, - )) - - # 3. 紧急审批流 - emergency_workflow = WorkflowDefinition( - name="紧急审批流", - description="紧急变更快速通道:任一管理员审批,超时2小时自动通过", - is_default=False, - ) - db.add(emergency_workflow) - await db.flush() - - db.add(WorkflowStep( - workflow_id=emergency_workflow.id, - step_number=1, - name="管理员快速审批", - step_type="any_one", - approver_role="admin", - is_required=True, - timeout_hours=2, - auto_approve_on_timeout=True, - )) - - await db.commit() diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..af78cbe --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "log" + + "sql-platform/config" + "sql-platform/internal/middleware" + "sql-platform/internal/model" + "sql-platform/router" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func main() { + // Load config + config.Init() + + if !config.C.Debug { + gin.SetMode(gin.ReleaseMode) + } + + // Connect to SQLite + db, err := gorm.Open(sqlite.Open(config.C.DBPath), &gorm.Config{}) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + model.DB = db + + // Auto-migrate (add missing tables/columns, won\'t drop existing data) + _ = db.AutoMigrate( + &model.User{}, + &model.Instance{}, + &model.Ticket{}, + &model.TicketApproval{}, + &model.TicketSQLItem{}, + &model.AuditRule{}, + &model.AuditLog{}, + &model.QueryHistory{}, + &model.WorkflowDefinition{}, + &model.WorkflowStep{}, + &model.WorkflowVersion{}, + &model.WorkflowExecutionLog{}, + &model.Announcement{}, + &model.InstanceGroup{}, + &model.InstanceGroupMember{}, + &model.LDAPConfig{}, + &model.NotificationChannel{}, + &model.NotificationTemplate{}, + &model.SystemSetting{}, + &model.OperationLog{}, + &model.MaskingRule{}, + &model.Backup{}, + &model.SchemaRevision{}, + &model.SlowLog{}, + &model.ScheduledTask{}, + &model.ScheduledTaskLog{}, + &model.UserGroup{}, + ) + + // Setup Gin + r := gin.Default() + r.Use(middleware.CORSMiddleware()) + + // Health check + r.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok", "version": "0.3.1", "backend": "go"}) + }) + + // Routes + router.Setup(r) + + // Start server + addr := fmt.Sprintf(":%s", config.C.Port) + log.Printf("SQL Platform Go backend starting on %s", addr) + if err := r.Run(addr); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/backend/config/config.go b/backend/config/config.go new file mode 100644 index 0000000..ef388ac --- /dev/null +++ b/backend/config/config.go @@ -0,0 +1,57 @@ +package config + +import ( + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + Port string + DBPath string + JWTSecret string + JWTExpire int // hours + GhOstPath string + Debug bool +} + +var C Config + +func Init() { + _ = godotenv.Load() + + C = Config{ + Port: getEnv("PORT", "9000"), + DBPath: getEnv("DB_PATH", "../backend-python/sqlite3.db"), + JWTSecret: getEnv("JWT_SECRET", "sql-platform-secret-key-change-in-production"), + JWTExpire: getEnvInt("JWT_EXPIRE_HOURS", 24), + GhOstPath: getEnv("GH_OST_PATH", "gh-ost"), + Debug: getEnvBool("DEBUG", true), + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if i, err := strconv.Atoi(v); err == nil { + return i + } + } + return fallback +} + +func getEnvBool(key string, fallback bool) bool { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return fallback +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..7594ac3 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,44 @@ +module sql-platform + +go 1.26 + +require ( + github.com/gin-contrib/cors v1.7.5 + github.com/gin-gonic/gin v1.10.1 + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.38.0 + gorm.io/driver/sqlite v1.5.7 + gorm.io/gorm v1.26.0 +) + +require ( + github.com/bytedance/sonic v1.13.2 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.0.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.15.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/backend/internal/ghost/ghost.go b/backend/internal/ghost/ghost.go new file mode 100644 index 0000000..d99c74a --- /dev/null +++ b/backend/internal/ghost/ghost.go @@ -0,0 +1,233 @@ +package ghost + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + "sync" + "time" +) + +// GhOstTask defines a gh-ost execution task +type GhOstTask struct { + Host string `json:"host"` + User string `json:"user"` + Password string `json:"password"` + Database string `json:"database"` + Table string `json:"table"` + Alter string `json:"alter"` // e.g. "ADD COLUMN status INT NOT NULL DEFAULT 0" + ChunkSize int `json:"chunk_size,omitempty"` + MaxLoad string `json:"max_load,omitempty"` + CriticalLoad string `json:"critical_load,omitempty"` + MaxLagMillis int `json:"max_lag_millis,omitempty"` + AllowOnMaster bool `json:"allow_on_master"` + DryRun bool `json:"dry_run"` + Execute bool `json:"execute"` + ExactRowCount bool `json:"exact_row_count"` + PostponeCutOver bool `json:"postpone_cut_over"` +} + +// GhOstResult holds the execution result +type GhOstResult struct { + Success bool `json:"success"` + Output string `json:"output"` + Error string `json:"error"` + Command string `json:"command"` + Duration string `json:"duration"` + TaskID string `json:"task_id"` +} + +// ProgressCallback is called with stdout lines for real-time progress +type ProgressCallback func(taskID string, line string) + +// TaskManager manages async gh-ost tasks +type TaskManager struct { + mu sync.RWMutex + tasks map[string]*GhOstResult + status map[string]string // running, completed, failed +} + +var Manager = &TaskManager{ + tasks: make(map[string]*GhOstResult), + status: make(map[string]string), +} + +// RunGhOst executes gh-ost synchronously +func RunGhOst(ctx context.Context, task GhOstTask, ghOstPath string) (*GhOstResult, error) { + start := time.Now() + + args := []string{ + "--host=" + task.Host, + "--user=" + task.User, + "--password=" + task.Password, + "--database=" + task.Database, + "--table=" + task.Table, + "--alter=" + task.Alter, + "--verbose", + } + + if task.ChunkSize > 0 { + args = append(args, fmt.Sprintf("--chunk-size=%d", task.ChunkSize)) + } + if task.MaxLoad != "" { + args = append(args, "--max-load="+task.MaxLoad) + } + if task.CriticalLoad != "" { + args = append(args, "--critical-load="+task.CriticalLoad) + } + if task.MaxLagMillis > 0 { + args = append(args, fmt.Sprintf("--max-lag-millis=%d", task.MaxLagMillis)) + } + if task.AllowOnMaster { + args = append(args, "--allow-on-master") + } + if task.ExactRowCount { + args = append(args, "--exact-rowcount") + } + if task.PostponeCutOver { + args = append(args, "--postpone-cut-over-flag-file=/tmp/gh-ost-cutover.flag") + } + + // Dry-Run vs Execute + if task.DryRun || !task.Execute { + args = append(args, "--dry-run") + } else { + args = append(args, "--execute") + } + + cmd := exec.CommandContext(ctx, ghOstPath, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + result := &GhOstResult{ + Command: "gh-ost " + strings.Join(args, " "), + Duration: time.Since(start).String(), + Output: stdout.String(), + } + + if err != nil { + result.Success = false + result.Error = stderr.String() + "\n" + err.Error() + return result, err + } + + result.Success = true + return result, nil +} + +// RunGhOstAsync runs gh-ost in background and tracks status +func RunGhOstAsync(task GhOstTask, ghOstPath string, cb ProgressCallback) string { + taskID := fmt.Sprintf("ghost-%d", time.Now().UnixNano()) + + Manager.mu.Lock() + Manager.status[taskID] = "running" + Manager.tasks[taskID] = &GhOstResult{TaskID: taskID} + Manager.mu.Unlock() + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) + defer cancel() + + start := time.Now() + + args := buildArgs(task, ghOstPath) + + cmd := exec.CommandContext(ctx, ghOstPath, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + Manager.mu.Lock() + defer Manager.mu.Unlock() + + result := &GhOstResult{ + TaskID: taskID, + Command: "gh-ost " + strings.Join(args, " "), + Duration: time.Since(start).String(), + Output: stdout.String(), + Success: err == nil, + } + + if err != nil { + result.Error = stderr.String() + "\n" + err.Error() + Manager.status[taskID] = "failed" + } else { + Manager.status[taskID] = "completed" + } + + Manager.tasks[taskID] = result + + if cb != nil { + cb(taskID, result.Output) + } + }() + + return taskID +} + +// GetTaskStatus returns the status of an async task +func (m *TaskManager) GetTaskStatus(taskID string) (string, *GhOstResult) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.status[taskID], m.tasks[taskID] +} + +// ListTasks returns all tracked tasks +func (m *TaskManager) ListTasks() map[string]string { + m.mu.RLock() + defer m.mu.RUnlock() + result := make(map[string]string) + for k, v := range m.status { + result[k] = v + } + return result +} + +func buildArgs(task GhOstTask, ghOstPath string) []string { + args := []string{ + "--host=" + task.Host, + "--user=" + task.User, + "--password=" + task.Password, + "--database=" + task.Database, + "--table=" + task.Table, + "--alter=" + task.Alter, + "--verbose", + } + + if task.ChunkSize > 0 { + args = append(args, fmt.Sprintf("--chunk-size=%d", task.ChunkSize)) + } + if task.MaxLoad != "" { + args = append(args, "--max-load="+task.MaxLoad) + } + if task.CriticalLoad != "" { + args = append(args, "--critical-load="+task.CriticalLoad) + } + if task.MaxLagMillis > 0 { + args = append(args, fmt.Sprintf("--max-lag-millis=%d", task.MaxLagMillis)) + } + if task.AllowOnMaster { + args = append(args, "--allow-on-master") + } + if task.ExactRowCount { + args = append(args, "--exact-rowcount") + } + if task.PostponeCutOver { + args = append(args, "--postpone-cut-over-flag-file=/tmp/gh-ost-cutover.flag") + } + + if task.DryRun || !task.Execute { + args = append(args, "--dry-run") + } else { + args = append(args, "--execute") + } + + return args +} diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go new file mode 100644 index 0000000..5860575 --- /dev/null +++ b/backend/internal/handler/auth.go @@ -0,0 +1,151 @@ +package handler + +import ( + "net/http" + "time" + + "sql-platform/internal/middleware" + "sql-platform/internal/model" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +// POST /api/v1/auth/login +func Login(c *gin.Context) { + var req model.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "username and password required"}) + return + } + + var user model.User + if err := model.DB.Where("username = ?", req.Username).First(&user).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"}) + return + } + + if user.IsActive != nil && !*user.IsActive { + c.JSON(http.StatusForbidden, gin.H{"detail": "Account is disabled"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"}) + return + } + + token, err := middleware.GenerateToken(&user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"}) + return + } + + now := time.Now() + user.LastLogin = &now + model.DB.Save(&user) + + c.JSON(http.StatusOK, model.LoginResponse{ + AccessToken: token, + TokenType: "bearer", + User: user, + }) +} + +// GET /api/v1/auth/me +func GetMe(c *gin.Context) { + userID, _, _ := middleware.GetCurrentUser(c) + var user model.User + if err := model.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"}) + return + } + c.JSON(http.StatusOK, user) +} + +// GET /api/v1/users +func ListUsers(c *gin.Context) { + var users []model.User + model.DB.Order("id").Find(&users) + c.JSON(http.StatusOK, users) +} + +// POST /api/v1/users +func CreateUser(c *gin.Context) { + var input struct { + Username string `json:"username" binding:"required"` + DisplayName string `json:"display_name" binding:"required"` + Password string `json:"password" binding:"required"` + Email *string `json:"email"` + Phone *string `json:"phone"` + Role string `json:"role" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to hash password"}) + return + } + + isActive := true + user := model.User{ + Username: input.Username, + DisplayName: input.DisplayName, + HashedPassword: string(hashed), + Email: input.Email, + Phone: input.Phone, + Role: input.Role, + IsActive: &isActive, + } + + if err := model.DB.Create(&user).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "Username already exists"}) + return + } + + c.JSON(http.StatusCreated, user) +} + +// PUT /api/v1/users/:id +func UpdateUser(c *gin.Context) { + id := c.Param("id") + var user model.User + if err := model.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"}) + return + } + + var input map[string]interface{} + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + if pw, ok := input["password"].(string); ok && pw != "" { + hashed, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + input["hashed_password"] = string(hashed) + } + delete(input, "password") + + if err := model.DB.Model(&user).Updates(input).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + model.DB.First(&user, id) + c.JSON(http.StatusOK, user) +} + +// DELETE /api/v1/users/:id +func DeleteUser(c *gin.Context) { + id := c.Param("id") + if err := model.DB.Delete(&model.User{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "User deleted"}) +} diff --git a/backend/internal/handler/dashboard.go b/backend/internal/handler/dashboard.go new file mode 100644 index 0000000..e3894e0 --- /dev/null +++ b/backend/internal/handler/dashboard.go @@ -0,0 +1,68 @@ +package handler + +import ( + "net/http" + + "sql-platform/internal/middleware" + "sql-platform/internal/model" + + "github.com/gin-gonic/gin" +) + +// GET /api/v1/dashboard/stats +func DashboardStats(c *gin.Context) { + var ticketCount, queryCount, instanceCount int64 + model.DB.Model(&model.Ticket{}).Count(&ticketCount) + model.DB.Model(&model.QueryHistory{}).Count(&queryCount) + model.DB.Model(&model.Instance{}).Count(&instanceCount) + + c.JSON(http.StatusOK, gin.H{ + "ticket_count": ticketCount, + "query_count": queryCount, + "instance_count": instanceCount, + }) +} + +// GET /api/v1/dashboard/my-tickets +func MyTickets(c *gin.Context) { + userID, _, role := middleware.GetCurrentUser(c) + + var tickets []model.Ticket + query := model.DB.Order("id DESC").Limit(10) + + switch role { + case "admin": + // Admin sees all pending/executing tickets + query = query.Where("status IN ?", []string{"pending", "reviewing", "executing"}) + case "dba", "auditor": + // DBA sees their own + pending approval + query = query.Where( + "(applicant_id = ? AND status IN ?) OR (id IN (SELECT ticket_id FROM ticket_approvals WHERE approver_id = ?) AND status IN ?)", + userID, []string{"pending", "executing"}, + userID, []string{"pending", "reviewing", "executing"}, + ) + default: // developer + query = query.Where("applicant_id = ?", userID) + } + + query.Find(&tickets) + + type ticketWithRelation struct { + model.Ticket + Relation string `json:"relation"` // "submitted" or "pending_approval" + } + + var result []ticketWithRelation + for _, t := range tickets { + rel := "submitted" + if t.ApplicantID != userID { + rel = "pending_approval" + } + result = append(result, ticketWithRelation{Ticket: t, Relation: rel}) + } + + if result == nil { + result = []ticketWithRelation{} + } + c.JSON(http.StatusOK, result) +} diff --git a/backend/internal/handler/ghost.go b/backend/internal/handler/ghost.go new file mode 100644 index 0000000..a62bf64 --- /dev/null +++ b/backend/internal/handler/ghost.go @@ -0,0 +1,132 @@ +package handler + +import ( + "net/http" + "time" + + "sql-platform/config" + "sql-platform/internal/ghost" + "sql-platform/internal/model" + + "github.com/gin-gonic/gin" +) + +// POST /api/v1/ghost/dry-run +func GhOstDryRun(c *gin.Context) { + var task ghost.GhOstTask + if err := c.ShouldBindJSON(&task); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + task.DryRun = true + task.Execute = false + + result, err := ghost.RunGhOst(c.Request.Context(), task, config.C.GhOstPath) + if err != nil { + c.JSON(http.StatusInternalServerError, result) + return + } + c.JSON(http.StatusOK, result) +} + +// POST /api/v1/ghost/execute +func GhOstExecute(c *gin.Context) { + var task ghost.GhOstTask + if err := c.ShouldBindJSON(&task); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + task.DryRun = false + task.Execute = true + + taskID := ghost.RunGhOstAsync(task, config.C.GhOstPath, nil) + c.JSON(http.StatusAccepted, gin.H{ + "task_id": taskID, + "message": "gh-ost execution started", + }) +} + +// GET /api/v1/ghost/status/:taskId +func GhOstStatus(c *gin.Context) { + taskID := c.Param("taskId") + status, result := ghost.Manager.GetTaskStatus(taskID) + if status == "" { + c.JSON(http.StatusNotFound, gin.H{"detail": "Task not found"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "task_id": taskID, + "status": status, + "result": result, + }) +} + +// GET /api/v1/ghost/tasks +func GhOstListTasks(c *gin.Context) { + tasks := ghost.Manager.ListTasks() + c.JSON(http.StatusOK, tasks) +} + +// GET /api/v1/audit-rules +func ListAuditRules(c *gin.Context) { + var rules []model.AuditRule + model.DB.Order("id").Find(&rules) + c.JSON(http.StatusOK, rules) +} + +// GET /api/v1/announcements +func ListAnnouncements(c *gin.Context) { + var announcements []model.Announcement + model.DB.Where("is_published = ?", true).Order("id DESC").Find(&announcements) + c.JSON(http.StatusOK, announcements) +} + +// GET /api/v1/query-histories +func ListQueryHistories(c *gin.Context) { + _, _, _ = c.GetInt("user_id"), c.GetString("username"), c.GetString("role") + + var histories []model.QueryHistory + model.DB.Order("id DESC").Limit(100).Find(&histories) + c.JSON(http.StatusOK, histories) +} + +// GET /api/v1/settings +func ListSettings(c *gin.Context) { + var settings []model.SystemSetting + model.DB.Order("sort_order").Find(&settings) + c.JSON(http.StatusOK, settings) +} + +// GET /api/v1/notifications/channels +func ListNotificationChannels(c *gin.Context) { + var channels []model.NotificationChannel + model.DB.Order("id").Find(&channels) + c.JSON(http.StatusOK, channels) +} + +// GET /api/v1/notifications/templates +func ListNotificationTemplates(c *gin.Context) { + var templates []model.NotificationTemplate + model.DB.Order("id").Find(&templates) + c.JSON(http.StatusOK, templates) +} + +// GET /api/v1/workflows +func ListWorkflows(c *gin.Context) { + var workflows []model.WorkflowDefinition + model.DB.Order("id").Find(&workflows) + c.JSON(http.StatusOK, workflows) +} + +// GET /api/v1/instance-groups +func ListInstanceGroups(c *gin.Context) { + var groups []model.InstanceGroup + model.DB.Order("id").Find(&groups) + c.JSON(http.StatusOK, groups) +} + +// POST /api/v1/notifications/test +func TestNotification(c *gin.Context) { + _ = time.Now() + c.JSON(http.StatusOK, gin.H{"message": "Notification sent (placeholder)"}) +} diff --git a/backend/internal/handler/instance.go b/backend/internal/handler/instance.go new file mode 100644 index 0000000..a8163b4 --- /dev/null +++ b/backend/internal/handler/instance.go @@ -0,0 +1,153 @@ +package handler + +import ( + "net/http" + "strconv" + + "sql-platform/internal/middleware" + "sql-platform/internal/model" + + "github.com/gin-gonic/gin" +) + +// GET /api/v1/instances +func ListInstances(c *gin.Context) { + userID, _, role := middleware.GetCurrentUser(c) + + var instances []model.Instance + query := model.DB.Order("id") + + // developer only sees instances in their groups + if role == "developer" { + var groupIDs []int + model.DB.Model(&model.UserGroup{}).Where("user_id = ?", userID).Pluck("group_id", &groupIDs) + if len(groupIDs) > 0 { + var instanceIDs []int + model.DB.Model(&model.InstanceGroupMember{}).Where("group_id IN ?", groupIDs).Pluck("instance_id", &instanceIDs) + if len(instanceIDs) > 0 { + query = query.Where("id IN ?", instanceIDs) + } else { + c.JSON(http.StatusOK, []model.Instance{}) + return + } + } + } + + if err := query.Find(&instances).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + // mask passwords in response + for i := range instances { + instances[i].Password = "******" + } + c.JSON(http.StatusOK, instances) +} + +// GET /api/v1/instances/:id +func GetInstance(c *gin.Context) { + id := c.Param("id") + var instance model.Instance + if err := model.DB.First(&instance, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + return + } + instance.Password = "******" + c.JSON(http.StatusOK, instance) +} + +// POST /api/v1/instances +func CreateInstance(c *gin.Context) { + var instance model.Instance + if err := c.ShouldBindJSON(&instance); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + if err := model.DB.Create(&instance).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + instance.Password = "******" + c.JSON(http.StatusCreated, instance) +} + +// PUT /api/v1/instances/:id +func UpdateInstance(c *gin.Context) { + id := c.Param("id") + var instance model.Instance + if err := model.DB.First(&instance, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + return + } + + var input map[string]interface{} + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + // if password is masked, don't update it + if pw, ok := input["password"].(string); ok && pw == "******" { + delete(input, "password") + } + + if err := model.DB.Model(&instance).Updates(input).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + model.DB.First(&instance, id) + instance.Password = "******" + c.JSON(http.StatusOK, instance) +} + +// DELETE /api/v1/instances/:id +func DeleteInstance(c *gin.Context) { + id := c.Param("id") + if err := model.DB.Delete(&model.Instance{}, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Instance deleted"}) +} + +// POST /api/v1/instances/:id/test-connection +func TestConnection(c *gin.Context) { + id := c.Param("id") + var instance model.Instance + if err := model.DB.First(&instance, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "Instance not found"}) + return + } + + // TODO: actual DB connection test + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Connection test passed (placeholder)", + }) +} + +// GET /api/v1/instances/stats +func InstanceStats(c *gin.Context) { + var total int64 + model.DB.Model(&model.Instance{}).Count(&total) + + type typeCount struct { + DBType string `json:"db_type"` + Count int64 `json:"count"` + } + var byType []typeCount + model.DB.Model(&model.Instance{}).Select("db_type, count(*) as count").Group("db_type").Scan(&byType) + + c.JSON(http.StatusOK, gin.H{ + "total": total, + "by_type": byType, + }) +} + +// Helper: parse id param to int +func parseID(s string) (int, bool) { + id, err := strconv.Atoi(s) + return id, err == nil +} diff --git a/backend/internal/handler/ticket.go b/backend/internal/handler/ticket.go new file mode 100644 index 0000000..436e34d --- /dev/null +++ b/backend/internal/handler/ticket.go @@ -0,0 +1,266 @@ +package handler + +import ( + "fmt" + "net/http" + "time" + + "sql-platform/internal/middleware" + "sql-platform/internal/model" + + "github.com/gin-gonic/gin" +) + +// GET /api/v1/tickets/statistics +func TicketStatistics(c *gin.Context) { + userID, _, role := middleware.GetCurrentUser(c) + + type statusCount struct { + Status string `json:"status"` + Count int64 `json:"count"` + } + var counts []statusCount + + query := model.DB.Model(&model.Ticket{}).Select("status, count(*) as count") + + // Filter by user role + switch role { + case "developer": + query = query.Where("applicant_id = ?", userID) + case "dba", "auditor": + // DBA sees tickets they submitted + tickets they need to approve + query = query.Where( + "applicant_id = ? OR id IN (SELECT ticket_id FROM ticket_approvals WHERE approver_id = ?)", + userID, userID, + ) + // admin sees everything + } + + query.Group("status").Scan(&counts) + + stats := model.TicketStatistics{} + for _, c := range counts { + switch c.Status { + case "pending": + stats.Pending = int(c.Count) + case "reviewing": + stats.Reviewing = int(c.Count) + case "executing": + stats.Executing = int(c.Count) + case "completed": + stats.Completed = int(c.Count) + case "failed": + stats.Failed = int(c.Count) + case "rejected": + stats.Rejected = int(c.Count) + } + } + stats.Total = stats.Pending + stats.Reviewing + stats.Executing + stats.Completed + stats.Failed + stats.Rejected + + c.JSON(http.StatusOK, stats) +} + +// GET /api/v1/tickets +func ListTickets(c *gin.Context) { + userID, _, role := middleware.GetCurrentUser(c) + status := c.Query("status") + page := 1 + limit := 20 + + query := model.DB.Model(&model.Ticket{}).Order("id DESC") + + // Filter by user role + switch role { + case "developer": + query = query.Where("applicant_id = ?", userID) + case "dba", "auditor": + query = query.Where( + "applicant_id = ? OR id IN (SELECT ticket_id FROM ticket_approvals WHERE approver_id = ?)", + userID, userID, + ) + } + + if status != "" && status != "all" { + query = query.Where("status = ?", status) + } + + var total int64 + query.Count(&total) + + var tickets []model.Ticket + query.Offset((page - 1) * limit).Limit(limit).Find(&tickets) + + c.JSON(http.StatusOK, gin.H{ + "items": tickets, + "total": total, + "page": page, + "limit": limit, + }) +} + +// GET /api/v1/tickets/:id +func GetTicket(c *gin.Context) { + id := c.Param("id") + var ticket model.Ticket + if err := model.DB.First(&ticket, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"}) + return + } + + // Get approvals + var approvals []model.TicketApproval + model.DB.Where("ticket_id = ?", id).Order("step_number").Find(&approvals) + + // Get SQL items + var sqlItems []model.TicketSQLItem + model.DB.Where("ticket_id = ?", id).Order("seq").Find(&sqlItems) + + c.JSON(http.StatusOK, gin.H{ + "ticket": ticket, + "approvals": approvals, + "sql_items": sqlItems, + }) +} + +// POST /api/v1/tickets +func CreateTicket(c *gin.Context) { + userID, _, _ := middleware.GetCurrentUser(c) + + var input struct { + Title string `json:"title" binding:"required"` + DBType string `json:"db_type" binding:"required"` + InstanceID int `json:"instance_id" binding:"required"` + TargetDatabase string `json:"target_database" binding:"required"` + SQLContent string `json:"sql_content" binding:"required"` + RollbackSQL *string `json:"rollback_sql"` + Priority *string `json:"priority"` + Reason *string `json:"reason"` + WorkflowID *int `json:"workflow_id"` + RiskLevel *string `json:"risk_level"` + ExecStrategy *string `json:"exec_strategy"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + status := "pending" + priority := "medium" + riskLevel := "low" + execStrategy := "stop_on_error" + + if input.Priority != nil { + priority = *input.Priority + } + if input.RiskLevel != nil { + riskLevel = *input.RiskLevel + } + if input.ExecStrategy != nil { + execStrategy = *input.ExecStrategy + } + + // Generate ticket number + now := time.Now() + ticketNo := now.Format("20060102") + "-" + fmt.Sprintf("%04d", time.Now().UnixNano()%10000) + + ticket := model.Ticket{ + Title: input.Title, + DBType: input.DBType, + InstanceID: input.InstanceID, + TargetDatabase: input.TargetDatabase, + SQLContent: input.SQLContent, + RollbackSQL: input.RollbackSQL, + Status: &status, + Priority: &priority, + ApplicantID: userID, + Reason: input.Reason, + WorkflowID: input.WorkflowID, + RiskLevel: &riskLevel, + ExecStrategy: &execStrategy, + TicketNo: &ticketNo, + } + + if err := model.DB.Create(&ticket).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + c.JSON(http.StatusCreated, ticket) +} + +// POST /api/v1/tickets/:id/approve +func ApproveTicket(c *gin.Context) { + id := c.Param("id") + userID, _, _ := middleware.GetCurrentUser(c) + + var ticket model.Ticket + if err := model.DB.First(&ticket, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"}) + return + } + + var input struct { + Action string `json:"action" binding:"required"` // approve, reject + Comment *string `json:"comment"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + now := time.Now() + + // Update approval record + var approval model.TicketApproval + if err := model.DB.Where("ticket_id = ? AND approver_id = ? AND action IS NULL", id, userID).First(&approval).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "No pending approval for you"}) + return + } + + approval.Action = &input.Action + approval.Comment = input.Comment + approval.ActualApproverID = &userID + approval.CompletedAt = &now + model.DB.Save(&approval) + + // Update ticket status + newStatus := "approved" + if input.Action == "reject" { + newStatus = "rejected" + } else if input.Action == "approve" { + // Check if there are more pending approvals + var pendingCount int64 + model.DB.Model(&model.TicketApproval{}).Where("ticket_id = ? AND action IS NULL", id).Count(&pendingCount) + if pendingCount > 0 { + newStatus = "reviewing" + } + } + + ticket.Status = &newStatus + ticket.ReviewTime = &now + model.DB.Save(&ticket) + + c.JSON(http.StatusOK, gin.H{"ticket": ticket, "approval": approval}) +} + +// POST /api/v1/tickets/:id/execute +func ExecuteTicket(c *gin.Context) { + id := c.Param("id") + + var ticket model.Ticket + if err := model.DB.First(&ticket, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "Ticket not found"}) + return + } + + status := "executing" + ticket.Status = &status + now := time.Now() + ticket.ExecuteTime = &now + model.DB.Save(&ticket) + + // TODO: actual SQL execution + + c.JSON(http.StatusOK, gin.H{"message": "Execution started", "ticket_id": ticket.ID}) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..28019ce --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,89 @@ +package middleware + +import ( + "net/http" + "strings" + "time" + + "sql-platform/config" + "sql-platform/internal/model" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID int `json:"user_id"` + Username string `json:"username"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func GenerateToken(user *model.User) (string, error) { + claims := Claims{ + UserID: user.ID, + Username: user.Username, + Role: user.Role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(config.C.JWTExpire) * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(config.C.JWTSecret)) +} + +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Not authenticated"}) + c.Abort() + return + } + + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + if tokenStr == authHeader { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid token format"}) + c.Abort() + return + } + + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (interface{}, error) { + return []byte(config.C.JWTSecret), nil + }) + + if err != nil || !token.Valid { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid or expired token"}) + c.Abort() + return + } + + c.Set("user_id", claims.UserID) + c.Set("username", claims.Username) + c.Set("role", claims.Role) + c.Next() + } +} + +func GetCurrentUser(c *gin.Context) (int, string, string) { + return c.GetInt("user_id"), c.GetString("username"), c.GetString("role") +} + +// RBAC middleware — allow specified roles +func RequireRole(roles ...string) gin.HandlerFunc { + roleSet := make(map[string]bool) + for _, r := range roles { + roleSet[r] = true + } + return func(c *gin.Context) { + role := c.GetString("role") + if !roleSet[role] { + c.JSON(http.StatusForbidden, gin.H{"detail": "Permission denied"}) + c.Abort() + return + } + c.Next() + } +} diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go new file mode 100644 index 0000000..7adc505 --- /dev/null +++ b/backend/internal/middleware/cors.go @@ -0,0 +1,16 @@ +package middleware + +import ( + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func CORSMiddleware() gin.HandlerFunc { + return cors.New(cors.Config{ + AllowOrigins: []string{"http://localhost:5173", "http://127.0.0.1:5173"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + }) +} diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go new file mode 100644 index 0000000..1ab3873 --- /dev/null +++ b/backend/internal/model/model.go @@ -0,0 +1,516 @@ +package model + +import ( + "database/sql" + "time" + + "gorm.io/gorm" +) + +// ==================== User ==================== + +type User struct { + ID int `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:50;uniqueIndex;not null" json:"username"` + DisplayName string `gorm:"size:100;not null" json:"display_name"` + Email *string `gorm:"size:100" json:"email"` + Phone *string `gorm:"size:20" json:"phone"` + HashedPassword string `gorm:"size:200;not null" json:"-"` + Role string `gorm:"size:20;not null" json:"role"` // admin, dba, developer, auditor + IsActive *bool `gorm:"default:true" json:"is_active"` + LastLogin *time.Time `json:"last_login"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +func (User) TableName() string { return "users" } + +// ==================== Instance ==================== + +type Instance struct { + ID int `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + DBType string `gorm:"size:20;not null" json:"db_type"` + Host string `gorm:"size:200;not null" json:"host"` + Port int `gorm:"not null" json:"port"` + Username string `gorm:"size:100;not null" json:"username"` + Password string `gorm:"size:500;not null" json:"password,omitempty"` + Database *string `gorm:"size:100" json:"database"` + Env *string `gorm:"size:20" json:"env"` + Description *string `gorm:"type:text" json:"description"` + Status *string `gorm:"size:20" json:"status"` + Version *string `gorm:"size:50" json:"version"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` + AccessMode *string `gorm:"size:20;default:readwrite" json:"access_mode"` + Workflow *string `gorm:"type:text" json:"workflow"` + ExcludedDatabases *string `gorm:"type:text" json:"excluded_databases"` + MaskFields *string `gorm:"type:text" json:"mask_fields"` + GroupID *int `json:"group_id"` + WorkflowID *int `json:"workflow_id"` +} + +func (Instance) TableName() string { return "instances" } + +// ==================== Ticket ==================== + +type Ticket struct { + ID int `gorm:"primaryKey" json:"id"` + Title string `gorm:"size:200;not null" json:"title"` + DBType string `gorm:"size:20;not null" json:"db_type"` + InstanceID int `gorm:"not null" json:"instance_id"` + TargetDatabase string `gorm:"size:100;not null" json:"target_database"` + SQLContent string `gorm:"type:text;not null" json:"sql_content"` + RollbackSQL *string `gorm:"type:text" json:"rollback_sql"` + Status *string `gorm:"size:20" json:"status"` + Priority *string `gorm:"size:10" json:"priority"` + ApplicantID int `gorm:"not null" json:"applicant_id"` + ApproverID *int `json:"approver_id"` + Reason *string `gorm:"type:text" json:"reason"` + AuditResult *string `gorm:"type:text" json:"audit_result"` + ExecuteResult *string `gorm:"type:text" json:"execute_result"` + CreateTime time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"create_time"` + ReviewTime *time.Time `json:"review_time"` + ExecuteTime *time.Time `json:"execute_time"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` + TicketNo *string `gorm:"size:20" json:"ticket_no"` + ScheduledAt sql.NullTime `json:"scheduled_at"` + WorkflowID *int `json:"workflow_id"` + RiskLevel *string `gorm:"size:20;default:low" json:"risk_level"` + ExecStrategy *string `gorm:"size:20;default:stop_on_error" json:"exec_strategy"` +} + +func (Ticket) TableName() string { return "tickets" } + +// ==================== TicketApproval ==================== + +type TicketApproval struct { + ID int `gorm:"primaryKey" json:"id"` + TicketID int `gorm:"not null" json:"ticket_id"` + WorkflowID *int `json:"workflow_id"` + VersionID *int `json:"version_id"` + StepNumber int `gorm:"not null" json:"step_number"` + StepName *string `gorm:"size:100" json:"step_name"` + StepType *string `gorm:"size:20" json:"step_type"` + ApproverID *int `json:"approver_id"` + ApproverRole *string `gorm:"size:20" json:"approver_role"` + ActualApproverID *int `json:"actual_approver_id"` + Action *string `gorm:"size:20" json:"action"` + Comment *string `gorm:"type:text" json:"comment"` + TransferFrom *int `json:"transfer_from"` + TransferReason *string `gorm:"type:text" json:"transfer_reason"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` + CompletedAt *time.Time `json:"completed_at"` + IsTimeout *bool `json:"is_timeout"` + TimeoutHandledAt *time.Time `json:"timeout_handled_at"` +} + +func (TicketApproval) TableName() string { return "ticket_approvals" } + +// ==================== TicketSQLItem ==================== + +type TicketSQLItem struct { + ID int `gorm:"primaryKey" json:"id"` + TicketID int `gorm:"not null" json:"ticket_id"` + Seq int `gorm:"not null" json:"seq"` + SQLText string `gorm:"type:text;not null" json:"sql_text"` + SQLType *string `gorm:"size:20" json:"sql_type"` + Status *string `gorm:"size:20;default:pending" json:"status"` + AffectedRows *int `gorm:"default:0" json:"affected_rows"` + DurationMs *int `gorm:"default:0" json:"duration_ms"` + ErrorMessage *string `gorm:"type:text" json:"error_message"` + StartedAt *time.Time `json:"started_at"` + FinishedAt *time.Time `json:"finished_at"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` +} + +func (TicketSQLItem) TableName() string { return "ticket_sql_items" } + +// ==================== AuditRule ==================== + +type AuditRule struct { + ID int `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + Desc *string `gorm:"type:text" json:"description"` + RuleType string `gorm:"size:50;not null" json:"rule_type"` + Severity *string `gorm:"size:20" json:"severity"` + DBType *string `gorm:"size:20" json:"db_type"` + Pattern *string `gorm:"type:text" json:"pattern"` + IsEnabled *bool `json:"is_enabled"` + Message string `gorm:"size:500;not null" json:"message"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` + Value *string `gorm:"size:255" json:"value"` + Code *string `gorm:"size:50" json:"code"` + ConfigJSON *string `gorm:"type:text;column:config_json" json:"config_json"` +} + +func (AuditRule) TableName() string { return "audit_rules" } + +// ==================== AuditLog ==================== + +type AuditLog struct { + ID int `gorm:"primaryKey" json:"id"` + UserID int `gorm:"not null" json:"user_id"` + Action string `gorm:"size:50;not null" json:"action"` + TargetType string `gorm:"size:50;not null" json:"target_type"` + TargetID *int `json:"target_id"` + Detail *string `gorm:"type:text" json:"detail"` + IPAddress *string `gorm:"size:50" json:"ip_address"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + Username *string `gorm:"size:50" json:"username"` + DBType *string `gorm:"size:20" json:"db_type"` + DatabaseName *string `gorm:"size:100" json:"database_name"` + InstanceName *string `gorm:"size:100" json:"instance_name"` + ExecutionTimeMs *int `json:"execution_time_ms"` + RowsAffected *int `json:"rows_affected"` + ResultStatus *string `gorm:"size:20" json:"result_status"` + QueryContent *string `gorm:"type:text" json:"query_content"` + UserAgent *string `gorm:"size:200" json:"user_agent"` + IsRisky *bool `gorm:"default:false" json:"is_risky"` +} + +func (AuditLog) TableName() string { return "audit_logs" } + +// ==================== QueryHistory ==================== + +type QueryHistory struct { + ID int `gorm:"primaryKey" json:"id"` + UserID int `gorm:"not null" json:"user_id"` + Username string `gorm:"size:100;not null" json:"username"` + InstanceID int `gorm:"not null" json:"instance_id"` + InstanceName string `gorm:"size:100;not null" json:"instance_name"` + DBType string `gorm:"size:20;not null" json:"db_type"` + DatabaseName string `gorm:"size:100;not null" json:"database_name"` + QueryContent string `gorm:"type:text;not null" json:"query_content"` + QueryType string `gorm:"size:50;not null" json:"query_type"` + ExecutionTimeMs int `gorm:"not null" json:"execution_time_ms"` + ResultRows int `gorm:"not null" json:"result_rows"` + IsSuccess bool `gorm:"not null" json:"is_success"` + ErrorMessage *string `gorm:"type:text" json:"error_message"` + IsFavorite bool `gorm:"not null" json:"is_favorite"` + Note string `gorm:"type:text;not null" json:"note"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` +} + +func (QueryHistory) TableName() string { return "query_histories" } + +// ==================== Workflow ==================== + +type WorkflowDefinition struct { + ID int `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + Description *string `gorm:"type:text" json:"description"` + Category *string `gorm:"size:50" json:"category"` + IsDefault *bool `json:"is_default"` + IsActive *bool `json:"is_active"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` + CreatedBy *int `json:"created_by"` + FlowConfig *string `gorm:"type:json" json:"flow_config"` + TriggerConditions *string `gorm:"type:json" json:"trigger_conditions"` +} + +func (WorkflowDefinition) TableName() string { return "workflow_definitions" } + +type WorkflowStep struct { + ID int `gorm:"primaryKey" json:"id"` + WorkflowID int `gorm:"not null" json:"workflow_id"` + StepNumber int `gorm:"not null" json:"step_number"` + Name string `gorm:"size:100;not null" json:"name"` + Description *string `gorm:"type:text" json:"description"` + StepType string `gorm:"size:20;not null" json:"step_type"` + ApproverRole *string `gorm:"size:20" json:"approver_role"` + ApproverIDs *string `gorm:"type:json" json:"approver_ids"` + ApproverAssignment *string `gorm:"type:json" json:"approver_assignment"` + IsRequired *bool `json:"is_required"` + TimeoutHours *int `json:"timeout_hours"` + AutoApproveOnTimeout *bool `json:"auto_approve_on_timeout"` + EntryConditions *string `gorm:"type:json" json:"entry_conditions"` + TransitionConditions *string `gorm:"type:json" json:"transition_conditions"` + FieldPermissions *string `gorm:"type:json" json:"field_permissions"` +} + +func (WorkflowStep) TableName() string { return "workflow_steps" } + +type WorkflowVersion struct { + ID int `gorm:"primaryKey" json:"id"` + WorkflowID int `gorm:"not null" json:"workflow_id"` + VersionNumber int `gorm:"not null" json:"version_number"` + VersionName *string `gorm:"size:100" json:"version_name"` + IsActive *bool `json:"is_active"` + StepsSnapshot *string `gorm:"type:json" json:"steps_snapshot"` + FlowConfigSnapshot *string `gorm:"type:json" json:"flow_config_snapshot"` + ChangeSummary *string `gorm:"type:text" json:"change_summary"` + CreatedBy *int `json:"created_by"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` +} + +func (WorkflowVersion) TableName() string { return "workflow_versions" } + +type WorkflowExecutionLog struct { + ID int `gorm:"primaryKey" json:"id"` + WorkflowID int `gorm:"not null" json:"workflow_id"` + TicketID *int `json:"ticket_id"` + StepID *int `json:"step_id"` + ApprovalID *int `json:"approval_id"` + EventType string `gorm:"size:50;not null" json:"event_type"` + EventDescription *string `gorm:"type:text" json:"event_description"` + ActorID *int `json:"actor_id"` + ActorName *string `gorm:"size:100" json:"actor_name"` + ContextData *string `gorm:"type:json" json:"context_data"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` +} + +func (WorkflowExecutionLog) TableName() string { return "workflow_execution_logs" } + +// ==================== Announcement ==================== + +type Announcement struct { + ID int `gorm:"primaryKey" json:"id"` + Title string `gorm:"size:200;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + IsPublished *bool `json:"is_published"` + PublishedAt *time.Time `json:"published_at"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +func (Announcement) TableName() string { return "announcements" } + +// ==================== InstanceGroup ==================== + +type InstanceGroup struct { + ID int `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + Description *string `gorm:"type:text" json:"description"` + DBType string `gorm:"size:20;not null" json:"db_type"` + Permissions *string `gorm:"type:text" json:"permissions"` + IsActive *bool `json:"is_active"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` + WorkflowID *int `json:"workflow_id"` + AccessType *string `gorm:"size:20;default:readonly" json:"access_type"` + AllowedDatabases *string `gorm:"type:text" json:"allowed_databases"` +} + +func (InstanceGroup) TableName() string { return "instance_groups" } + +type InstanceGroupMember struct { + ID int `gorm:"primaryKey" json:"id"` + GroupID int `gorm:"not null" json:"group_id"` + InstanceID int `gorm:"not null" json:"instance_id"` + AccessType string `gorm:"default:readonly" json:"access_type"` +} + +func (InstanceGroupMember) TableName() string { return "instance_group_members" } + +// ==================== LDAP Config ==================== + +type LDAPConfig struct { + ID int `gorm:"primaryKey" json:"id"` + LDAPHost string `gorm:"size:200;not null" json:"ldap_host"` + LDAPPort *int `json:"ldap_port"` + UseLDAPS *bool `json:"use_ldaps"` + AdminDN string `gorm:"size:500;not null" json:"admin_dn"` + AdminPassword string `gorm:"size:500;not null" json:"admin_password"` + SearchFilter *string `gorm:"size:500" json:"search_filter"` + BaseDN string `gorm:"size:500;not null" json:"base_dn"` + UserMapping *string `gorm:"type:text" json:"user_mapping"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +func (LDAPConfig) TableName() string { return "ldap_config" } + +// ==================== Notification ==================== + +type NotificationChannel struct { + ID int `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + ChannelType string `gorm:"size:30;not null" json:"channel_type"` + Config string `gorm:"type:text;not null" json:"config"` + IsEnabled *bool `json:"is_enabled"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +func (NotificationChannel) TableName() string { return "notification_channels" } + +type NotificationTemplate struct { + ID int `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + EventType string `gorm:"size:50;not null" json:"event_type"` + ChannelType string `gorm:"size:30;not null" json:"channel_type"` + TitleTemplate *string `gorm:"type:text" json:"title_template"` + BodyTemplate *string `gorm:"type:text" json:"body_template"` + IsEnabled *bool `json:"is_enabled"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` +} + +func (NotificationTemplate) TableName() string { return "notification_templates" } + +// ==================== Other Models ==================== + +type SystemSetting struct { + ID int `gorm:"primaryKey" json:"id"` + Key string `gorm:"size:100;not null" json:"key"` + Value string `gorm:"type:text;not null" json:"value"` + ValueType string `gorm:"size:20;not null" json:"value_type"` + Group string `gorm:"size:50;not null" json:"group"` + Label string `gorm:"size:100;not null" json:"label"` + Desc *string `gorm:"type:text" json:"description"` + DefaultValue *string `gorm:"type:text" json:"default_value"` + SortOrder int `gorm:"not null" json:"sort_order"` + UpdatedAt *time.Time `json:"updated_at"` +} + +func (SystemSetting) TableName() string { return "system_settings" } + +type OperationLog struct { + ID int `gorm:"primaryKey" json:"id"` + UserID *int `json:"user_id"` + Username *string `gorm:"size:50" json:"username"` + Action string `gorm:"size:50;not null" json:"action"` + ResourceType *string `gorm:"size:50" json:"resource_type"` + ResourceID *int `json:"resource_id"` + Detail *string `gorm:"type:text" json:"detail"` + IPAddress *string `gorm:"size:50" json:"ip_address"` + UserAgent *string `gorm:"size:255" json:"user_agent"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` +} + +func (OperationLog) TableName() string { return "operation_logs" } + +type MaskingRule struct { + ID int `gorm:"primaryKey" json:"id"` + InstanceID int `gorm:"not null" json:"instance_id"` + Database string `gorm:"size:100;not null" json:"database"` + TblName string `gorm:"size:100;not null;column:table_name" json:"table_name"` + ColumnName string `gorm:"size:100;not null" json:"column_name"` + MaskType string `gorm:"size:30;not null" json:"mask_type"` + MaskConfig *string `gorm:"type:text" json:"mask_config"` + IsEnabled *bool `json:"is_enabled"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +func (MaskingRule) TableName() string { return "masking_rules" } + +type Backup struct { + ID int `gorm:"primaryKey" json:"id"` + InstanceID int `gorm:"not null" json:"instance_id"` + Title string `gorm:"size:200;not null" json:"title"` + Database string `gorm:"size:100;not null" json:"database"` + Filename string `gorm:"size:500;not null" json:"filename"` + SizeBytes *int64 `json:"size_bytes"` + Status *string `gorm:"size:20" json:"status"` + CreatedBy *int `json:"created_by"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + CompletedAt *time.Time `json:"completed_at"` + Remark *string `gorm:"type:text" json:"remark"` +} + +func (Backup) TableName() string { return "backups" } + +type SchemaRevision struct { + ID int `gorm:"primaryKey" json:"id"` + InstanceID int `gorm:"not null" json:"instance_id"` + Database string `gorm:"size:100;not null" json:"database"` + Revision string `gorm:"size:64;not null" json:"revision"` + DDLContent *string `gorm:"type:text" json:"ddl_content"` + Checksum string `gorm:"size:64;not null" json:"checksum"` + AppliedBy *int `json:"applied_by"` + AppliedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"applied_at"` + Status *string `gorm:"size:20" json:"status"` +} + +func (SchemaRevision) TableName() string { return "schema_revisions" } + +type SlowLog struct { + ID int `gorm:"primaryKey" json:"id"` + InstanceID int `gorm:"not null" json:"instance_id"` + Database *string `gorm:"size:100" json:"database"` + SQLDigest *string `gorm:"size:64" json:"sql_digest"` + SQLText *string `gorm:"type:text" json:"sql_text"` + ExecutionCount *int `json:"execution_count"` + AvgTimeMs *int `json:"avg_time_ms"` + MaxTimeMs *int `json:"max_time_ms"` + RowsExamined *int `json:"rows_examined"` + RowsSent *int `json:"rows_sent"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` +} + +func (SlowLog) TableName() string { return "slow_logs" } + +type ScheduledTask struct { + ID int `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + Description *string `gorm:"type:text" json:"description"` + TaskSQLType string `gorm:"size:20;not null" json:"task_sql_type"` + InstanceID int `gorm:"not null" json:"instance_id"` + TargetDB *string `gorm:"size:100" json:"target_db"` + TargetTable *string `gorm:"size:100" json:"target_table"` + SQLContent string `gorm:"type:text;not null" json:"sql_content"` + CronExpression string `gorm:"size:50;not null" json:"cron_expression"` + IsEnabled *bool `json:"is_enabled"` + LastRunAt *time.Time `json:"last_run_at"` + LastRunStatus *string `gorm:"size:20" json:"last_run_status"` + LastRunMessage *string `gorm:"type:text" json:"last_run_message"` + NextRunAt *time.Time `json:"next_run_at"` + RunCount *int `json:"run_count"` + CreatedBy *int `json:"created_by"` + CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` +} + +func (ScheduledTask) TableName() string { return "scheduled_tasks" } + +type ScheduledTaskLog struct { + ID int `gorm:"primaryKey" json:"id"` + TaskID int `gorm:"not null" json:"task_id"` + Status string `gorm:"size:20;not null" json:"status"` + SQLContent *string `gorm:"type:text" json:"sql_content"` + AffectedRows *int `json:"affected_rows"` + DurationMs *int `json:"duration_ms"` + ErrorMessage *string `gorm:"type:text" json:"error_message"` + StartedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"started_at"` + FinishedAt *time.Time `json:"finished_at"` +} + +func (ScheduledTaskLog) TableName() string { return "scheduled_task_logs" } + +type UserGroup struct { + UserID int `gorm:"primaryKey" json:"user_id"` + GroupID int `gorm:"primaryKey" json:"group_id"` + Permissions *string `gorm:"type:text" json:"permissions"` +} + +func (UserGroup) TableName() string { return "user_groups" } + +// ==================== DTO ==================== + +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +type LoginResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + User User `json:"user"` +} + +type TicketStatistics struct { + Total int `json:"total"` + Pending int `json:"pending"` + Reviewing int `json:"reviewing"` + Executing int `json:"executing"` + Completed int `json:"completed"` + Failed int `json:"failed"` + Rejected int `json:"rejected"` +} + +// DB is the global database instance +var DB *gorm.DB diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go new file mode 100644 index 0000000..f0a3122 --- /dev/null +++ b/backend/internal/service/auth_service.go @@ -0,0 +1,145 @@ +package service + +import ( + "errors" + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "sql-platform/internal/middleware" + "sql-platform/internal/model" +) + +type AuthService struct { + DB *gorm.DB +} + +func NewAuthService(db *gorm.DB) *AuthService { + return &AuthService{DB: db} +} + +// Login authenticates a user and returns a JWT token +func (s *AuthService) Login(username, password string) (*model.User, string, error) { + var user model.User + if err := s.DB.Where("username = ?", username).First(&user).Error; err != nil { + return nil, "", errors.New("invalid username or password") + } + + if !user.IsActive { + return nil, "", errors.New("account is disabled") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(password)); err != nil { + return nil, "", errors.New("invalid username or password") + } + + token, err := middleware.GenerateToken(user.ID, user.Username, user.Role) + if err != nil { + return nil, "", errors.New("failed to generate token") + } + + // Update last login + now := time.Now() + user.LastLogin = &now + s.DB.Model(&user).Update("last_login", now) + + return &user, token, nil +} + +// Register creates a new user +func (s *AuthService) Register(username, displayName, email, phone, password, role string) (*model.User, error) { + // Check if username already exists + var count int64 + s.DB.Model(&model.User{}).Where("username = ?", username).Count(&count) + if count > 0 { + return nil, errors.New("username already exists") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, errors.New("failed to hash password") + } + + if role == "" { + role = "developer" + } + + user := model.User{ + Username: username, + DisplayName: displayName, + Email: email, + Phone: phone, + HashedPassword: string(hashedPassword), + Role: role, + IsActive: true, + } + + if err := s.DB.Create(&user).Error; err != nil { + return nil, errors.New("failed to create user") + } + + return &user, nil +} + +// GetUserByID retrieves a user by ID +func (s *AuthService) GetUserByID(id uint) (*model.User, error) { + var user model.User + if err := s.DB.First(&user, id).Error; err != nil { + return nil, errors.New("user not found") + } + return &user, nil +} + +// ListUsers returns all users +func (s *AuthService) ListUsers(page, pageSize int) ([]model.User, int64, error) { + var users []model.User + var total int64 + + db := s.DB.Model(&model.User{}) + db.Count(&total) + + offset := (page - 1) * pageSize + if err := db.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil { + return nil, 0, err + } + + return users, total, nil +} + +// UpdateUser updates a user's info +func (s *AuthService) UpdateUser(id uint, updates map[string]interface{}) error { + return s.DB.Model(&model.User{}).Where("id = ?", id).Updates(updates).Error +} + +// DeleteUser soft-deletes a user +func (s *AuthService) DeleteUser(id uint) error { + return s.DB.Delete(&model.User{}, id).Error +} + +// ChangePassword changes a user's password +func (s *AuthService) ChangePassword(id uint, oldPassword, newPassword string) error { + var user model.User + if err := s.DB.First(&user, id).Error; err != nil { + return errors.New("user not found") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.HashedPassword), []byte(oldPassword)); err != nil { + return errors.New("incorrect current password") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return errors.New("failed to hash password") + } + + return s.DB.Model(&user).Update("hashed_password", string(hashedPassword)).Error +} + +// ResetPassword resets a user's password (admin only) +func (s *AuthService) ResetPassword(id uint, newPassword string) error { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return errors.New("failed to hash password") + } + return s.DB.Model(&model.User{}).Where("id = ?", id).Update("hashed_password", string(hashedPassword)).Error +} diff --git a/backend/internal/service/instance_service.go b/backend/internal/service/instance_service.go new file mode 100644 index 0000000..18ac4a1 --- /dev/null +++ b/backend/internal/service/instance_service.go @@ -0,0 +1,93 @@ +package service + +import ( + "errors" + + "gorm.io/gorm" + "sql-platform/internal/model" +) + +type InstanceService struct { + DB *gorm.DB +} + +func NewInstanceService(db *gorm.DB) *InstanceService { + return &InstanceService{DB: db} +} + +// CreateInstance creates a new database instance +func (s *InstanceService) CreateInstance(instance *model.Instance) error { + return s.DB.Create(instance).Error +} + +// GetInstanceByID retrieves an instance by ID +func (s *InstanceService) GetInstanceByID(id uint) (*model.Instance, error) { + var instance model.Instance + if err := s.DB.First(&instance, id).Error; err != nil { + return nil, errors.New("instance not found") + } + return &instance, nil +} + +// ListInstances returns paginated instances +func (s *InstanceService) ListInstances(page, pageSize int, dbType, env string) ([]model.Instance, int64, error) { + var instances []model.Instance + var total int64 + + db := s.DB.Model(&model.Instance{}) + if dbType != "" { + db = db.Where("db_type = ?", dbType) + } + if env != "" { + db = db.Where("env = ?", env) + } + + db.Count(&total) + offset := (page - 1) * pageSize + if err := db.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&instances).Error; err != nil { + return nil, 0, err + } + + return instances, total, nil +} + +// UpdateInstance updates an instance +func (s *InstanceService) UpdateInstance(id uint, updates map[string]interface{}) error { + result := s.DB.Model(&model.Instance{}).Where("id = ?", id).Updates(updates) + if result.RowsAffected == 0 { + return errors.New("instance not found") + } + return result.Error +} + +// DeleteInstance soft-deletes an instance +func (s *InstanceService) DeleteInstance(id uint) error { + return s.DB.Delete(&model.Instance{}, id).Error +} + +// TestConnection tests the database connection +func (s *InstanceService) TestConnection(instance *model.Instance) error { + // TODO: Implement actual connection test based on db_type + return nil +} + +// ListInstancesByIDs returns instances matching the given IDs +func (s *InstanceService) ListInstancesByIDs(ids []uint) ([]model.Instance, error) { + var instances []model.Instance + if err := s.DB.Where("id IN ?", ids).Find(&instances).Error; err != nil { + return nil, err + } + return instances, nil +} + +// GetInstanceGroups returns all instance groups +func (s *InstanceService) GetInstanceGroups() ([]model.InstanceGroup, error) { + var groups []model.InstanceGroup + err := s.DB.Find(&groups).Error + return groups, err +} + +// CreateInstanceGroup creates a new instance group +func (s *InstanceService) CreateInstanceGroup(group *model.InstanceGroup) error { + return s.DB.Create(group).Error +} diff --git a/backend/internal/service/ticket_service.go b/backend/internal/service/ticket_service.go new file mode 100644 index 0000000..10b2523 --- /dev/null +++ b/backend/internal/service/ticket_service.go @@ -0,0 +1,129 @@ +package service + +import ( + "errors" + "fmt" + "time" + + "gorm.io/gorm" + "sql-platform/internal/model" +) + +type TicketService struct { + DB *gorm.DB +} + +func NewTicketService(db *gorm.DB) *TicketService { + return &TicketService{DB: db} +} + +// CreateTicket creates a new ticket +func (s *TicketService) CreateTicket(ticket *model.Ticket) error { + if ticket.TicketNo == "" { + ticket.TicketNo = s.generateTicketNo() + } + if ticket.CreateTime.IsZero() { + ticket.CreateTime = time.Now() + } + if ticket.Status == "" { + ticket.Status = "pending" + } + return s.DB.Create(ticket).Error +} + +// GetTicketByID retrieves a ticket by ID +func (s *TicketService) GetTicketByID(id uint) (*model.Ticket, error) { + var ticket model.Ticket + if err := s.DB.First(&ticket, id).Error; err != nil { + return nil, errors.New("ticket not found") + } + return &ticket, nil +} + +// ListTickets returns paginated tickets with filters +func (s *TicketService) ListTickets(page, pageSize int, status, priority string, applicantID uint) ([]model.Ticket, int64, error) { + var tickets []model.Ticket + var total int64 + + db := s.DB.Model(&model.Ticket{}) + if status != "" { + db = db.Where("status = ?", status) + } + if priority != "" { + db = db.Where("priority = ?", priority) + } + if applicantID > 0 { + db = db.Where("applicant_id = ?", applicantID) + } + + db.Count(&total) + offset := (page - 1) * pageSize + if err := db.Offset(offset).Limit(pageSize).Order("create_time DESC").Find(&tickets).Error; err != nil { + return nil, 0, err + } + + return tickets, total, nil +} + +// UpdateTicketStatus updates a ticket's status +func (s *TicketService) UpdateTicketStatus(id uint, status string, updaterID uint) error { + updates := map[string]interface{}{ + "status": status, + } + now := time.Now() + switch status { + case "approved": + updates["approver_id"] = updaterID + updates["review_time"] = now + case "executing", "completed", "failed": + updates["execute_time"] = now + } + + result := s.DB.Model(&model.Ticket{}).Where("id = ?", id).Updates(updates) + if result.RowsAffected == 0 { + return errors.New("ticket not found") + } + return result.Error +} + +// ApproveTicket approves a ticket +func (s *TicketService) ApproveTicket(id, approverID uint, comment string) error { + return s.UpdateTicketStatus(id, "approved", approverID) +} + +// RejectTicket rejects a ticket +func (s *TicketService) RejectTicket(id, approverID uint, comment string) error { + return s.UpdateTicketStatus(id, "rejected", approverID) +} + +// GetTicketSQLItems returns SQL items for a ticket +func (s *TicketService) GetTicketSQLItems(ticketID uint) ([]model.TicketSQLItem, error) { + var items []model.TicketSQLItem + err := s.DB.Where("ticket_id = ?", ticketID).Order("seq ASC").Find(&items).Error + return items, err +} + +// CreateTicketSQLItem creates a SQL item for a ticket +func (s *TicketService) CreateTicketSQLItem(item *model.TicketSQLItem) error { + return s.DB.Create(item).Error +} + +// GetTicketApprovals returns approval records for a ticket +func (s *TicketService) GetTicketApprovals(ticketID uint) ([]model.TicketApproval, error) { + var approvals []model.TicketApproval + err := s.DB.Where("ticket_id = ?", ticketID).Order("step_number ASC").Find(&approvals).Error + return approvals, err +} + +// DeleteTicket soft-deletes a ticket +func (s *TicketService) DeleteTicket(id uint) error { + return s.DB.Delete(&model.Ticket{}, id).Error +} + +// generateTicketNo generates a unique ticket number +func (s *TicketService) generateTicketNo() string { + now := time.Now() + var count int64 + s.DB.Model(&model.Ticket{}).Where("DATE(create_time) = DATE(?)", now).Count(&count) + return fmt.Sprintf("TK-%s-%04d", now.Format("20060102"), count+1) +} diff --git a/backend/pytest.ini b/backend/pytest.ini deleted file mode 100644 index 2f4c80e..0000000 --- a/backend/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -asyncio_mode = auto diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index e7668cf..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,20 +0,0 @@ -fastapi>=0.104.0 -uvicorn[standard]>=0.24.0 -sqlalchemy[asyncio]>=2.0.23 -aiosqlite>=0.19.0 -pydantic>=2.5.0 -pydantic-settings>=2.1.0 -python-jose[cryptography]>=3.3.0 -passlib[bcrypt]>=1.7.4 -python-multipart>=0.0.6 -cryptography>=41.0.0 -sqlparse>=0.4.4 -alembic>=1.13.0 -aiomysql>=0.2.0 -asyncpg>=0.29.0 -motor>=3.3.0 -pymongo>=4.6.0 -httpx>=0.25.0 -aiosmtplib>=3.0.0 -motor>=3.3.0 -redis>=5.0.0 diff --git a/backend/router/router.go b/backend/router/router.go new file mode 100644 index 0000000..efe7813 --- /dev/null +++ b/backend/router/router.go @@ -0,0 +1,81 @@ +package router + +import ( + "sql-platform/internal/handler" + "sql-platform/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func Setup(r *gin.Engine) { + api := r.Group("/api/v1") + + // Public routes + api.POST("/auth/login", handler.Login) + + // Authenticated routes + auth := api.Group("") + auth.Use(middleware.AuthMiddleware()) + { + // Auth + auth.GET("/auth/me", handler.GetMe) + + // Dashboard + auth.GET("/dashboard/stats", handler.DashboardStats) + auth.GET("/dashboard/my-tickets", handler.MyTickets) + + // Tickets + auth.GET("/tickets/statistics", handler.TicketStatistics) + auth.GET("/tickets", handler.ListTickets) + auth.GET("/tickets/:id", handler.GetTicket) + auth.POST("/tickets", handler.CreateTicket) + auth.POST("/tickets/:id/approve", handler.ApproveTicket) + auth.POST("/tickets/:id/execute", handler.ExecuteTicket) + + // Instances + auth.GET("/instances", handler.ListInstances) + auth.GET("/instances/stats", handler.InstanceStats) + auth.GET("/instances/:id", handler.GetInstance) + auth.POST("/instances/:id/test-connection", handler.TestConnection) + + // Instance management (admin/dba only) + admin := auth.Group("") + admin.Use(middleware.RequireRole("admin", "dba")) + { + admin.POST("/instances", handler.CreateInstance) + admin.PUT("/instances/:id", handler.UpdateInstance) + admin.DELETE("/instances/:id", handler.DeleteInstance) + } + + // Users (admin only) + userAdmin := auth.Group("") + userAdmin.Use(middleware.RequireRole("admin")) + { + userAdmin.GET("/users", handler.ListUsers) + userAdmin.POST("/users", handler.CreateUser) + userAdmin.PUT("/users/:id", handler.UpdateUser) + userAdmin.DELETE("/users/:id", handler.DeleteUser) + } + + // gh-ost (admin/dba only) + ghostGroup := auth.Group("") + ghostGroup.Use(middleware.RequireRole("admin", "dba")) + { + ghostGroup.POST("/ghost/dry-run", handler.GhOstDryRun) + ghostGroup.POST("/ghost/execute", handler.GhOstExecute) + ghostGroup.GET("/ghost/status/:taskId", handler.GhOstStatus) + ghostGroup.GET("/ghost/tasks", handler.GhOstListTasks) + } + + // Read-only resources + auth.GET("/audit-rules", handler.ListAuditRules) + auth.GET("/announcements", handler.ListAnnouncements) + auth.GET("/query-histories", handler.ListQueryHistories) + auth.GET("/settings", handler.ListSettings) + auth.GET("/notifications/channels", handler.ListNotificationChannels) + auth.GET("/notifications/templates", handler.ListNotificationTemplates) + auth.GET("/workflows", handler.ListWorkflows) + auth.GET("/instance-groups", handler.ListInstanceGroups) + auth.POST("/notifications/test", handler.TestNotification) + } +} diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py deleted file mode 100644 index 4f6294f..0000000 --- a/backend/tests/conftest.py +++ /dev/null @@ -1,182 +0,0 @@ -from typing import AsyncGenerator - -import pytest_asyncio -from httpx import ASGITransport, AsyncClient -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker -from sqlalchemy import text - -from app.core.database import Base, get_db -from app.core.security import hash_password, create_access_token -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.models.ticket import Ticket -from app.models.audit_log import AuditLog -from app.models.audit_rule import AuditRule -from app.models.announcement import Announcement -from app.models.notification import NotificationChannel -from app.models.ldap_config import LdapConfig -from app.models.backup import Backup -from app.models.scheduled_task import ScheduledTask -from app.models.schema_revision import SchemaRevision -from app.models.masking_rule import MaskingRule -from app.models.notification_template import NotificationTemplate -from app.models.workflow import WorkflowDefinition, WorkflowStep, TicketApproval - -SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://" - -engine = create_async_engine(SQLALCHEMY_DATABASE_URL, echo=False) -TestSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - -@pytest_asyncio.fixture(scope="session", autouse=True) -async def setup_database(): - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - yield - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.drop_all) - - -@pytest_asyncio.fixture(autouse=True) -async def clean_tables(): - yield - async with engine.begin() as conn: - for table in reversed(Base.metadata.sorted_tables): - await conn.execute(text(f"DELETE FROM {table.name}")) - - -@pytest_asyncio.fixture -async def db() -> AsyncGenerator[AsyncSession, None]: - async with TestSessionLocal() as session: - yield session - - -@pytest_asyncio.fixture -async def client(db: AsyncSession) -> AsyncGenerator[AsyncClient, None]: - from app.main import app - - async def _override(): - yield db - - app.dependency_overrides[get_db] = _override - - transport = ASGITransport(app=app) - async with AsyncClient(transport=transport, base_url="http://test") as c: - yield c - - app.dependency_overrides.clear() - - -@pytest_asyncio.fixture -async def admin_user(db: AsyncSession) -> User: - user = User( - username="admin", - display_name="管理员", - role="admin", - hashed_password=hash_password("123456"), - ) - db.add(user) - await db.commit() - await db.refresh(user) - return user - - -@pytest_asyncio.fixture -async def dba_user(db: AsyncSession) -> User: - user = User( - username="dba", - display_name="DBA张三", - role="dba", - hashed_password=hash_password("123456"), - ) - db.add(user) - await db.commit() - await db.refresh(user) - return user - - -@pytest_asyncio.fixture -async def developer_user(db: AsyncSession) -> User: - user = User( - username="dev1", - display_name="开发者李四", - role="developer", - hashed_password=hash_password("123456"), - ) - db.add(user) - await db.commit() - await db.refresh(user) - return user - - -@pytest_asyncio.fixture -async def guest_user(db: AsyncSession) -> User: - user = User( - username="guest1", - display_name="访客", - role="guest", - hashed_password=hash_password("123456"), - ) - db.add(user) - await db.commit() - await db.refresh(user) - return user - - -@pytest_asyncio.fixture -async def disabled_user(db: AsyncSession) -> User: - user = User( - username="disabled", - display_name="已禁用用户", - role="developer", - is_active=False, - hashed_password=hash_password("123456"), - ) - db.add(user) - await db.commit() - await db.refresh(user) - return user - - -@pytest_asyncio.fixture -async def admin_token(admin_user: User) -> str: - return create_access_token(data={"sub": str(admin_user.id), "role": admin_user.role}) - - -@pytest_asyncio.fixture -async def dba_token(dba_user: User) -> str: - return create_access_token(data={"sub": str(dba_user.id), "role": dba_user.role}) - - -@pytest_asyncio.fixture -async def developer_token(developer_user: User) -> str: - return create_access_token(data={"sub": str(developer_user.id), "role": developer_user.role}) - - -@pytest_asyncio.fixture -async def guest_token(guest_user: User) -> str: - return create_access_token(data={"sub": str(guest_user.id), "role": guest_user.role}) - - -def auth_header(token: str) -> dict: - return {"Authorization": f"Bearer {token}"} - - -@pytest_asyncio.fixture -async def test_instance(db: AsyncSession) -> Instance: - instance = Instance( - name="测试MySQL", - db_type="mysql", - host="127.0.0.1", - port=3306, - username="root", - password="test123", - database="test_db", - env="development", - status="disconnected", - ) - db.add(instance) - await db.commit() - await db.refresh(instance) - return instance diff --git a/backend/tests/test_announcement.py b/backend/tests/test_announcement.py deleted file mode 100644 index 86331d4..0000000 --- a/backend/tests/test_announcement.py +++ /dev/null @@ -1,318 +0,0 @@ -"""Comprehensive tests for Announcement CRUD, permissions, and edge cases.""" -import pytest -from httpx import AsyncClient - -from tests.conftest import auth_header - -BASE = "/api/v1/announcements" - -# ─── CREATE (POST /announcements) ────────────────────────────────────── - -PAYLOAD = { - "title": "系统维护通知", - "content": "将于本周六凌晨进行系统维护,届时服务将暂停。", -} - - -@pytest.mark.asyncio -async def test_create_announcement(client: AsyncClient, admin_token: str): - resp = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) - assert resp.status_code == 201 - data = resp.json() - assert data["title"] == PAYLOAD["title"] - assert data["content"] == PAYLOAD["content"] - assert data["is_published"] is False - assert data["published_at"] is None - assert "id" in data - assert "created_at" in data - assert "updated_at" in data - - -@pytest.mark.asyncio -async def test_create_announcement_empty_title(client: AsyncClient, admin_token: str): - resp = await client.post( - BASE, json={"title": "", "content": "内容"}, headers=auth_header(admin_token) - ) - assert resp.status_code == 422 - - -@pytest.mark.asyncio -async def test_create_announcement_title_too_long(client: AsyncClient, admin_token: str): - resp = await client.post( - BASE, json={"title": "x" * 201, "content": "内容"}, headers=auth_header(admin_token) - ) - assert resp.status_code == 422 - - -@pytest.mark.asyncio -async def test_create_announcement_empty_content(client: AsyncClient, admin_token: str): - resp = await client.post( - BASE, json={"title": "标题", "content": ""}, headers=auth_header(admin_token) - ) - assert resp.status_code == 422 - - -@pytest.mark.asyncio -async def test_create_announcement_missing_fields(client: AsyncClient, admin_token: str): - resp = await client.post(BASE, json={}, headers=auth_header(admin_token)) - assert resp.status_code == 422 - - -# ─── LIST (GET /announcements) ───────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_list_announcements_empty(client: AsyncClient, admin_token: str): - resp = await client.get(BASE, headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert resp.json() == [] - - -@pytest.mark.asyncio -async def test_list_announcements_returns_all(client: AsyncClient, admin_token: str): - for i in range(3): - await client.post( - BASE, - json={"title": f"公告{i}", "content": f"内容{i}"}, - headers=auth_header(admin_token), - ) - resp = await client.get(BASE, headers=auth_header(admin_token)) - assert resp.status_code == 200 - items = resp.json() - assert len(items) == 3 - # ordered by id desc - assert items[0]["title"] == "公告2" - assert items[2]["title"] == "公告0" - - -@pytest.mark.asyncio -async def test_list_announcements_requires_auth(client: AsyncClient): - resp = await client.get(BASE) - assert resp.status_code == 401 - - -# ─── UPDATE (PUT /announcements/{id}) ────────────────────────────────── - - -@pytest.mark.asyncio -async def test_update_announcement_title(client: AsyncClient, admin_token: str): - create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) - aid = create.json()["id"] - - resp = await client.put( - f"{BASE}/{aid}", - json={"title": "更新后的标题"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["title"] == "更新后的标题" - # content unchanged - assert resp.json()["content"] == PAYLOAD["content"] - - -@pytest.mark.asyncio -async def test_update_announcement_content(client: AsyncClient, admin_token: str): - create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) - aid = create.json()["id"] - - resp = await client.put( - f"{BASE}/{aid}", - json={"content": "新内容"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["content"] == "新内容" - - -@pytest.mark.asyncio -async def test_update_announcement_publish(client: AsyncClient, admin_token: str): - create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) - aid = create.json()["id"] - assert create.json()["is_published"] is False - - resp = await client.put( - f"{BASE}/{aid}", - json={"is_published": True}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["is_published"] is True - # published_at should be set on first publish - assert data["published_at"] is not None - - -@pytest.mark.asyncio -async def test_update_announcement_publish_idempotent(client: AsyncClient, admin_token: str): - """Publishing an already-published announcement should not change published_at.""" - create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) - aid = create.json()["id"] - - first = await client.put( - f"{BASE}/{aid}", - json={"is_published": True}, - headers=auth_header(admin_token), - ) - first_published_at = first.json()["published_at"] - - # Update title while staying published - second = await client.put( - f"{BASE}/{aid}", - json={"title": "改标题不改发布时间"}, - headers=auth_header(admin_token), - ) - assert second.json()["published_at"] == first_published_at - - -@pytest.mark.asyncio -async def test_update_announcement_unpublish(client: AsyncClient, admin_token: str): - create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) - aid = create.json()["id"] - - # publish first - await client.put( - f"{BASE}/{aid}", - json={"is_published": True}, - headers=auth_header(admin_token), - ) - # then unpublish - resp = await client.put( - f"{BASE}/{aid}", - json={"is_published": False}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["is_published"] is False - - -@pytest.mark.asyncio -async def test_update_nonexistent_announcement(client: AsyncClient, admin_token: str): - resp = await client.put( - f"{BASE}/99999", - json={"title": "不存在"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - -@pytest.mark.asyncio -async def test_update_announcement_empty_title(client: AsyncClient, admin_token: str): - create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) - aid = create.json()["id"] - - resp = await client.put( - f"{BASE}/{aid}", - json={"title": ""}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 422 - - -# ─── DELETE (DELETE /announcements/{id}) ─────────────────────────────── - - -@pytest.mark.asyncio -async def test_delete_announcement(client: AsyncClient, admin_token: str): - create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) - aid = create.json()["id"] - - resp = await client.delete(f"{BASE}/{aid}", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert "message" in resp.json() - - # verify it's gone from list - items = (await client.get(BASE, headers=auth_header(admin_token))).json() - assert all(a["id"] != aid for a in items) - - -@pytest.mark.asyncio -async def test_delete_nonexistent_announcement(client: AsyncClient, admin_token: str): - resp = await client.delete(f"{BASE}/99999", headers=auth_header(admin_token)) - assert resp.status_code == 404 - - -# ─── PERMISSION CHECKS ───────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_create_requires_admin(client: AsyncClient, dba_token: str): - resp = await client.post(BASE, json=PAYLOAD, headers=auth_header(dba_token)) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_update_requires_admin(client: AsyncClient, dba_token: str): - resp = await client.put( - f"{BASE}/1", json={"title": "x"}, headers=auth_header(dba_token) - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_delete_requires_admin(client: AsyncClient, developer_token: str): - resp = await client.delete(f"{BASE}/1", headers=auth_header(developer_token)) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_list_allows_any_authenticated_user( - client: AsyncClient, developer_token: str -): - """All logged-in users can list announcements (not admin-only).""" - resp = await client.get(BASE, headers=auth_header(developer_token)) - assert resp.status_code == 200 - - -@pytest.mark.asyncio -async def test_guest_can_list_announcements(client: AsyncClient, guest_token: str): - resp = await client.get(BASE, headers=auth_header(guest_token)) - assert resp.status_code == 200 - - -# ─── EDGE CASES ───────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_no_auth_returns_401(client: AsyncClient): - for method, url in [ - ("get", BASE), - ("post", BASE), - ("put", f"{BASE}/1"), - ("delete", f"{BASE}/1"), - ]: - resp = await getattr(client, method)(url) - assert resp.status_code == 401, f"{method.upper()} {url} should be 401, got {resp.status_code}" - - -@pytest.mark.asyncio -async def test_create_and_full_lifecycle(client: AsyncClient, admin_token: str): - """Create → list → update → publish → list → delete → 404.""" - # create - create = await client.post(BASE, json=PAYLOAD, headers=auth_header(admin_token)) - assert create.status_code == 201 - aid = create.json()["id"] - - # list contains it - items = (await client.get(BASE, headers=auth_header(admin_token))).json() - assert any(a["id"] == aid for a in items) - - # update title - upd = await client.put( - f"{BASE}/{aid}", json={"title": "新标题"}, headers=auth_header(admin_token) - ) - assert upd.json()["title"] == "新标题" - - # publish - pub = await client.put( - f"{BASE}/{aid}", json={"is_published": True}, headers=auth_header(admin_token) - ) - assert pub.json()["is_published"] is True - - # delete - await client.delete(f"{BASE}/{aid}", headers=auth_header(admin_token)) - - # update non-existent → 404 - resp = await client.put( - f"{BASE}/{aid}", json={"title": "x"}, headers=auth_header(admin_token) - ) - assert resp.status_code == 404 diff --git a/backend/tests/test_approval_flow_comprehensive.py b/backend/tests/test_approval_flow_comprehensive.py deleted file mode 100644 index 39ccf75..0000000 --- a/backend/tests/test_approval_flow_comprehensive.py +++ /dev/null @@ -1,339 +0,0 @@ -"""审批流全面场景测试 — MySQL + Redis。 - -覆盖: - - MySQL: 基础查询/写入、高危DDL、性能规则、安全规则 - - Redis: 基础读写、高危命令、特殊场景 - - 审核引擎规则命中 + 风险等级自动计算 + 审批流匹配 -""" - -import pytest -import asyncio -from unittest.mock import AsyncMock, MagicMock - - -def _audit_sql(sql, db_type="mysql"): - """辅助:直接调审核引擎,返回 findings。""" - from app.services.audit_engine_v2 import SQLAuditEngineV2 - - async def _run(): - engine = SQLAuditEngineV2(db_session=None, env="development") - report = await engine.audit(sql, db_type=db_type) - return report - - return asyncio.get_event_loop().run_until_complete(_run()) - - -def _eval_risk(sql, db_type="mysql", sql_type="", table_sizes=None): - """辅助:直接调风险评估。""" - from app.services.risk_evaluator import evaluate_risk_level - return evaluate_risk_level(sql, db_type=db_type, sql_type=sql_type, table_sizes=table_sizes) - - -# ══════════════════════════════════════════════ -# 一、MySQL 基础功能测试 -# ══════════════════════════════════════════════ - -class TestMySQLBasic: - """MySQL 基础功能 — 查询/写入/批量/无WHERE。""" - - def test_01_simple_select_low_risk(self): - """#1 简单查询 — risk=low。""" - risk = _eval_risk("SELECT * FROM users LIMIT 10;", db_type="mysql", sql_type="DQL") - assert risk == "low" - - def test_02_simple_insert_medium(self): - """#2 简单 INSERT — risk=medium。""" - risk = _eval_risk("INSERT INTO users(name) VALUES('test');", db_type="mysql", sql_type="DML") - assert risk == "medium" - - def test_03_batch_insert_high(self): - """#3 批量 INSERT(1000条) — risk=high(statement_count>5)。""" - sql = "INSERT INTO users(name) VALUES('test');\n" * 1000 - from app.services.risk_evaluator import evaluate_risk_level - import sqlparse - stmt_list = [s.strip() for s in sqlparse.split(sql) if s.strip()] - risk = evaluate_risk_level(sql, db_type="mysql", sql_type="DML", statement_count=len(stmt_list)) - assert risk in ("high", "medium") - - def test_04_update_no_where_high_risk(self): - """#4 UPDATE 无 WHERE — risk=high。""" - risk = _eval_risk("UPDATE users SET status=1;", db_type="mysql") - assert risk == "high", f"预期 high,实际 {risk}" - - def test_05_delete_no_where_high_risk(self): - """#5 DELETE 无 WHERE — risk=high。""" - risk = _eval_risk("DELETE FROM users;", db_type="mysql") - assert risk == "high", f"预期 high,实际 {risk}" - - -# ══════════════════════════════════════════════ -# 二、MySQL 高危 DDL 操作 -# ══════════════════════════════════════════════ - -class TestMySQLDDL: - """MySQL 高危 DDL — DROP/TRUNCATE/ALTER。""" - - def test_06_drop_table_critical(self): - """#6 DROP TABLE — risk=critical。""" - risk = _eval_risk("DROP TABLE users;", db_type="mysql") - assert risk == "critical" - - def test_06_drop_table_audit_blocked(self): - """#6 DROP TABLE — 审核引擎拦截(DDL-004 / MYSQL-DDL-016)。""" - report = _audit_sql("DROP TABLE users;", db_type="mysql") - findings = report.findings if hasattr(report, 'findings') else [] - codes = [f.rule_code for f in findings] - # DDL-004(禁止DROP TABLE) 或 MYSQL-DDL-016 都算拦截 - assert any(c in codes for c in ("DDL-004", "MYSQL-DDL-016")), f"DROP TABLE 应被拦截,实际命中: {codes}" - - def test_07_truncate_critical(self): - """#7 TRUNCATE — risk=critical。""" - risk = _eval_risk("TRUNCATE TABLE orders;", db_type="mysql") - assert risk == "critical" - - def test_08_alter_large_table_risk_upgrade(self): - """#8 ALTER 大表(>10万行) — risk 升级为 high。""" - risk = _eval_risk( - "ALTER TABLE orders ADD COLUMN new_col varchar(255);", - db_type="mysql", sql_type="DDL", - table_sizes={"orders": 1_000_000} - ) - assert risk == "high" - - def test_08_alter_small_table_medium(self): - """#8 ALTER 小表 — risk=medium。""" - risk = _eval_risk( - "ALTER TABLE users ADD COLUMN age INT;", - db_type="mysql", sql_type="DDL", - table_sizes={"users": 50} - ) - assert risk == "medium" - - def test_09_modify_column_warning(self): - """#9 MODIFY COLUMN — 审核引擎应有警告。""" - report = _audit_sql("ALTER TABLE users MODIFY id bigint;", db_type="mysql") - findings = report.findings if hasattr(report, 'findings') else [] - assert len(findings) > 0, "MODIFY COLUMN 应触发审核规则" - - def test_10_create_index_medium(self): - """#10 CREATE INDEX — risk=medium。""" - risk = _eval_risk( - "CREATE INDEX idx_name ON users(name);", - db_type="mysql", sql_type="DDL" - ) - assert risk == "medium" - - -# ══════════════════════════════════════════════ -# 三、MySQL 性能与安全规则 -# ══════════════════════════════════════════════ - -class TestMySQLPerfSecurity: - """MySQL 性能与安全规则检测。""" - - def test_select_star_production_warning(self): - """SELECT * 应触发审核警告。""" - report = _audit_sql("SELECT * FROM users;", db_type="mysql") - findings = report.findings if hasattr(report, 'findings') else [] - codes = [f.rule_code for f in findings] - # DQL-001 或类似规则 - assert len(findings) > 0, f"SELECT * 应触发审核规则,实际命中: {codes}" - - def test_multi_join_warning(self): - """多表 JOIN(>3) 应触发警告。""" - sql = """ - SELECT * FROM t1 - JOIN t2 ON t1.id = t2.t1_id - JOIN t3 ON t2.id = t3.t2_id - JOIN t4 ON t3.id = t4.t3_id - JOIN t5 ON t4.id = t5.t4_id - """ - report = _audit_sql(sql, db_type="mysql") - findings = report.findings if hasattr(report, 'findings') else [] - # 可能触发 JOIN 数量限制规则 - assert len(findings) > 0, "5表 JOIN 应触发审核规则" - - def test_sensitive_column_warning(self): - """敏感字段操作(password)应触发警告。""" - sql = "SELECT password, id_card FROM users;" - report = _audit_sql(sql, db_type="mysql") - findings = report.findings if hasattr(report, 'findings') else [] - # 安全规则应检测敏感字段 - assert len(findings) > 0, "敏感字段应触发安全规则" - - -# ══════════════════════════════════════════════ -# 四、Redis 基础命令测试 -# ══════════════════════════════════════════════ - -class TestRedisBasic: - """Redis 基础命令 — 读/写/批量/KEYS/FLUSH。""" - - def test_01_redis_get_low(self): - """Redis GET — risk=low。""" - risk = _eval_risk("GET user:1001", db_type="redis") - assert risk == "low" - - def test_02_redis_set_medium(self): - """Redis SET — risk=medium。""" - risk = _eval_risk("SET user:999 test EX 3600", db_type="redis") - assert risk == "medium" - - def test_04_redis_keys_high(self): - """Redis KEYS * — 生产环境应拦截。""" - # KEYS 不是写命令但极其危险 - # 当前 risk_evaluator 只按命令前缀判断,KEYS 不在写命令列表 - # 但审核引擎应该有规则拦截 - report = _audit_sql("KEYS user:*", db_type="redis") - findings = report.findings if hasattr(report, 'findings') else [] - # 至少应该有警告(如果审核引擎有 Redis 规则的话) - # 这里主要验证不 crash - assert report is not None - - def test_05_redis_flushall_critical(self): - """Redis FLUSHALL — risk=critical。""" - risk = _eval_risk("FLUSHALL", db_type="redis") - assert risk == "critical" - - def test_05_redis_flushdb_critical(self): - """Redis FLUSHDB — risk=critical。""" - risk = _eval_risk("FLUSHDB", db_type="redis") - assert risk == "critical" - - def test_05_redis_flushdb_audit_blocked(self): - """Redis FLUSHDB — 审核引擎拦截。""" - report = _audit_sql("FLUSHDB", db_type="redis") - findings = report.findings if hasattr(report, 'findings') else [] - codes = [f.rule_code for f in findings] - # Redis 规则应拦截 FLUSHDB - assert len(findings) > 0, f"FLUSHDB 应被拦截,实际: {codes}" - - -# ══════════════════════════════════════════════ -# 五、Redis 高危操作测试 -# ══════════════════════════════════════════════ - -class TestRedisDangerous: - """Redis 高危命令 — DEL/CONFIG/EVAL/BGSAVE。""" - - def test_06_redis_del_high(self): - """Redis DEL 大量Key — risk=high。""" - risk = _eval_risk("DEL key1 key2 key3 key4 key5", db_type="redis") - assert risk == "high" - - def test_07_redis_rename_high(self): - """Redis RENAME — risk=high。""" - risk = _eval_risk("RENAME oldkey newkey", db_type="redis") - assert risk == "high" - - def test_08_redis_config_set_warning(self): - """Redis CONFIG SET — 审核引擎应有规则。""" - report = _audit_sql("CONFIG SET maxmemory 10gb", db_type="redis") - findings = report.findings if hasattr(report, 'findings') else [] - # 验证不 crash - assert report is not None - - def test_09_redis_eval_script_warning(self): - """Redis EVAL Lua 脚本 — 应检测危险模式。""" - report = _audit_sql('EVAL "redis.call(\'FLUSHALL\')" 0', db_type="redis") - findings = report.findings if hasattr(report, 'findings') else [] - # FLUSHALL 在 EVAL 中应被检测 - assert len(findings) > 0, "EVAL 中包含 FLUSHALL 应被拦截" - - def test_10_redis_bgsave_low(self): - """Redis BGSAVE — risk=low(只读管理命令)。""" - risk = _eval_risk("BGSAVE", db_type="redis") - assert risk == "low" - - -# ══════════════════════════════════════════════ -# 六、风险等级边界测试 -# ══════════════════════════════════════════════ - -class TestRiskLevelBoundary: - """风险等级边界值测试。""" - - def test_critical_drop_database(self): - risk = _eval_risk("DROP DATABASE test_e2e;", db_type="mysql") - assert risk == "critical" - - def test_critical_drop_schema(self): - risk = _eval_risk("DROP SCHEMA public;", db_type="mysql") - assert risk == "critical" - - def test_high_alter_drop_column(self): - risk = _eval_risk("ALTER TABLE users DROP COLUMN age;", db_type="mysql") - assert risk == "high" - - def test_high_alter_drop_primary_key(self): - risk = _eval_risk("ALTER TABLE users DROP PRIMARY KEY;", db_type="mysql") - assert risk == "high" - - def test_high_delete_with_where_medium(self): - """DELETE 有 WHERE 但无 LIMIT — medium。""" - risk = _eval_risk("DELETE FROM users WHERE id = 1;", db_type="mysql") - assert risk == "medium" - - def test_low_select_with_where(self): - """SELECT 查询 — low。""" - risk = _eval_risk("SELECT id, name FROM users WHERE id = 1;", db_type="mysql") - assert risk == "low" - - def test_medium_insert(self): - """INSERT — medium。""" - risk = _eval_risk("INSERT INTO users(name) VALUES('test');", db_type="mysql", sql_type="DML") - assert risk == "medium" - - def test_medium_grant(self): - """GRANT — medium。""" - risk = _eval_risk("GRANT SELECT ON test.* TO 'reader'@'%';", db_type="mysql") - assert risk == "medium" - - def test_medium_create_table(self): - """CREATE TABLE — medium。""" - risk = _eval_risk("CREATE TABLE test_table (id INT PRIMARY KEY);", db_type="mysql") - assert risk == "medium" - - def test_table_sizes_exact_threshold(self): - """大表阈值:99999行不升级,100001行升级。""" - risk_below = _eval_risk( - "ALTER TABLE t ADD COLUMN x INT;", - db_type="mysql", sql_type="DDL", - table_sizes={"t": 99_999} - ) - assert risk_below == "medium" - - risk_above = _eval_risk( - "ALTER TABLE t ADD COLUMN x INT;", - db_type="mysql", sql_type="DDL", - table_sizes={"t": 100_001} - ) - assert risk_above == "high" - - -# ══════════════════════════════════════════════ -# 七、MongoDB 风险评估 -# ══════════════════════════════════════════════ - -class TestMongoDBRisk: - """MongoDB 操作风险评估。""" - - def test_mongo_drop_database_critical(self): - risk = _eval_risk("db.dropDatabase()", db_type="mongodb") - assert risk == "critical" - - def test_mongo_drop_collection_high(self): - risk = _eval_risk("db.users.drop()", db_type="mongodb") - assert risk == "high" - - def test_mongo_insert_medium(self): - risk = _eval_risk("db.users.insertOne({name: 'test'})", db_type="mongodb") - assert risk == "medium" - - def test_mongo_find_low(self): - risk = _eval_risk("db.users.find({})", db_type="mongodb") - assert risk == "low" - - def test_mongo_delete_many_high(self): - risk = _eval_risk("db.users.deleteMany({})", db_type="mongodb") - assert risk == "high" diff --git a/backend/tests/test_audit_engine.py b/backend/tests/test_audit_engine.py deleted file mode 100644 index c6ff34e..0000000 --- a/backend/tests/test_audit_engine.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Tests for SQL Audit Engine (Phase 3.2).""" -import pytest -from app.services.audit_engine import SQLAuditEngine, Severity - - -class TestSQLAuditEngine: - def setup_method(self): - self.engine = SQLAuditEngine(env="development") - - # --- DDL Rules --- - - def test_drop_table_without_if_exists(self): - report = self.engine.audit("DROP TABLE users;") - assert not report.passed or report.has_warnings - rules = [f.rule for f in report.findings] - assert "DROP_TABLE_DANGER" in rules - - def test_drop_table_production_blocked(self): - engine = SQLAuditEngine(env="production") - report = engine.audit("DROP TABLE users;") - assert report.has_warnings or not report.passed - warnings = [f for f in report.findings if f.rule == "DROP_TABLE_DANGER"] - assert len(warnings) > 0 - - def test_truncate_warning(self): - report = self.engine.audit("TRUNCATE TABLE logs;") - rules = [f.rule for f in report.findings] - assert "TRUNCATE_DANGER" in rules - - def test_truncate_production_error(self): - engine = SQLAuditEngine(env="production") - report = engine.audit("TRUNCATE TABLE logs;") - errors = [f for f in report.findings if f.level == Severity.ERROR] - assert any(f.rule == "TRUNCATE_DANGER" for f in errors) - - # --- Missing WHERE --- - - def test_update_no_where_error(self): - report = self.engine.audit("UPDATE users SET status = 1;") - assert not report.passed - errors = [f.rule for f in report.findings if f.level == Severity.ERROR] - assert "UPDATE_NO_WHERE" in errors - - def test_delete_no_where_error(self): - report = self.engine.audit("DELETE FROM users;") - assert not report.passed - errors = [f.rule for f in report.findings if f.level == Severity.ERROR] - assert "DELETE_NO_WHERE" in errors - - def test_update_with_where_passes(self): - report = self.engine.audit("UPDATE users SET name = 'test' WHERE id = 1;") - error_rules = [f.rule for f in report.findings if f.level == Severity.ERROR] - assert "UPDATE_NO_WHERE" not in error_rules - - def test_delete_with_where_passes(self): - report = self.engine.audit("DELETE FROM users WHERE id = 1;") - error_rules = [f.rule for f in report.findings if f.level == Severity.ERROR] - assert "DELETE_NO_WHERE" not in error_rules - - # --- SELECT * --- - - def test_select_star_info(self): - report = self.engine.audit("SELECT * FROM users WHERE id = 1;") - rules = [f.rule for f in report.findings] - assert "SELECT_STAR" in rules - star_finding = next(f for f in report.findings if f.rule == "SELECT_STAR") - assert star_finding.level == Severity.INFO - - def test_select_columns_clean(self): - report = self.engine.audit("SELECT id, name FROM users WHERE id = 1;") - rules = [f.rule for f in report.findings] - assert "SELECT_STAR" not in rules - - # --- Multiple statements --- - - def test_multi_statement_error(self): - report = self.engine.audit("UPDATE users SET name = 'a'; DELETE FROM logs;") - assert not report.passed - rules = [f.rule for f in report.findings if f.level == Severity.ERROR] - assert "MULTI_STATEMENT" in rules - - def test_single_statement_ok(self): - report = self.engine.audit("SELECT id FROM users LIMIT 10;") - rules = [f.rule for f in report.findings] - assert "MULTI_STATEMENT" not in rules - - # --- Large IN clause --- - - def test_large_in_warning(self): - values = ", ".join([str(i) for i in range(150)]) - sql = f"SELECT * FROM users WHERE id IN ({values});" - report = self.engine.audit(sql) - rules = [f.rule for f in report.findings] - assert "LARGE_IN_CLAUSE" in rules - - def test_small_in_ok(self): - report = self.engine.audit("SELECT * FROM users WHERE id IN (1, 2, 3);") - rules = [f.rule for f in report.findings] - assert "LARGE_IN_CLAUSE" not in rules - - # --- Production extra rules --- - - def test_alter_table_production_error(self): - engine = SQLAuditEngine(env="production") - report = engine.audit("ALTER TABLE users ADD COLUMN age INT;") - assert not report.passed - errors = [f.rule for f in report.findings if f.level == Severity.ERROR] - assert "ALTER_TABLE_PRODUCTION" in errors - - def test_alter_table_dev_ok(self): - report = self.engine.audit("ALTER TABLE users ADD COLUMN age INT;") - error_rules = [f.rule for f in report.findings if f.level == Severity.ERROR] - assert "ALTER_TABLE_PRODUCTION" not in error_rules - - # --- JOIN without ON --- - - def test_join_no_on_warning(self): - report = self.engine.audit("SELECT * FROM users JOIN orders;") - rules = [f.rule for f in report.findings] - assert "JOIN_NO_ON" in rules - - def test_join_with_on_ok(self): - report = self.engine.audit("SELECT * FROM users JOIN orders ON users.id = orders.user_id;") - rules = [f.rule for f in report.findings] - assert "JOIN_NO_ON" not in rules - - # --- Score --- - - def test_clean_sql_score_100(self): - report = self.engine.audit("SELECT id, name FROM users WHERE id = 1 LIMIT 10;") - assert report.score == 100 - assert report.passed - - def test_error_reduces_score(self): - report = self.engine.audit("UPDATE users SET name = 'test';") - assert report.score < 100 - assert not report.passed - - def test_report_to_dict(self): - report = self.engine.audit("SELECT * FROM users;") - d = report.to_dict() - assert "passed" in d - assert "score" in d - assert "findings" in d - assert isinstance(d["findings"], list) - - -class TestAuditEngineIntegration: - """Test audit engine integration with ticket creation via API.""" - - @pytest.mark.asyncio - async def test_ticket_creation_returns_audit_report(self, client, db, admin_token, test_instance): - from tests.conftest import auth_header - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "SQL审核测试工单", - "db_type": "mysql", - "instance_id": test_instance.id, - "target_database": "test_db", - "sql_content": "SELECT * FROM users WHERE id = 1;", - "reason": "测试审核引擎", - "priority": "medium", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - assert "audit_report" in data - assert "score" in data["audit_report"] - assert "findings" in data["audit_report"] - - @pytest.mark.asyncio - async def test_ticket_with_dangerous_sql_has_low_score(self, client, db, admin_token, test_instance): - from tests.conftest import auth_header - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "危险SQL工单", - "db_type": "mysql", - "instance_id": test_instance.id, - "target_database": "test_db", - "sql_content": "UPDATE users SET status = 1;", - "reason": "全表更新测试", - "priority": "high", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - report = data["audit_report"] - assert report["passed"] is False - assert report["score"] < 100 diff --git a/backend/tests/test_audit_engine_v2.py b/backend/tests/test_audit_engine_v2.py deleted file mode 100644 index b1a5511..0000000 --- a/backend/tests/test_audit_engine_v2.py +++ /dev/null @@ -1,714 +0,0 @@ -"""Tests for SQL Audit Engine V2 — 可配置规则引擎 (40+ 规则覆盖测试). - -测试覆盖: -- DDL 规则 (12条): 主键、注释、索引、类型等 -- DML 规则 (8条): WHERE、LIMIT、INSERT、REPLACE 等 -- DQL 规则 (5条): SELECT *、LIMIT、函数、排序等 -- 命名规范 (5条): 表名、字段名、索引名等 -- 性能规则 (8条): 全表扫描、IN、LIKE、JOIN 等 -- 安全规则 (5条): 敏感字段、密码、UNION、注释等 -""" - -import pytest -from app.services.audit_engine_v2 import ( - SQLAuditEngineV2, - AuditReportV2, - AuditFindingV2, - Severity, - RuleCategory, - AuditRuleV2, -) - - -# ────────────────────────────────────────────── -# 引擎基础测试 -# ────────────────────────────────────────────── - - -class TestSQLAuditEngineV2: - """SQLAuditEngineV2 引擎核心功能测试。""" - - @pytest.fixture - def engine(self): - """返回不带数据库的同步引擎实例。""" - return SQLAuditEngineV2(db_session=None, env="development") - - @pytest.mark.asyncio - async def test_audit_returns_report(self, engine): - """audit() 应返回 AuditReportV2 实例。""" - report = await engine.audit("SELECT id, name FROM users LIMIT 10;") - assert isinstance(report, AuditReportV2) - - @pytest.mark.asyncio - async def test_clean_sql_passes(self, engine): - """规范 SQL 应通过审核且评分为100。""" - report = await engine.audit("SELECT id, name FROM users WHERE id = 1 LIMIT 10;") - assert report.passed - assert report.score == 100 - - @pytest.mark.asyncio - async def test_report_has_summary(self, engine): - """审核报告应包含摘要。""" - report = await engine.audit("UPDATE users SET name = 'test' WHERE id = 1;") - assert report.summary - assert isinstance(report.summary, str) - - @pytest.mark.asyncio - async def test_report_to_dict_backward_compat(self, engine): - """to_dict() 应返回向后兼容的字典格式。""" - report = await engine.audit("SELECT * FROM users;") - d = report.to_dict() - assert "passed" in d - assert "score" in d - assert "findings" in d - assert "summary" in d - assert isinstance(d["findings"], list) - - @pytest.mark.asyncio - async def test_error_reduces_score(self, engine): - """ERROR 级别发现项应扣10分。""" - report = await engine.audit("UPDATE users SET status = 1;") - assert report.score < 100 - assert not report.passed - - @pytest.mark.asyncio - async def test_db_type_filtering(self, engine): - """规则应按 db_type 过滤。""" - report_mysql = await engine.audit("SELECT id FROM t LIMIT 10;", db_type="mysql") - report_postgres = await engine.audit("SELECT id FROM t LIMIT 10;", db_type="postgres") - # 两者都应通过(基本 SELECT) - assert report_mysql.passed - assert report_postgres.passed - - @pytest.mark.asyncio - async def test_sync_audit(self, engine): - """同步审核应正常工作。""" - report = engine.audit_sync("SELECT id FROM users WHERE id = 1 LIMIT 10;") - assert report.passed - assert report.score == 100 - - @pytest.mark.asyncio - async def test_get_rule_list(self, engine): - """获取规则列表应返回非空列表。""" - rules = await engine.get_rule_list() - assert len(rules) >= 40 # 至少40条规则 - - -# ────────────────────────────────────────────── -# DDL 规则测试 -# ────────────────────────────────────────────── - - -class TestDDLRules: - """DDL 规则测试 (DDL-001 ~ DDL-012)。""" - - @pytest.fixture - def engine(self): - return SQLAuditEngineV2(db_session=None, env="development") - - # DDL-001: 建表必须包含主键 - @pytest.mark.asyncio - async def test_ddl_001_no_primary_key(self, engine): - report = await engine.audit("CREATE TABLE users (name VARCHAR(50));") - findings = [f for f in report.findings if f.rule_code == "DDL-001"] - assert len(findings) >= 1 - assert findings[0].level == Severity.ERROR - - @pytest.mark.asyncio - async def test_ddl_001_has_primary_key(self, engine): - report = await engine.audit("CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(50));") - findings = [f for f in report.findings if f.rule_code == "DDL-001"] - assert len(findings) == 0 - - # DDL-002: 建表必须添加表注释 - @pytest.mark.asyncio - async def test_ddl_002_no_comment(self, engine): - report = await engine.audit("CREATE TABLE users (id INT PRIMARY KEY);") - findings = [f for f in report.findings if f.rule_code == "DDL-002"] - assert len(findings) >= 1 - - @pytest.mark.asyncio - async def test_ddl_002_has_comment(self, engine): - report = await engine.audit( - "CREATE TABLE users (id INT PRIMARY KEY) COMMENT='用户表';" - ) - findings = [f for f in report.findings if f.rule_code == "DDL-002"] - assert len(findings) == 0 - - # DDL-004: 禁止 DROP TABLE - @pytest.mark.asyncio - async def test_ddl_004_drop_table(self, engine): - report = await engine.audit("DROP TABLE users;") - findings = [f for f in report.findings if f.rule_code == "DDL-004"] - assert len(findings) >= 1 - assert findings[0].level == Severity.ERROR - - # DDL-005: 禁止 TRUNCATE TABLE - @pytest.mark.asyncio - async def test_ddl_005_truncate(self, engine): - report = await engine.audit("TRUNCATE TABLE users;") - findings = [f for f in report.findings if f.rule_code == "DDL-005"] - assert len(findings) >= 1 - assert findings[0].level == Severity.ERROR - - # DDL-006: 单表索引数量不得超过12个 - @pytest.mark.asyncio - async def test_ddl_006_too_many_indexes(self, engine): - # 创建15个索引 - idx_defs = ", ".join([f"KEY idx_{i} (col{i})" for i in range(15)]) - sql = f"CREATE TABLE t (id INT PRIMARY KEY, {', '.join([f'col{i} INT' for i in range(15)])}, {idx_defs});" - report = await engine.audit(sql) - findings = [f for f in report.findings if f.rule_code == "DDL-006"] - assert len(findings) >= 1 - - # DDL-007: 单索引字段数不得超过8个 - @pytest.mark.asyncio - async def test_ddl_007_too_many_index_columns(self, engine): - cols = ", ".join([f"col{i}" for i in range(10)]) - sql = f"CREATE TABLE t (id INT PRIMARY KEY, KEY idx_big ({cols}));" - report = await engine.audit(sql) - findings = [f for f in report.findings if f.rule_code == "DDL-007"] - assert len(findings) >= 1 - - # DDL-008: 禁止使用 FLOAT/DOUBLE - @pytest.mark.asyncio - async def test_ddl_008_float_type(self, engine): - report = await engine.audit( - "CREATE TABLE t (id INT PRIMARY KEY, price FLOAT);" - ) - findings = [f for f in report.findings if f.rule_code == "DDL-008"] - assert len(findings) >= 1 - - # DDL-009: CHAR 类型长度不得超过10 - @pytest.mark.asyncio - async def test_ddl_009_char_too_long(self, engine): - report = await engine.audit( - "CREATE TABLE t (id INT PRIMARY KEY, code CHAR(20));" - ) - findings = [f for f in report.findings if f.rule_code == "DDL-009"] - assert len(findings) >= 1 - - # DDL-010: 表名长度不得超过45字符 - @pytest.mark.asyncio - async def test_ddl_010_long_table_name(self, engine): - long_name = "a" * 50 - report = await engine.audit( - f"CREATE TABLE {long_name} (id INT PRIMARY KEY);" - ) - findings = [f for f in report.findings if f.rule_code == "DDL-010"] - assert len(findings) >= 1 - - # DDL-011: 禁止外键约束 - @pytest.mark.asyncio - async def test_ddl_011_foreign_key(self, engine): - sql = """CREATE TABLE orders ( - id INT PRIMARY KEY, - user_id INT, - FOREIGN KEY (user_id) REFERENCES users(id) - );""" - report = await engine.audit(sql) - findings = [f for f in report.findings if f.rule_code == "DDL-011"] - assert len(findings) >= 1 - - # DDL-012: 必须包含 create_time/update_time - @pytest.mark.asyncio - async def test_ddl_012_missing_audit_fields(self, engine): - report = await engine.audit( - "CREATE TABLE t (id INT PRIMARY KEY, name VARCHAR(50));" - ) - findings = [f for f in report.findings if f.rule_code == "DDL-012"] - assert len(findings) >= 1 - - @pytest.mark.asyncio - async def test_ddl_012_has_audit_fields(self, engine): - report = await engine.audit( - "CREATE TABLE t (id INT PRIMARY KEY, name VARCHAR(50), create_time DATETIME, update_time DATETIME);" - ) - findings = [f for f in report.findings if f.rule_code == "DDL-012"] - assert len(findings) == 0 - - -# ────────────────────────────────────────────── -# DML 规则测试 -# ────────────────────────────────────────────── - - -class TestDMLRules: - """DML 规则测试 (DML-001 ~ DML-008)。""" - - @pytest.fixture - def engine(self): - return SQLAuditEngineV2(db_session=None, env="development") - - # DML-001: UPDATE/DELETE 必须包含 WHERE - @pytest.mark.asyncio - async def test_dml_001_update_no_where(self, engine): - report = await engine.audit("UPDATE users SET name = 'test';") - findings = [f for f in report.findings if f.rule_code == "DML-001"] - assert len(findings) >= 1 - assert findings[0].level == Severity.ERROR - - @pytest.mark.asyncio - async def test_dml_001_delete_no_where(self, engine): - report = await engine.audit("DELETE FROM users;") - findings = [f for f in report.findings if f.rule_code == "DML-001"] - assert len(findings) >= 1 - assert findings[0].level == Severity.ERROR - - @pytest.mark.asyncio - async def test_dml_001_update_with_where(self, engine): - report = await engine.audit("UPDATE users SET name = 'test' WHERE id = 1;") - findings = [f for f in report.findings if f.rule_code == "DML-001"] - assert len(findings) == 0 - - # DML-003: UPDATE/DELETE 禁止带 LIMIT - @pytest.mark.asyncio - async def test_dml_003_update_with_limit(self, engine): - report = await engine.audit("UPDATE users SET name = 'test' WHERE id = 1 LIMIT 10;") - findings = [f for f in report.findings if f.rule_code == "DML-003"] - assert len(findings) >= 1 - - # DML-007: 禁止 REPLACE INTO - @pytest.mark.asyncio - async def test_dml_007_replace_into(self, engine): - report = await engine.audit("REPLACE INTO users (id, name) VALUES (1, 'test');") - findings = [f for f in report.findings if f.rule_code == "DML-007"] - assert len(findings) >= 1 - assert findings[0].level == Severity.ERROR - - # DML-008: INSERT 必须指定字段列表 - @pytest.mark.asyncio - async def test_dml_008_insert_no_columns(self, engine): - report = await engine.audit("INSERT INTO users VALUES (1, 'test');") - findings = [f for f in report.findings if f.rule_code == "DML-008"] - assert len(findings) >= 1 - - @pytest.mark.asyncio - async def test_dml_008_insert_with_columns(self, engine): - report = await engine.audit("INSERT INTO users (id, name) VALUES (1, 'test');") - findings = [f for f in report.findings if f.rule_code == "DML-008"] - assert len(findings) == 0 - - -# ────────────────────────────────────────────── -# DQL 规则测试 -# ────────────────────────────────────────────── - - -class TestDQLRules: - """DQL 规则测试 (DQL-001 ~ DQL-005)。""" - - @pytest.fixture - def engine(self): - return SQLAuditEngineV2(db_session=None, env="development") - - # DQL-001: 禁止 SELECT * - @pytest.mark.asyncio - async def test_dql_001_select_star(self, engine): - report = await engine.audit("SELECT * FROM users;") - findings = [f for f in report.findings if f.rule_code == "DQL-001"] - assert len(findings) >= 1 - - @pytest.mark.asyncio - async def test_dql_001_select_columns(self, engine): - report = await engine.audit("SELECT id, name FROM users;") - findings = [f for f in report.findings if f.rule_code == "DQL-001"] - assert len(findings) == 0 - - # DQL-002: SELECT 必须有 LIMIT - @pytest.mark.asyncio - async def test_dql_002_no_limit(self, engine): - report = await engine.audit("SELECT id, name FROM users;") - findings = [f for f in report.findings if f.rule_code == "DQL-002"] - assert len(findings) >= 1 - - @pytest.mark.asyncio - async def test_dql_002_has_limit(self, engine): - report = await engine.audit("SELECT id, name FROM users LIMIT 10;") - findings = [f for f in report.findings if f.rule_code == "DQL-002"] - assert len(findings) == 0 - - # DQL-003: 禁止在 WHERE 中使用函数 - @pytest.mark.asyncio - async def test_dql_003_where_function(self, engine): - report = await engine.audit("SELECT id FROM users WHERE YEAR(created_at) = 2024;") - findings = [f for f in report.findings if f.rule_code == "DQL-003"] - # 只要检测到 WHERE + 函数模式 - assert len(findings) >= 1 - - # DQL-005: ORDER BY 必须使用索引字段 - @pytest.mark.asyncio - async def test_dql_005_order_by(self, engine): - report = await engine.audit("SELECT id FROM users ORDER BY name;") - findings = [f for f in report.findings if f.rule_code == "DQL-005"] - assert len(findings) >= 1 - - -# ────────────────────────────────────────────── -# 命名规范测试 -# ────────────────────────────────────────────── - - -class TestNamingRules: - """命名规范测试 (NAME-001 ~ NAME-005)。""" - - @pytest.fixture - def engine(self): - return SQLAuditEngineV2(db_session=None, env="development") - - # NAME-001: 表名必须小写下划线命名 - @pytest.mark.asyncio - async def test_name_001_uppercase_table(self, engine): - report = await engine.audit("CREATE TABLE UserInfo (id INT PRIMARY KEY);") - findings = [f for f in report.findings if f.rule_code == "NAME-001"] - assert len(findings) >= 1 - - @pytest.mark.asyncio - async def test_name_001_snake_case_table(self, engine): - report = await engine.audit("CREATE TABLE user_info (id INT PRIMARY KEY);") - findings = [f for f in report.findings if f.rule_code == "NAME-001"] - assert len(findings) == 0 - - # NAME-002: 字段名必须小写下划线命名 - @pytest.mark.asyncio - async def test_name_002_uppercase_column(self, engine): - report = await engine.audit( - "CREATE TABLE t (id INT PRIMARY KEY, UserName VARCHAR(50));" - ) - findings = [f for f in report.findings if f.rule_code == "NAME-002"] - assert len(findings) >= 1 - - # NAME-004: 主键名必须为 id 或 pk_ 开头 - @pytest.mark.asyncio - async def test_name_004_bad_pk_name(self, engine): - report = await engine.audit( - "CREATE TABLE t (user_id INT PRIMARY KEY, name VARCHAR(50));" - ) - findings = [f for f in report.findings if f.rule_code == "NAME-004"] - assert len(findings) >= 1 - - -# ────────────────────────────────────────────── -# 性能规则测试 -# ────────────────────────────────────────────── - - -class TestPerfRules: - """性能规则测试 (PERF-001 ~ PERF-008)。""" - - @pytest.fixture - def engine(self): - return SQLAuditEngineV2(db_session=None, env="development") - - # PERF-002: IN 子句值数量不得超过100个 - @pytest.mark.asyncio - async def test_perf_002_large_in(self, engine): - values = ", ".join([str(i) for i in range(150)]) - sql = f"SELECT id FROM users WHERE id IN ({values});" - report = await engine.audit(sql) - findings = [f for f in report.findings if f.rule_code == "PERF-002"] - assert len(findings) >= 1 - - # PERF-003: 禁止 LIKE '%xxx%' - @pytest.mark.asyncio - async def test_perf_003_full_like(self, engine): - report = await engine.audit("SELECT id FROM users WHERE name LIKE '%test%';") - findings = [f for f in report.findings if f.rule_code == "PERF-003"] - assert len(findings) >= 1 - - # PERF-005: 禁止 ORDER BY RAND() - @pytest.mark.asyncio - async def test_perf_005_order_by_rand(self, engine): - report = await engine.audit("SELECT id FROM users ORDER BY RAND() LIMIT 10;") - findings = [f for f in report.findings if f.rule_code == "PERF-005"] - assert len(findings) >= 1 - assert findings[0].level == Severity.ERROR - - # PERF-007: 大表禁止 OFFSET 分页 - @pytest.mark.asyncio - async def test_perf_007_offset(self, engine): - report = await engine.audit("SELECT id FROM users LIMIT 10 OFFSET 1000;") - findings = [f for f in report.findings if f.rule_code == "PERF-007"] - assert len(findings) >= 1 - - # PERF-008: 禁止 SELECT DISTINCT - @pytest.mark.asyncio - async def test_perf_008_distinct(self, engine): - report = await engine.audit("SELECT DISTINCT name FROM users;") - findings = [f for f in report.findings if f.rule_code == "PERF-008"] - assert len(findings) >= 1 - - -# ────────────────────────────────────────────── -# 安全规则测试 -# ────────────────────────────────────────────── - - -class TestSecurityRules: - """安全规则测试 (SEC-001 ~ SEC-005)。""" - - @pytest.fixture - def engine(self): - return SQLAuditEngineV2(db_session=None, env="development") - - # SEC-001: 禁止查询敏感字段 - @pytest.mark.asyncio - async def test_sec_001_sensitive_fields(self, engine): - report = await engine.audit("SELECT id, password, secret FROM users;") - findings = [f for f in report.findings if f.rule_code == "SEC-001"] - assert len(findings) >= 1 - assert findings[0].level == Severity.ERROR - - # SEC-003: 禁止 UNION - @pytest.mark.asyncio - async def test_sec_003_union(self, engine): - report = await engine.audit("SELECT id FROM users UNION SELECT id FROM orders;") - findings = [f for f in report.findings if f.rule_code == "SEC-003"] - assert len(findings) >= 1 - assert findings[0].level == Severity.ERROR - - # SEC-004: 禁止注释符 - @pytest.mark.asyncio - async def test_sec_004_comment_dash(self, engine): - report = await engine.audit("SELECT id FROM users WHERE id = 1; -- comment") - findings = [f for f in report.findings if f.rule_code == "SEC-004"] - assert len(findings) >= 1 - - @pytest.mark.asyncio - async def test_sec_004_comment_block(self, engine): - report = await engine.audit("SELECT id /* comment */ FROM users;") - findings = [f for f in report.findings if f.rule_code == "SEC-004"] - assert len(findings) >= 1 - - # SEC-005: 禁止多语句 - @pytest.mark.asyncio - async def test_sec_005_multi_statement(self, engine): - report = await engine.audit("UPDATE users SET name = 'a'; DELETE FROM logs;") - findings = [f for f in report.findings if f.rule_code == "SEC-005"] - assert len(findings) >= 1 - assert findings[0].level == Severity.ERROR - - -# ────────────────────────────────────────────── -# 数据类测试 -# ────────────────────────────────────────────── - - -class TestAuditDataClasses: - """数据类单元测试。""" - - def test_audit_rule_v2_dataclass(self): - """AuditRuleV2 数据类应正常工作。""" - rule = AuditRuleV2( - code="DDL-001", - name="测试规则", - category=RuleCategory.DDL, - severity=Severity.ERROR, - ) - assert rule.code == "DDL-001" - assert rule.compiled_pattern is None - - def test_audit_rule_v2_pattern_compile(self): - """AuditRuleV2 应能编译正则表达式。""" - rule = AuditRuleV2( - code="TEST-001", - name="测试规则", - category=RuleCategory.DDL, - severity=Severity.WARNING, - pattern=r"^test_", - ) - assert rule.compiled_pattern is not None - assert rule.compiled_pattern.match("test_table") - - def test_audit_finding_v2(self): - """AuditFindingV2 数据类应正常工作。""" - finding = AuditFindingV2( - rule_code="DDL-001", - rule_name="测试规则", - level=Severity.ERROR, - message="测试消息", - position=10, - ) - assert finding.rule_code == "DDL-001" - assert finding.position == 10 - - def test_audit_report_v2_score_calculation(self): - """AuditReportV2 评分计算应正确。""" - report = AuditReportV2(sql="SELECT * FROM t;") - report.findings = [ - AuditFindingV2("DQL-001", "禁止SELECT *", Severity.INFO, "msg1"), - AuditFindingV2("DDL-004", "禁止DROP TABLE", Severity.ERROR, "msg2"), - ] - assert report.score == 88 # 100 - 10(ERROR) - 2(INFO) = 88 - assert not report.passed - - def test_audit_report_v2_empty_findings(self): - """无发现项时评分应为100且通过。""" - report = AuditReportV2(sql="SELECT id FROM t WHERE id = 1;") - assert report.score == 100 - assert report.passed - assert report.summary == "审核通过,未发现违规项" - - def test_audit_report_backward_compat(self): - """AuditReportV2 应向后兼容 AuditReport 格式。""" - report = AuditReportV2(sql="UPDATE t SET a = 1;") - report.findings.append( - AuditFindingV2("DML-001", "缺少WHERE", Severity.ERROR, "msg") - ) - d = report.to_dict() - assert "passed" in d - assert "score" in d - assert "findings" in d - # 检查旧版格式兼容性 - assert d["findings"][0]["rule"] == "DML-001" - assert d["findings"][0]["level"] == "error" - - -# ────────────────────────────────────────────── -# API 集成测试 -# ────────────────────────────────────────────── - - -class TestAuditEngineV2Integration: - """V2 引擎与 API 集成测试。""" - - @pytest.mark.asyncio - async def test_ticket_creation_uses_v2_engine(self, client, db, admin_token, test_instance): - """创建工单时应使用 V2 引擎进行审核。""" - from tests.conftest import auth_header - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "V2引擎测试工单", - "db_type": "mysql", - "instance_id": test_instance.id, - "target_database": "test_db", - "sql_content": "SELECT * FROM users WHERE id = 1;", - "reason": "测试V2审核引擎", - "priority": "medium", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - assert "audit_report" in data - report = data["audit_report"] - assert "score" in report - assert "findings" in report - assert "summary" in report - # SELECT * 应触发 DQL-001 (INFO) - rules = [f["rule"] for f in report["findings"]] - assert "DQL-001" in rules or "DQL-002" in rules - - @pytest.mark.asyncio - async def test_audit_endpoint_returns_v2_format(self, client, db, admin_token, test_instance): - """审核接口应返回 V2 格式报告。""" - from tests.conftest import auth_header - resp = await client.post( - "/api/v1/tickets/audit", - json={ - "sql_content": "UPDATE users SET status = 1 WHERE id = 1;", - "instance_id": test_instance.id, - "database": "test_db", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert "score" in data - assert "passed" in data - assert "summary" in data - assert "findings" in data - - @pytest.mark.asyncio - async def test_audit_endpoint_detects_dangerous_sql(self, client, db, admin_token, test_instance): - """审核接口应能检测危险 SQL。""" - from tests.conftest import auth_header - resp = await client.post( - "/api/v1/tickets/audit", - json={ - "sql_content": "DROP TABLE users;", - "instance_id": test_instance.id, - "database": "test_db", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["passed"] is False - rules = [f["rule"] for f in data["findings"]] - assert "DDL-004" in rules - - @pytest.mark.asyncio - async def test_seed_v2_endpoint(self, client, db, admin_token): - """seed-v2 接口应能初始化所有 V2 规则。""" - from tests.conftest import auth_header - resp = await client.post( - "/api/v1/audit-rules/seed-v2", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert isinstance(data, list) - assert len(data) >= 40 # 应创建至少40条规则 - - # 再次调用应跳过已存在的规则 - resp2 = await client.post( - "/api/v1/audit-rules/seed-v2", - headers=auth_header(admin_token), - ) - assert resp2.status_code == 200 - data2 = resp2.json() - assert len(data2) == 0 # 都已存在,不再创建 - - -class TestAllRulesRegistered: - """验证所有要求的规则都已注册。""" - - def test_ddl_rules_registered(self): - """DDL 规则应全部注册。""" - from app.services.audit_engine_v2 import get_all_registered_rules - rules = get_all_registered_rules() - codes = [r.code for r in rules] - for i in range(1, 13): - assert f"DDL-{i:03d}" in codes, f"DDL-{i:03d} 未注册" - - def test_dml_rules_registered(self): - """DML 规则应全部注册。""" - from app.services.audit_engine_v2 import get_all_registered_rules - rules = get_all_registered_rules() - codes = [r.code for r in rules] - for i in range(1, 9): - assert f"DML-{i:03d}" in codes, f"DML-{i:03d} 未注册" - - def test_dql_rules_registered(self): - """DQL 规则应全部注册。""" - from app.services.audit_engine_v2 import get_all_registered_rules - rules = get_all_registered_rules() - codes = [r.code for r in rules] - for i in range(1, 6): - assert f"DQL-{i:03d}" in codes, f"DQL-{i:03d} 未注册" - - def test_naming_rules_registered(self): - """命名规范规则应全部注册。""" - from app.services.audit_engine_v2 import get_all_registered_rules - rules = get_all_registered_rules() - codes = [r.code for r in rules] - for i in range(1, 6): - assert f"NAME-{i:03d}" in codes, f"NAME-{i:03d} 未注册" - - def test_perf_rules_registered(self): - """性能规则应全部注册。""" - from app.services.audit_engine_v2 import get_all_registered_rules - rules = get_all_registered_rules() - codes = [r.code for r in rules] - for i in range(1, 9): - assert f"PERF-{i:03d}" in codes, f"PERF-{i:03d} 未注册" - - def test_security_rules_registered(self): - """安全规则应全部注册。""" - from app.services.audit_engine_v2 import get_all_registered_rules - rules = get_all_registered_rules() - codes = [r.code for r in rules] - for i in range(1, 6): - assert f"SEC-{i:03d}" in codes, f"SEC-{i:03d} 未注册" diff --git a/backend/tests/test_audit_logs.py b/backend/tests/test_audit_logs.py deleted file mode 100644 index 82f0116..0000000 --- a/backend/tests/test_audit_logs.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Tests for Audit Log API (Phase 3.4).""" -import pytest -from tests.conftest import auth_header - - -class TestAuditLogAPI: - @pytest.mark.asyncio - async def test_list_audit_logs_admin(self, client, admin_token): - """Admin can list audit logs.""" - resp = await client.get( - "/api/v1/audit/logs", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert "items" in data - assert "total" in data - assert "page" in data - - @pytest.mark.asyncio - async def test_list_audit_logs_dba(self, client, dba_token): - """DBA can also list audit logs.""" - resp = await client.get( - "/api/v1/audit/logs", - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - @pytest.mark.asyncio - async def test_list_audit_logs_developer_forbidden(self, client, developer_token): - """Developer cannot access audit logs.""" - resp = await client.get( - "/api/v1/audit/logs", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_create_instance_logs_audit(self, client, db, admin_token): - """Creating an instance produces an audit log entry.""" - from app.models.audit_log import AuditLog - from sqlalchemy import select - - resp = await client.post( - "/api/v1/instances", - json={ - "name": "审计测试实例", - "db_type": "mysql", - "host": "10.0.0.100", - "port": 3306, - "username": "root", - "password": "test123", - "database": "test", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - - # Check audit log was created - result = await db.execute( - select(AuditLog).where(AuditLog.action == "CREATE_INSTANCE") - ) - log = result.scalar_one_or_none() - assert log is not None - assert "审计测试实例" in log.detail - - @pytest.mark.asyncio - async def test_create_user_logs_audit(self, client, admin_token): - """Creating a user produces an audit log entry.""" - resp = await client.post( - "/api/v1/users", - json={ - "username": "audit_test_user", - "display_name": "审计测试", - "password": "test123456", - "role": "developer", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - # Audit middleware writes to its own session; verified via API in integration tests - # Here we just confirm the create succeeded - - @pytest.mark.asyncio - async def test_create_ticket_logs_audit(self, client, admin_token, test_instance): - """Creating a ticket produces an audit log entry.""" - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "审计日志工单测试", - "db_type": "mysql", - "instance_id": test_instance.id, - "target_database": "test_db", - "sql_content": "SELECT id FROM users LIMIT 10;", - "reason": "测试审计日志", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - # Audit middleware writes to its own session; verified via API in integration tests - - @pytest.mark.asyncio - async def test_audit_log_pagination(self, client, admin_token): - """Audit log pagination works.""" - resp = await client.get( - "/api/v1/audit/logs?page=1&page_size=5", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["page_size"] == 5 - - @pytest.mark.asyncio - async def test_audit_log_filter_by_action(self, client, admin_token, db, test_instance): - """Can filter audit logs by action.""" - # Create a ticket first - await client.post( - "/api/v1/tickets", - json={ - "title": "过滤测试工单", - "db_type": "mysql", - "instance_id": test_instance.id, - "target_database": "test_db", - "sql_content": "SELECT 1;", - "reason": "过滤测试用", - }, - headers=auth_header(admin_token), - ) - - resp = await client.get( - "/api/v1/audit/logs?action=CREATE_TICKET", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - for item in data["items"]: - assert item["action"] == "CREATE_TICKET" - - @pytest.mark.asyncio - async def test_review_ticket_logs_audit(self, client, db, admin_token, dba_token, test_instance): - """Reviewing a ticket creates an audit log.""" - from app.models.audit_log import AuditLog - from sqlalchemy import select - - # Create ticket as admin - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "审核日志测试", - "db_type": "mysql", - "instance_id": test_instance.id, - "target_database": "test_db", - "sql_content": "SELECT 1;", - "reason": "审核日志测试", - }, - headers=auth_header(admin_token), - ) - ticket_id = resp.json()["id"] - - # Review as DBA - resp = await client.post( - f"/api/v1/tickets/{ticket_id}/review", - json={"action": "approve", "comment": "批准"}, - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - result = await db.execute( - select(AuditLog).where(AuditLog.action == "REVIEW_TICKET") - ) - log = result.scalar_one_or_none() - assert log is not None - assert "approve" in log.detail diff --git a/backend/tests/test_audit_rule.py b/backend/tests/test_audit_rule.py deleted file mode 100644 index 2409740..0000000 --- a/backend/tests/test_audit_rule.py +++ /dev/null @@ -1,759 +0,0 @@ -"""审核规则管理端点全面测试。 - -覆盖所有 9 个端点的正常/异常路径,包括: - - 权限校验(admin 可读写, dba 只读, developer/guest 禁止访问) - - 列表过滤(rule_type, severity, db_type, is_enabled) - - 创建/更新/删除/切换启用状态 - - 统计摘要 - - 种子数据(V1 seed/{db_type} 和 V2 seed-v2) -""" -import pytest -from tests.conftest import auth_header - -# ── 通用测试载荷 ────────────────────────────────────────────── -VALID_RULE_PAYLOAD = { - "name": "禁止SELECT星号", - "description": "禁止使用 SELECT * 查询", - "rule_type": "performance", - "severity": "warning", - "db_type": "mysql", - "pattern": r"SELECT\s+\*", - "value": None, - "is_enabled": True, - "message": "禁止使用 SELECT *,请明确指定字段", -} - -PREFIX = "/api/v1/audit-rules" - - -# ══════════════════════════════════════════════════════════════ -# 一、列表端点 GET /api/v1/audit-rules -# ══════════════════════════════════════════════════════════════ - - -class TestListAuditRules: - """GET /audit-rules — 列表 & 过滤""" - - @pytest.mark.asyncio - async def test_admin列表返回200(self, client, admin_token): - """admin 角色可正常获取列表""" - resp = await client.get(PREFIX, headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert isinstance(resp.json(), list) - - @pytest.mark.asyncio - async def test_dba列表返回200(self, client, dba_token): - """dba 角色可正常获取列表(只读权限)""" - resp = await client.get(PREFIX, headers=auth_header(dba_token)) - assert resp.status_code == 200 - - @pytest.mark.asyncio - async def test_developer无权限返回403(self, client, developer_token): - """developer 角色无权限""" - resp = await client.get(PREFIX, headers=auth_header(developer_token)) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_guest无权限返回403(self, client, guest_token): - """guest 角色无权限""" - resp = await client.get(PREFIX, headers=auth_header(guest_token)) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test无token返回401(self, client): - """未携带 JWT 应返回 401""" - resp = await client.get(PREFIX) - assert resp.status_code == 401 - - @pytest.mark.asyncio - async def test按rule_type过滤(self, client, admin_token): - """通过 rule_type 查询参数过滤""" - # 创建一条 performance 规则 - await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "perf-rule"}, - headers=auth_header(admin_token), - ) - resp = await client.get( - f"{PREFIX}?rule_type=performance", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - for rule in resp.json(): - assert rule["rule_type"] == "performance" - - @pytest.mark.asyncio - async def test按severity过滤(self, client, admin_token): - """通过 severity 查询参数过滤""" - await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "sev-rule", "severity": "error"}, - headers=auth_header(admin_token), - ) - resp = await client.get( - f"{PREFIX}?severity=error", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - for rule in resp.json(): - assert rule["severity"] == "error" - - @pytest.mark.asyncio - async def test按db_type过滤(self, client, admin_token): - """通过 db_type 过滤,应同时包含 db_type='all' 的规则""" - await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "mysql-rule", "db_type": "mysql"}, - headers=auth_header(admin_token), - ) - resp = await client.get( - f"{PREFIX}?db_type=mysql", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - for rule in resp.json(): - assert rule["db_type"] in ("mysql", "all") - - @pytest.mark.asyncio - async def test按is_enabled过滤(self, client, admin_token): - """通过 is_enabled 过滤已启用/已禁用规则""" - # 创建一条规则后禁用 - create = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "disabled-rule"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - await client.post( - f"{PREFIX}/{rule_id}/toggle", headers=auth_header(admin_token) - ) - - # 只查已禁用的 - resp = await client.get( - f"{PREFIX}?is_enabled=false", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - for rule in resp.json(): - assert rule["is_enabled"] is False - - @pytest.mark.asyncio - async def test多条件联合过滤(self, client, admin_token): - """同时传入多个过滤参数""" - await client.post( - PREFIX, - json={ - **VALID_RULE_PAYLOAD, - "name": "multi-filter", - "rule_type": "DDL", - "severity": "error", - }, - headers=auth_header(admin_token), - ) - resp = await client.get( - f"{PREFIX}?rule_type=DDL&severity=error&is_enabled=true", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - for rule in resp.json(): - assert rule["rule_type"] == "DDL" - assert rule["severity"] == "error" - assert rule["is_enabled"] is True - - @pytest.mark.asyncio - async def test空数据库返回空列表(self, client, admin_token): - """无数据时返回空列表""" - resp = await client.get(PREFIX, headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert resp.json() == [] - - -# ══════════════════════════════════════════════════════════════ -# 二、创建端点 POST /api/v1/audit-rules -# ══════════════════════════════════════════════════════════════ - - -class TestCreateAuditRule: - """POST /audit-rules — 创建规则""" - - @pytest.mark.asyncio - async def test创建成功返回201(self, client, admin_token): - """正常创建,返回 201 + 完整对象""" - resp = await client.post( - PREFIX, json=VALID_RULE_PAYLOAD, headers=auth_header(admin_token) - ) - assert resp.status_code == 201 - data = resp.json() - assert data["id"] is not None - assert data["name"] == VALID_RULE_PAYLOAD["name"] - assert data["rule_type"] == "performance" - assert data["severity"] == "warning" - assert data["is_enabled"] is True - assert "created_at" in data - assert "updated_at" in data - - @pytest.mark.asyncio - async def test重复名称返回400(self, client, admin_token): - """同名规则不允许重复创建""" - payload = {**VALID_RULE_PAYLOAD, "name": "dup-name"} - await client.post(PREFIX, json=payload, headers=auth_header(admin_token)) - resp = await client.post(PREFIX, json=payload, headers=auth_header(admin_token)) - assert resp.status_code == 400 - assert "已存在" in resp.json()["detail"] - - @pytest.mark.asyncio - async def test_dba创建返回403(self, client, dba_token): - """dba 只有只读权限,不能创建""" - resp = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "dba-create"}, - headers=auth_header(dba_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_developer创建返回403(self, client, developer_token): - """developer 无权限""" - resp = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "dev-create"}, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test创建时仅必填字段(self, client, admin_token): - """只提供 name/rule_type/message 三个必填字段也可成功""" - resp = await client.post( - PREFIX, - json={ - "name": "minimal-rule", - "rule_type": "syntax", - "message": "语法检查", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - assert data["severity"] == "warning" # 默认值 - assert data["db_type"] == "mysql" # 默认值 - assert data["is_enabled"] is True # 默认值 - - @pytest.mark.asyncio - async def test创建全部rule_type合法(self, client, admin_token): - """所有合法 rule_type 枚举均可创建""" - for rt in ("syntax", "performance", "safety", "naming", "DDL", "DML"): - resp = await client.post( - PREFIX, - json={ - "name": f"rt-{rt}", - "rule_type": rt, - "message": f"类型{rt}规则", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201, f"rule_type={rt} 创建失败" - - @pytest.mark.asyncio - async def test创建全部severity合法(self, client, admin_token): - """所有合法 severity 枚举均可创建""" - for sev in ("error", "warning", "info"): - resp = await client.post( - PREFIX, - json={ - "name": f"sev-{sev}", - "rule_type": "syntax", - "severity": sev, - "message": f"严重度{sev}", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201, f"severity={sev} 创建失败" - - @pytest.mark.asyncio - async def test非法rule_type返回422(self, client, admin_token): - """非法 rule_type 应触发 Pydantic 校验错误""" - resp = await client.post( - PREFIX, - json={ - "name": "bad-type", - "rule_type": "invalid_type", - "message": "测试", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 422 - - @pytest.mark.asyncio - async def test缺少必填字段返回422(self, client, admin_token): - """缺少 name 字段""" - resp = await client.post( - PREFIX, - json={"rule_type": "syntax", "message": "测试"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 422 - - @pytest.mark.asyncio - async def test创建带value和pattern(self, client, admin_token): - """附带 pattern 和 value 的规则可正常创建""" - resp = await client.post( - PREFIX, - json={ - "name": "pattern-rule", - "rule_type": "safety", - "pattern": r"DROP\s+TABLE", - "value": "100", - "message": "禁止删表", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - assert resp.json()["pattern"] == r"DROP\s+TABLE" - assert resp.json()["value"] == "100" - - -# ══════════════════════════════════════════════════════════════ -# 三、统计摘要 GET /api/v1/audit-rules/stats/summary -# ══════════════════════════════════════════════════════════════ - - -class TestRuleStats: - """GET /audit-rules/stats/summary — 统计摘要""" - - @pytest.mark.asyncio - async def test空数据库统计(self, client, admin_token): - """无数据时 total=0""" - resp = await client.get(f"{PREFIX}/stats/summary", headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 0 - assert data["enabled"] == 0 - assert data["disabled"] == 0 - - @pytest.mark.asyncio - async def test统计数据正确(self, client, admin_token): - """创建规则后统计数据应更新""" - # 创建2条启用 - await client.post( - PREFIX, - json={"name": "stat1", "rule_type": "DDL", "message": "t1"}, - headers=auth_header(admin_token), - ) - await client.post( - PREFIX, - json={"name": "stat2", "rule_type": "DML", "message": "t2"}, - headers=auth_header(admin_token), - ) - # 创建1条后禁用 - create = await client.post( - PREFIX, - json={"name": "stat3", "rule_type": "DDL", "message": "t3"}, - headers=auth_header(admin_token), - ) - rid = create.json()["id"] - await client.post(f"{PREFIX}/{rid}/toggle", headers=auth_header(admin_token)) - - resp = await client.get(f"{PREFIX}/stats/summary", headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 3 - assert data["enabled"] == 2 - assert data["disabled"] == 1 - assert data["by_type"]["DDL"] == 2 - assert data["by_type"]["DML"] == 1 - - @pytest.mark.asyncio - async def test_dba可查看统计(self, client, dba_token): - """dba 有只读权限""" - resp = await client.get(f"{PREFIX}/stats/summary", headers=auth_header(dba_token)) - assert resp.status_code == 200 - - @pytest.mark.asyncio - async def test_developer不可查看统计(self, client, developer_token): - """developer 无权限""" - resp = await client.get(f"{PREFIX}/stats/summary", headers=auth_header(developer_token)) - assert resp.status_code == 403 - - -# ══════════════════════════════════════════════════════════════ -# 四、种子数据 V1 POST /api/v1/audit-rules/seed/{db_type} -# ══════════════════════════════════════════════════════════════ - - -class TestSeedAuditRules: - """POST /audit-rules/seed/{db_type} — 批量种子数据""" - - @pytest.mark.asyncio - async def test_mysql种子数据创建成功(self, client, admin_token): - """MySQL 种子数据可正常导入""" - resp = await client.post(f"{PREFIX}/seed/mysql", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert isinstance(resp.json(), list) - assert len(resp.json()) > 0 - - @pytest.mark.asyncio - async def test_postgres种子数据创建成功(self, client, admin_token): - """PostgreSQL 种子数据可正常导入""" - resp = await client.post(f"{PREFIX}/seed/postgres", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert len(resp.json()) > 0 - - @pytest.mark.asyncio - async def test_redis种子数据创建成功(self, client, admin_token): - """Redis 种子数据可正常导入""" - resp = await client.post(f"{PREFIX}/seed/redis", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert len(resp.json()) > 0 - - @pytest.mark.asyncio - async def test不支持的db_type返回400(self, client, admin_token): - """不支持的数据库类型应返回 400""" - resp = await client.post(f"{PREFIX}/seed/oracle", headers=auth_header(admin_token)) - assert resp.status_code == 400 - assert "不支持的数据库类型" in resp.json()["detail"] - - @pytest.mark.asyncio - async def test_dba无种子写入权限(self, client, dba_token): - """dba 无写权限""" - resp = await client.post(f"{PREFIX}/seed/mysql", headers=auth_header(dba_token)) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test重复种子数据跳过已存在(self, client, admin_token): - """第二次导入相同种子时,已存在的规则会被跳过""" - r1 = await client.post(f"{PREFIX}/seed/mysql", headers=auth_header(admin_token)) - r2 = await client.post(f"{PREFIX}/seed/mysql", headers=auth_header(admin_token)) - assert r1.status_code == 200 - assert r2.status_code == 200 - # 第二次不应创建新规则(列表应为空) - assert len(r2.json()) == 0 - - -# ══════════════════════════════════════════════════════════════ -# 五、获取单条 GET /api/v1/audit-rules/{rule_id} -# ══════════════════════════════════════════════════════════════ - - -class TestGetAuditRule: - """GET /audit-rules/{rule_id} — 获取单条""" - - @pytest.mark.asyncio - async def test获取存在的规则(self, client, admin_token): - """正常获取已创建的规则""" - create = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "get-one"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - resp = await client.get( - f"{PREFIX}/{rule_id}", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "get-one" - - @pytest.mark.asyncio - async def test获取不存在的规则返回404(self, client, admin_token): - """不存在的 ID 返回 404""" - resp = await client.get(f"{PREFIX}/99999", headers=auth_header(admin_token)) - assert resp.status_code == 404 - assert "不存在" in resp.json()["detail"] - - @pytest.mark.asyncio - async def test_dba可获取规则(self, client, admin_token, dba_token): - """dba 有只读权限""" - create = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "dba-get"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - resp = await client.get( - f"{PREFIX}/{rule_id}", headers=auth_header(dba_token) - ) - assert resp.status_code == 200 - - @pytest.mark.asyncio - async def test_developer不可获取规则(self, client, admin_token, developer_token): - """developer 无权限""" - create = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "dev-get"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - resp = await client.get( - f"{PREFIX}/{rule_id}", headers=auth_header(developer_token) - ) - assert resp.status_code == 403 - - -# ══════════════════════════════════════════════════════════════ -# 六、更新 PUT /api/v1/audit-rules/{rule_id} -# ══════════════════════════════════════════════════════════════ - - -class TestUpdateAuditRule: - """PUT /audit-rules/{rule_id} — 更新规则""" - - @pytest.mark.asyncio - async def test更新部分字段成功(self, client, admin_token): - """只更新 severity 和 message""" - create = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "update-partial"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - resp = await client.put( - f"{PREFIX}/{rule_id}", - json={"severity": "error", "message": "更新后的消息"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["severity"] == "error" - assert data["message"] == "更新后的消息" - # 未修改的字段保持不变 - assert data["name"] == "update-partial" - assert data["rule_type"] == "performance" - - @pytest.mark.asyncio - async def test更新不存在的规则返回404(self, client, admin_token): - """更新不存在的 ID""" - resp = await client.put( - f"{PREFIX}/99999", - json={"severity": "info"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_dba无更新权限返回403(self, client, admin_token, dba_token): - """dba 无写权限""" - create = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "dba-update"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - resp = await client.put( - f"{PREFIX}/{rule_id}", - json={"severity": "info"}, - headers=auth_header(dba_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test更新全部字段(self, client, admin_token): - """一次更新所有可修改字段""" - create = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "full-update"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - resp = await client.put( - f"{PREFIX}/{rule_id}", - json={ - "name": "full-update-v2", - "description": "新描述", - "rule_type": "safety", - "severity": "error", - "db_type": "postgres", - "pattern": r"ALTER\s+TABLE", - "value": "200", - "is_enabled": False, - "message": "全新的消息", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["name"] == "full-update-v2" - assert data["description"] == "新描述" - assert data["rule_type"] == "safety" - assert data["severity"] == "error" - assert data["db_type"] == "postgres" - assert data["pattern"] == r"ALTER\s+TABLE" - assert data["value"] == "200" - assert data["is_enabled"] is False - assert data["message"] == "全新的消息" - - @pytest.mark.asyncio - async def test更新is_enabled字段(self, client, admin_token): - """通过 PUT 单独更新 is_enabled""" - create = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "toggle-via-put"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - assert create.json()["is_enabled"] is True - - resp = await client.put( - f"{PREFIX}/{rule_id}", - json={"is_enabled": False}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["is_enabled"] is False - - -# ══════════════════════════════════════════════════════════════ -# 七、删除 DELETE /api/v1/audit-rules/{rule_id} -# ══════════════════════════════════════════════════════════════ - - -class TestDeleteAuditRule: - """DELETE /audit-rules/{rule_id} — 删除规则""" - - @pytest.mark.asyncio - async def test删除成功(self, client, admin_token): - """删除后 GET 应返回 404""" - create = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "del-me"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - resp = await client.delete( - f"{PREFIX}/{rule_id}", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - assert resp.json()["message"] == "规则已删除" - - # 确认已删除 - get = await client.get( - f"{PREFIX}/{rule_id}", headers=auth_header(admin_token) - ) - assert get.status_code == 404 - - @pytest.mark.asyncio - async def test删除不存在的规则返回404(self, client, admin_token): - """删除不存在的 ID""" - resp = await client.delete( - f"{PREFIX}/99999", headers=auth_header(admin_token) - ) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_dba无删除权限返回403(self, client, admin_token, dba_token): - """dba 无删除权限""" - create = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "dba-del"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - resp = await client.delete( - f"{PREFIX}/{rule_id}", headers=auth_header(dba_token) - ) - assert resp.status_code == 403 - - -# ══════════════════════════════════════════════════════════════ -# 八、切换启用 POST /api/v1/audit-rules/{rule_id}/toggle -# ══════════════════════════════════════════════════════════════ - - -class TestToggleAuditRule: - """POST /audit-rules/{rule_id}/toggle — 切换启用/禁用""" - - @pytest.mark.asyncio - async def test切换启用到禁用再恢复(self, client, admin_token): - """连续 toggle 两次:启用→禁用→启用""" - create = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "toggle-twice"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - assert create.json()["is_enabled"] is True - - # 第一次 toggle:禁用 - r1 = await client.post( - f"{PREFIX}/{rule_id}/toggle", headers=auth_header(admin_token) - ) - assert r1.status_code == 200 - assert r1.json()["is_enabled"] is False - - # 第二次 toggle:恢复启用 - r2 = await client.post( - f"{PREFIX}/{rule_id}/toggle", headers=auth_header(admin_token) - ) - assert r2.status_code == 200 - assert r2.json()["is_enabled"] is True - - @pytest.mark.asyncio - async def test切换不存在的规则返回404(self, client, admin_token): - """不存在的 ID 返回 404""" - resp = await client.post( - f"{PREFIX}/99999/toggle", headers=auth_header(admin_token) - ) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_dba可切换规则(self, client, admin_token, dba_token): - """dba 有 toggle 权限(只读类操作中 toggle 是特殊写操作,需确认)""" - create = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "dba-toggle"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - resp = await client.post( - f"{PREFIX}/{rule_id}/toggle", headers=auth_header(dba_token) - ) - # 端点 require_role("admin", "dba") — dba 有权限 - assert resp.status_code == 200 - assert resp.json()["is_enabled"] is False - - @pytest.mark.asyncio - async def test_developer不可切换(self, client, admin_token, developer_token): - """developer 无权限""" - create = await client.post( - PREFIX, - json={**VALID_RULE_PAYLOAD, "name": "dev-toggle"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - resp = await client.post( - f"{PREFIX}/{rule_id}/toggle", headers=auth_header(developer_token) - ) - assert resp.status_code == 403 - - -# ══════════════════════════════════════════════════════════════ -# 九、V2 种子数据 POST /api/v1/audit-rules/seed-v2 -# ══════════════════════════════════════════════════════════════ - - -class TestSeedV2AuditRules: - """POST /audit-rules/seed-v2 — V2 种子数据""" - - @pytest.mark.asyncio - async def test_v2种子数据创建成功(self, client, admin_token): - """V2 种子数据可正常导入""" - resp = await client.post(f"{PREFIX}/seed-v2", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert isinstance(resp.json(), list) - assert len(resp.json()) > 0 - - @pytest.mark.asyncio - async def test_v2种子数据去重(self, client, admin_token): - """重复导入 V2 种子时应跳过已存在的规则""" - await client.post(f"{PREFIX}/seed-v2", headers=auth_header(admin_token)) - resp = await client.post(f"{PREFIX}/seed-v2", headers=auth_header(admin_token)) - assert resp.status_code == 200 - # 第二次不会创建新规则 - assert len(resp.json()) == 0 - - @pytest.mark.asyncio - async def test_dba无v2种子写入权限(self, client, dba_token): - """dba 无写权限""" - resp = await client.post(f"{PREFIX}/seed-v2", headers=auth_header(dba_token)) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_developer无v2种子权限(self, client, developer_token): - """developer 无权限""" - resp = await client.post(f"{PREFIX}/seed-v2", headers=auth_header(developer_token)) - assert resp.status_code == 403 diff --git a/backend/tests/test_audit_rule_seeds.py b/backend/tests/test_audit_rule_seeds.py deleted file mode 100644 index 50c5d06..0000000 --- a/backend/tests/test_audit_rule_seeds.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Tests for Audit Rule seed endpoint and value field (Phase 4.2+).""" -import pytest -from tests.conftest import auth_header - - -class TestSeedAuditRules: - @pytest.mark.asyncio - async def test_seed_mysql_rules(self, client, admin_token): - """MySQL 种子规则批量创建""" - resp = await client.post( - "/api/v1/audit-rules/seed/mysql", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 15 # MySQL 有 15 条预设规则 - names = {r["name"] for r in data} - assert "DDLMultiToCommit" in names - assert "DDLMaxKeyParts" in names - assert "DMLMaxInsertRows" in names - assert "DMLWhere" in names - # 验证 value 字段 - key_parts = next(r for r in data if r["name"] == "DDLMaxKeyParts") - assert key_parts["value"] == "8" - max_key = next(r for r in data if r["name"] == "DDLMaxKey") - assert max_key["value"] == "12" - insert_rows = next(r for r in data if r["name"] == "DMLMaxInsertRows") - assert insert_rows["value"] == "500" - table_name = next(r for r in data if r["name"] == "MaxTableNameLen") - assert table_name["value"] == "45" - - @pytest.mark.asyncio - async def test_seed_postgres_rules(self, client, admin_token): - """PostgreSQL 种子规则批量创建""" - resp = await client.post( - "/api/v1/audit-rules/seed/postgres", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 11 - names = {r["name"] for r in data} - assert "PGDDLMultiToCommit" in names - assert "PGDDLMaxKeyParts" in names - assert "PGMaxTableNameLen" in names - # 验证 value 字段 - key_parts = next(r for r in data if r["name"] == "PGDDLMaxKeyParts") - assert key_parts["value"] == "8" - table_len = next(r for r in data if r["name"] == "PGMaxTableNameLen") - assert table_len["value"] == "63" - insert_rows = next(r for r in data if r["name"] == "PGDMLMaxInsertRows") - assert insert_rows["value"] == "500" - - @pytest.mark.asyncio - async def test_seed_redis_rules(self, client, admin_token): - """Redis 种子规则批量创建""" - resp = await client.post( - "/api/v1/audit-rules/seed/redis", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 4 - names = {r["name"] for r in data} - assert "RedisAllowFlushDB" in names - assert "RedisMaxKeyLen" in names - assert "RedisRequireTTL" in names - assert "RedisDefaultTTL" in names - # 验证 value 字段 - key_len = next(r for r in data if r["name"] == "RedisMaxKeyLen") - assert key_len["value"] == "256" - ttl = next(r for r in data if r["name"] == "RedisDefaultTTL") - assert ttl["value"] == "3600" - - @pytest.mark.asyncio - async def test_seed_unsupported_db_type(self, client, admin_token): - """不支持的数据库类型应返回 400""" - resp = await client.post( - "/api/v1/audit-rules/seed/oracle", - headers=auth_header(admin_token), - ) - assert resp.status_code == 400 - assert "不支持的数据库类型" in resp.json()["detail"] - - @pytest.mark.asyncio - async def test_seed_idempotent(self, client, admin_token): - """重复调用 seed 应幂等,不创建重复规则""" - resp1 = await client.post( - "/api/v1/audit-rules/seed/mysql", - headers=auth_header(admin_token), - ) - assert resp1.status_code == 200 - count1 = len(resp1.json()) - - resp2 = await client.post( - "/api/v1/audit-rules/seed/mysql", - headers=auth_header(admin_token), - ) - assert resp2.status_code == 200 - count2 = len(resp2.json()) - assert count2 == 0 # 第二次调用不再创建 - - @pytest.mark.asyncio - async def test_seed_forbidden_for_dba(self, client, dba_token): - """DBA 无权调用 seed 接口""" - resp = await client.post( - "/api/v1/audit-rules/seed/mysql", - headers=auth_header(dba_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_seed_forbidden_for_developer(self, client, developer_token): - """Developer 无权调用 seed 接口""" - resp = await client.post( - "/api/v1/audit-rules/seed/mysql", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -class TestValueFieldAndDDLType: - @pytest.mark.asyncio - async def test_create_rule_with_value(self, client, admin_token): - """创建带 value 字段的规则""" - resp = await client.post( - "/api/v1/audit-rules", - json={ - "name": "test_with_value", - "description": "测试value字段", - "rule_type": "DDL", - "severity": "warning", - "db_type": "mysql", - "value": "100", - "message": "测试规则带value", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - assert data["value"] == "100" - assert data["rule_type"] == "DDL" - - @pytest.mark.asyncio - async def test_create_rule_with_dml_type(self, client, admin_token): - """创建 DML 类型规则""" - resp = await client.post( - "/api/v1/audit-rules", - json={ - "name": "test_dml_rule", - "rule_type": "DML", - "severity": "error", - "db_type": "mysql", - "message": "DML测试规则", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - assert resp.json()["rule_type"] == "DML" - - @pytest.mark.asyncio - async def test_create_rule_null_value(self, client, admin_token): - """value 可以为 null""" - resp = await client.post( - "/api/v1/audit-rules", - json={ - "name": "test_null_value", - "rule_type": "DDL", - "severity": "info", - "message": "null value test", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - assert resp.json()["value"] is None - - @pytest.mark.asyncio - async def test_update_rule_value(self, client, admin_token): - """更新 value 字段""" - create = await client.post( - "/api/v1/audit-rules", - json={ - "name": "update_value_test", - "rule_type": "DDL", - "severity": "warning", - "value": "50", - "message": "test", - }, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - - resp = await client.put( - f"/api/v1/audit-rules/{rule_id}", - json={"value": "200"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["value"] == "200" - - @pytest.mark.asyncio - async def test_list_rules_with_ddl_filter(self, client, admin_token): - """按 DDL 类型筛选规则""" - # 先 seed MySQL - await client.post( - "/api/v1/audit-rules/seed/mysql", - headers=auth_header(admin_token), - ) - resp = await client.get( - "/api/v1/audit-rules?rule_type=DDL", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) > 0 - assert all(r["rule_type"] == "DDL" for r in data) - - @pytest.mark.asyncio - async def test_response_includes_value_field(self, client, admin_token): - """响应中包含 value 字段""" - create = await client.post( - "/api/v1/audit-rules", - json={ - "name": "response_value_test", - "rule_type": "DDL", - "severity": "warning", - "value": "42", - "message": "test", - }, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - - resp = await client.get( - f"/api/v1/audit-rules/{rule_id}", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert "value" in resp.json() - assert resp.json()["value"] == "42" diff --git a/backend/tests/test_audit_rules.py b/backend/tests/test_audit_rules.py deleted file mode 100644 index 3f042cc..0000000 --- a/backend/tests/test_audit_rules.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Tests for Audit Rule CRUD API (Phase 4.2).""" -import pytest -from tests.conftest import auth_header - - -class TestListAuditRules: - @pytest.mark.asyncio - async def test_list_rules_admin(self, client, admin_token): - resp = await client.get("/api/v1/audit-rules", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert isinstance(resp.json(), list) - - @pytest.mark.asyncio - async def test_list_rules_dba(self, client, dba_token): - resp = await client.get("/api/v1/audit-rules", headers=auth_header(dba_token)) - assert resp.status_code == 200 - - @pytest.mark.asyncio - async def test_list_rules_developer_forbidden(self, client, developer_token): - resp = await client.get("/api/v1/audit-rules", headers=auth_header(developer_token)) - assert resp.status_code == 403 - - -class TestCreateAuditRule: - @pytest.mark.asyncio - async def test_create_rule_success(self, client, admin_token): - resp = await client.post( - "/api/v1/audit-rules", - json={ - "name": "no_select_star", - "description": "禁止 SELECT *", - "rule_type": "performance", - "severity": "warning", - "db_type": "mysql", - "pattern": r"SELECT\s+\*", - "message": "禁止使用 SELECT *,请明确指定字段", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - assert data["name"] == "no_select_star" - assert data["rule_type"] == "performance" - assert data["is_enabled"] is True - - @pytest.mark.asyncio - async def test_create_duplicate_name(self, client, admin_token): - payload = { - "name": "dup_rule", - "rule_type": "safety", - "severity": "error", - "message": "test", - } - await client.post("/api/v1/audit-rules", json=payload, headers=auth_header(admin_token)) - resp = await client.post("/api/v1/audit-rules", json=payload, headers=auth_header(admin_token)) - assert resp.status_code == 400 - - @pytest.mark.asyncio - async def test_create_rule_non_admin(self, client, dba_token): - resp = await client.post( - "/api/v1/audit-rules", - json={"name": "dba_rule", "rule_type": "syntax", "severity": "info", "message": "test"}, - headers=auth_header(dba_token), - ) - assert resp.status_code == 403 - - -class TestGetAuditRule: - @pytest.mark.asyncio - async def test_get_rule(self, client, admin_token): - create = await client.post( - "/api/v1/audit-rules", - json={"name": "get_test", "rule_type": "naming", "severity": "info", "message": "test"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - resp = await client.get(f"/api/v1/audit-rules/{rule_id}", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert resp.json()["name"] == "get_test" - - @pytest.mark.asyncio - async def test_get_nonexistent_rule(self, client, admin_token): - resp = await client.get("/api/v1/audit-rules/99999", headers=auth_header(admin_token)) - assert resp.status_code == 404 - - -class TestUpdateAuditRule: - @pytest.mark.asyncio - async def test_update_rule(self, client, admin_token): - create = await client.post( - "/api/v1/audit-rules", - json={"name": "update_test", "rule_type": "safety", "severity": "warning", "message": "old msg"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - resp = await client.put( - f"/api/v1/audit-rules/{rule_id}", - json={"severity": "error", "message": "new msg"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["severity"] == "error" - assert resp.json()["message"] == "new msg" - - -class TestDeleteAuditRule: - @pytest.mark.asyncio - async def test_delete_rule(self, client, admin_token): - create = await client.post( - "/api/v1/audit-rules", - json={"name": "delete_test", "rule_type": "syntax", "severity": "info", "message": "test"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - resp = await client.delete(f"/api/v1/audit-rules/{rule_id}", headers=auth_header(admin_token)) - assert resp.status_code == 200 - - # Verify deleted - get = await client.get(f"/api/v1/audit-rules/{rule_id}", headers=auth_header(admin_token)) - assert get.status_code == 404 - - -class TestToggleAuditRule: - @pytest.mark.asyncio - async def test_toggle_rule(self, client, admin_token): - create = await client.post( - "/api/v1/audit-rules", - json={"name": "toggle_test", "rule_type": "safety", "severity": "warning", "message": "test"}, - headers=auth_header(admin_token), - ) - rule_id = create.json()["id"] - assert create.json()["is_enabled"] is True - - # Toggle off - resp = await client.post(f"/api/v1/audit-rules/{rule_id}/toggle", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert resp.json()["is_enabled"] is False - - # Toggle back on - resp = await client.post(f"/api/v1/audit-rules/{rule_id}/toggle", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert resp.json()["is_enabled"] is True - - -class TestRuleStats: - @pytest.mark.asyncio - async def test_rule_stats(self, client, admin_token): - # Create some rules - await client.post( - "/api/v1/audit-rules", - json={"name": "stats_rule_1", "rule_type": "safety", "severity": "error", "message": "test"}, - headers=auth_header(admin_token), - ) - await client.post( - "/api/v1/audit-rules", - json={"name": "stats_rule_2", "rule_type": "performance", "severity": "warning", "message": "test"}, - headers=auth_header(admin_token), - ) - - resp = await client.get("/api/v1/audit-rules/stats/summary", headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 2 - assert data["enabled"] >= 2 - assert "by_type" in data diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py deleted file mode 100644 index f283f08..0000000 --- a/backend/tests/test_auth.py +++ /dev/null @@ -1,77 +0,0 @@ -import pytest -from httpx import AsyncClient - -from app.core.security import create_access_token - - -class TestLogin: - async def test_login_success(self, client: AsyncClient, admin_user): - resp = await client.post("/api/v1/auth/login", json={ - "username": "admin", - "password": "123456", - }) - assert resp.status_code == 200 - data = resp.json() - assert "access_token" in data - assert data["token_type"] == "bearer" - assert data["user"]["username"] == "admin" - assert data["user"]["role"] == "admin" - - async def test_login_wrong_password(self, client: AsyncClient, admin_user): - resp = await client.post("/api/v1/auth/login", json={ - "username": "admin", - "password": "wrong", - }) - assert resp.status_code == 401 - - async def test_login_nonexistent_user(self, client: AsyncClient): - resp = await client.post("/api/v1/auth/login", json={ - "username": "nobody", - "password": "123456", - }) - assert resp.status_code == 401 - - async def test_login_disabled_user(self, client: AsyncClient, disabled_user): - resp = await client.post("/api/v1/auth/login", json={ - "username": "disabled", - "password": "123456", - }) - assert resp.status_code == 403 - - -class TestGetMe: - async def test_get_me_no_token(self, client: AsyncClient): - resp = await client.get("/api/v1/auth/me") - assert resp.status_code in (401, 403) - - async def test_get_me_valid_token(self, client: AsyncClient, admin_user, admin_token): - resp = await client.get( - "/api/v1/auth/me", - headers={"Authorization": f"Bearer {admin_token}"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["username"] == "admin" - assert "id" in data - assert "role" in data - - async def test_get_me_expired_token(self, client: AsyncClient, admin_user): - from datetime import timedelta - expired = create_access_token( - data={"sub": str(admin_user.id), "role": admin_user.role}, - expires_delta=timedelta(seconds=-1), - ) - resp = await client.get( - "/api/v1/auth/me", - headers={"Authorization": f"Bearer {expired}"}, - ) - assert resp.status_code == 401 - - -class TestLogout: - async def test_logout(self, client: AsyncClient, admin_token): - resp = await client.post( - "/api/v1/auth/logout", - headers={"Authorization": f"Bearer {admin_token}"}, - ) - assert resp.status_code == 200 diff --git a/backend/tests/test_backup.py b/backend/tests/test_backup.py deleted file mode 100644 index f7727fb..0000000 --- a/backend/tests/test_backup.py +++ /dev/null @@ -1,395 +0,0 @@ -"""备份管理 API 端点测试。 - -测试范围: -- GET /api/v1/backups — 列表(支持 instance_id 过滤) -- POST /api/v1/backups — 创建备份(201, 自动生成 filename) -- GET /api/v1/backups/{id} — 备份详情(404 不存在) - -覆盖要点: -- CRUD 基本流程 -- 权限控制(admin/dba 可访问, developer/guest → 403) -- instance_id 过滤 -- filename 自动生成(database_instance_id.sql) -- 404 不存在的备份 -""" -import pytest -from httpx import AsyncClient - -from tests.conftest import auth_header - -BASE = "/api/v1/backups" - - -# ══════════════════════════════════════════════════════════════════ -# 权限测试 — developer / guest → 403 -# ══════════════════════════════════════════════════════════════════ - - -class TestBackupPermissions: - """备份路由权限控制:仅 admin / dba 可访问。""" - - @pytest.mark.asyncio - async def test_list_backups_developer_forbidden( - self, client: AsyncClient, developer_token: str - ): - """developer 访问列表 → 403。""" - resp = await client.get(BASE, headers=auth_header(developer_token)) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_list_backups_guest_forbidden( - self, client: AsyncClient, guest_token: str - ): - """guest 访问列表 → 403。""" - resp = await client.get(BASE, headers=auth_header(guest_token)) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_create_backup_developer_forbidden( - self, client: AsyncClient, developer_token: str - ): - """developer 创建备份 → 403。""" - resp = await client.post( - BASE, - json={"title": "t", "instance_id": 1, "database": "db"}, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_create_backup_guest_forbidden( - self, client: AsyncClient, guest_token: str - ): - """guest 创建备份 → 403。""" - resp = await client.post( - BASE, - json={"title": "t", "instance_id": 1, "database": "db"}, - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_get_backup_developer_forbidden( - self, client: AsyncClient, developer_token: str - ): - """developer 获取详情 → 403。""" - resp = await client.get(f"{BASE}/1", headers=auth_header(developer_token)) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_get_backup_guest_forbidden( - self, client: AsyncClient, guest_token: str - ): - """guest 获取详情 → 403。""" - resp = await client.get(f"{BASE}/1", headers=auth_header(guest_token)) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_unauthenticated_access(self, client: AsyncClient): - """未认证 → 401。""" - resp = await client.get(BASE) - assert resp.status_code == 401 - - -# ══════════════════════════════════════════════════════════════════ -# 创建备份 — POST /api/v1/backups -# ══════════════════════════════════════════════════════════════════ - - -class TestCreateBackup: - """创建备份测试。""" - - @pytest.mark.asyncio - async def test_create_backup_admin( - self, client: AsyncClient, admin_token: str, test_instance - ): - """admin 创建备份 → 201,字段完整。""" - payload = { - "title": "每日全量备份", - "instance_id": test_instance.id, - "database": "production_db", - "remark": "定时任务触发", - } - resp = await client.post(BASE, json=payload, headers=auth_header(admin_token)) - assert resp.status_code == 201 - - data = resp.json() - assert data["id"] is not None - assert data["title"] == "每日全量备份" - assert data["instance_id"] == test_instance.id - assert data["database"] == "production_db" - assert data["status"] == "pending" - assert data["size_bytes"] == 0 - assert data["remark"] == "定时任务触发" - # created_by 应为 admin 用户 id - assert data["created_by"] is not None - # created_at 应有值 - assert data["created_at"] is not None - - @pytest.mark.asyncio - async def test_create_backup_filename_auto_generated( - self, client: AsyncClient, admin_token: str, test_instance - ): - """filename 自动生成为 {database}_{instance_id}.sql。""" - payload = { - "title": "测试文件名", - "instance_id": test_instance.id, - "database": "mydb", - } - resp = await client.post(BASE, json=payload, headers=auth_header(admin_token)) - assert resp.status_code == 201 - - data = resp.json() - expected_filename = f"mydb_{test_instance.id}.sql" - assert data["filename"] == expected_filename - - @pytest.mark.asyncio - async def test_create_backup_dba( - self, client: AsyncClient, dba_token: str, test_instance - ): - """dba 创建备份 → 201。""" - payload = { - "title": "DBA手动备份", - "instance_id": test_instance.id, - "database": "orders", - } - resp = await client.post(BASE, json=payload, headers=auth_header(dba_token)) - assert resp.status_code == 201 - assert resp.json()["title"] == "DBA手动备份" - - @pytest.mark.asyncio - async def test_create_backup_without_remark( - self, client: AsyncClient, admin_token: str, test_instance - ): - """不传 remark → remark 为 None。""" - payload = { - "title": "无备注备份", - "instance_id": test_instance.id, - "database": "test_db", - } - resp = await client.post(BASE, json=payload, headers=auth_header(admin_token)) - assert resp.status_code == 201 - assert resp.json()["remark"] is None - - @pytest.mark.asyncio - async def test_create_backup_sets_created_by( - self, client: AsyncClient, dba_token: str, dba_user, test_instance - ): - """created_by 应为当前登录用户 id。""" - payload = { - "title": "验证created_by", - "instance_id": test_instance.id, - "database": "hr_db", - } - resp = await client.post(BASE, json=payload, headers=auth_header(dba_token)) - assert resp.status_code == 201 - assert resp.json()["created_by"] == dba_user.id - - @pytest.mark.asyncio - async def test_create_backup_nonexistent_instance_id( - self, client: AsyncClient, admin_token: str - ): - """instance_id 可以是不存在的(无外键约束),仍然创建成功。""" - payload = { - "title": "幽灵实例备份", - "instance_id": 99999, - "database": "ghost_db", - } - resp = await client.post(BASE, json=payload, headers=auth_header(admin_token)) - assert resp.status_code == 201 - assert resp.json()["instance_id"] == 99999 - - -# ══════════════════════════════════════════════════════════════════ -# 获取备份详情 — GET /api/v1/backups/{backup_id} -# ══════════════════════════════════════════════════════════════════ - - -class TestGetBackup: - """获取备份详情测试。""" - - @pytest.mark.asyncio - async def test_get_backup_by_id( - self, client: AsyncClient, admin_token: str, test_instance - ): - """创建后通过 id 获取 → 数据一致。""" - # 先创建 - payload = { - "title": "查询测试", - "instance_id": test_instance.id, - "database": "inventory", - "remark": "验证查询", - } - create_resp = await client.post( - BASE, json=payload, headers=auth_header(admin_token) - ) - assert create_resp.status_code == 201 - backup_id = create_resp.json()["id"] - - # 再查询 - get_resp = await client.get( - f"{BASE}/{backup_id}", headers=auth_header(admin_token) - ) - assert get_resp.status_code == 200 - data = get_resp.json() - assert data["id"] == backup_id - assert data["title"] == "查询测试" - assert data["database"] == "inventory" - assert data["remark"] == "验证查询" - - @pytest.mark.asyncio - async def test_get_backup_not_found(self, client: AsyncClient, admin_token: str): - """获取不存在的 backup_id → 404。""" - resp = await client.get(f"{BASE}/99999", headers=auth_header(admin_token)) - assert resp.status_code == 404 - assert "不存在" in resp.json()["detail"] - - @pytest.mark.asyncio - async def test_get_backup_dba( - self, client: AsyncClient, dba_token: str, admin_token: str, test_instance - ): - """dba 可以获取 admin 创建的备份。""" - payload = { - "title": "跨角色查看", - "instance_id": test_instance.id, - "database": "shared_db", - } - create_resp = await client.post( - BASE, json=payload, headers=auth_header(admin_token) - ) - backup_id = create_resp.json()["id"] - - get_resp = await client.get( - f"{BASE}/{backup_id}", headers=auth_header(dba_token) - ) - assert get_resp.status_code == 200 - assert get_resp.json()["id"] == backup_id - - -# ══════════════════════════════════════════════════════════════════ -# 列表查询 — GET /api/v1/backups -# ══════════════════════════════════════════════════════════════════ - - -class TestListBackups: - """备份列表查询测试。""" - - @pytest.mark.asyncio - async def test_list_backups_empty(self, client: AsyncClient, admin_token: str): - """无备份时返回空列表。""" - resp = await client.get(BASE, headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert resp.json() == [] - - @pytest.mark.asyncio - async def test_list_backups_returns_created( - self, client: AsyncClient, admin_token: str, test_instance - ): - """创建后列表包含该备份。""" - payload = { - "title": "列表验证", - "instance_id": test_instance.id, - "database": "catalog", - } - await client.post(BASE, json=payload, headers=auth_header(admin_token)) - - resp = await client.get(BASE, headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert len(data) >= 1 - assert any(b["title"] == "列表验证" for b in data) - - @pytest.mark.asyncio - async def test_list_backups_filter_by_instance_id( - self, client: AsyncClient, admin_token: str, test_instance - ): - """instance_id 过滤:只返回对应实例的备份。""" - # 创建两个不同 instance_id 的备份 - await client.post( - BASE, - json={"title": "目标实例", "instance_id": test_instance.id, "database": "a"}, - headers=auth_header(admin_token), - ) - await client.post( - BASE, - json={"title": "其他实例", "instance_id": 99999, "database": "b"}, - headers=auth_header(admin_token), - ) - - resp = await client.get( - BASE, - params={"instance_id": test_instance.id}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 1 - assert data[0]["title"] == "目标实例" - assert data[0]["instance_id"] == test_instance.id - - @pytest.mark.asyncio - async def test_list_backups_filter_nonexistent_instance( - self, client: AsyncClient, admin_token: str, test_instance - ): - """过滤不存在的 instance_id → 空列表。""" - # 先创建一个备份确保数据库不为空 - await client.post( - BASE, - json={ - "title": "占位", - "instance_id": test_instance.id, - "database": "x", - }, - headers=auth_header(admin_token), - ) - - resp = await client.get( - BASE, - params={"instance_id": 88888}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json() == [] - - @pytest.mark.asyncio - async def test_list_backups_dba( - self, client: AsyncClient, dba_token: str, admin_token: str, test_instance - ): - """dba 可以查看备份列表。""" - await client.post( - BASE, - json={ - "title": "DBA列表", - "instance_id": test_instance.id, - "database": "dba_db", - }, - headers=auth_header(admin_token), - ) - - resp = await client.get(BASE, headers=auth_header(dba_token)) - assert resp.status_code == 200 - assert len(resp.json()) >= 1 - - @pytest.mark.asyncio - async def test_list_backups_multiple_ordered_desc( - self, client: AsyncClient, admin_token: str, test_instance - ): - """多条备份按 id 降序排列。""" - for i in range(3): - await client.post( - BASE, - json={ - "title": f"备份{i}", - "instance_id": test_instance.id, - "database": f"db{i}", - }, - headers=auth_header(admin_token), - ) - - resp = await client.get(BASE, headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 3 - # 按 id 降序 - ids = [b["id"] for b in data] - assert ids == sorted(ids, reverse=True) diff --git a/backend/tests/test_crypto.py b/backend/tests/test_crypto.py deleted file mode 100644 index a289c2b..0000000 --- a/backend/tests/test_crypto.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Tests for AES encryption (Phase 3.1).""" -import pytest -from app.core.crypto import encrypt_password, decrypt_password - - -class TestEncryption: - def test_encrypt_decrypt_roundtrip(self): - """Encrypt then decrypt returns original.""" - plain = "my_secret_password_123" - encrypted = encrypt_password(plain) - assert encrypted != plain - assert decrypt_password(encrypted) == plain - - def test_encrypt_produces_different_ciphertext(self): - """Each encryption uses a random nonce, so ciphertext differs.""" - plain = "same_password" - e1 = encrypt_password(plain) - e2 = encrypt_password(plain) - assert e1 != e2 # different nonces - assert decrypt_password(e1) == plain - assert decrypt_password(e2) == plain - - def test_encrypt_empty_string(self): - """Can encrypt and decrypt empty string.""" - encrypted = encrypt_password("") - assert decrypt_password(encrypted) == "" - - def test_encrypt_special_characters(self): - """Handles special characters in passwords.""" - plain = 'p@ssw0rd!#$%^&*()_+-=[]{}|;\':",./<>?' - encrypted = encrypt_password(plain) - assert decrypt_password(encrypted) == plain - - def test_encrypt_unicode(self): - """Handles unicode characters.""" - plain = "密码测试🔑" - encrypted = encrypt_password(plain) - assert decrypt_password(encrypted) == plain - - def test_decrypt_invalid_data_returns_as_is(self): - """Invalid ciphertext returns original string (backward compat).""" - result = decrypt_password("not_valid_base64!!!") - assert result == "not_valid_base64!!!" - - -class TestInstanceEncryption: - """Test that instance passwords are stored encrypted via API.""" - - @pytest.mark.asyncio - async def test_create_instance_encrypts_password(self, client, admin_token): - """POST /instances stores password as encrypted ciphertext.""" - from tests.conftest import auth_header - resp = await client.post( - "/api/v1/instances", - json={ - "name": "加密测试", - "db_type": "mysql", - "host": "10.0.0.1", - "port": 3306, - "username": "root", - "password": "plain_text_pw", - "database": "test", - "env": "development", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - # Response should NOT contain password at all (InstanceResponse excludes it) - assert "password" not in data - - @pytest.mark.asyncio - async def test_password_not_stored_as_plaintext(self, client, db, admin_token): - """Verify the database actually stores encrypted, not plain text.""" - from tests.conftest import auth_header - from app.models.instance import Instance - from sqlalchemy import select - - plain_pw = "super_secret_123" - resp = await client.post( - "/api/v1/instances", - json={ - "name": "明文检查", - "db_type": "mysql", - "host": "10.0.0.2", - "port": 3306, - "username": "root", - "password": plain_pw, - "database": "test", - "env": "development", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - instance_id = resp.json()["id"] - - # Read raw from database - result = await db.execute(select(Instance).where(Instance.id == instance_id)) - instance = result.scalar_one() - assert instance.password != plain_pw # Not stored as plaintext - assert len(instance.password) > 20 # Encrypted is longer - - @pytest.mark.asyncio - async def test_update_instance_re_encrypts_password(self, client, db, admin_token): - """PUT /instances/:id re-encrypts password when updated.""" - from tests.conftest import auth_header - from app.models.instance import Instance - from sqlalchemy import select - - # Create - resp = await client.post( - "/api/v1/instances", - json={ - "name": "更新加密", - "db_type": "mysql", - "host": "10.0.0.3", - "port": 3306, - "username": "root", - "password": "old_password", - "database": "test", - }, - headers=auth_header(admin_token), - ) - iid = resp.json()["id"] - - # Update password - resp = await client.put( - f"/api/v1/instances/{iid}", - json={"password": "new_password"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - # Verify encrypted - result = await db.execute(select(Instance).where(Instance.id == iid)) - inst = result.scalar_one() - assert inst.password != "new_password" - assert inst.password != "old_password" diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py deleted file mode 100644 index 21fa988..0000000 --- a/backend/tests/test_dashboard.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Comprehensive tests for Dashboard stats endpoint.""" -import pytest -from httpx import AsyncClient -from sqlalchemy.ext.asyncio import AsyncSession - -from tests.conftest import auth_header -from app.models.ticket import Ticket -from app.models.instance import Instance -from app.models.user import User -from app.models.audit_log import AuditLog -from app.core.security import hash_password - -BASE = "/api/v1/dashboard/stats" - - -class TestDashboard: - async def test_get_stats(self, client: AsyncClient, admin_token): - resp = await client.get(BASE, headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert "ticket_total" in data - assert "instance_total" in data - assert "user_total" in data - - async def test_unauthenticated(self, client: AsyncClient): - resp = await client.get(BASE) - assert resp.status_code in (401, 403) - - -# ─── DETAILED STATS TESTS ────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_stats_all_zero_on_empty_db(client: AsyncClient, admin_token: str): - """Fresh DB with only the admin user should show zeros except user_total=1.""" - resp = await client.get(BASE, headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert data["ticket_total"] == 0 - assert data["ticket_pending"] == 0 - assert data["ticket_month"] == 0 - assert data["ticket_executing"] == 0 - assert data["instance_total"] == 0 - assert data["instance_online"] == 0 - assert data["user_total"] == 1 # admin_user from fixture - assert data["queries_today"] == 0 - - -@pytest.mark.asyncio -async def test_stats_fields_structure(client: AsyncClient, admin_token: str): - """Verify all expected fields are present and are integers.""" - resp = await client.get(BASE, headers=auth_header(admin_token)) - data = resp.json() - expected_keys = { - "ticket_total", - "ticket_pending", - "ticket_month", - "ticket_executing", - "instance_total", - "instance_online", - "user_total", - "queries_today", - "query_stats", - } - assert set(data.keys()) == expected_keys - for key in expected_keys: - if key == "query_stats": - assert isinstance(data[key], (dict, type(None))), f"{key} should be dict or None" - else: - assert isinstance(data[key], int), f"{key} should be int" - - -@pytest.mark.asyncio -async def test_stats_reflect_ticket_counts( - client: AsyncClient, db: AsyncSession, admin_token: str, test_instance: Instance -): - """Create tickets with various statuses and verify counts.""" - for status in ["pending", "reviewing", "executing", "success"]: - ticket = Ticket( - title=f"工单-{status}", - db_type="mysql", - instance_id=test_instance.id, - target_database="test_db", - sql_content="SELECT 1", - status=status, - applicant_id=1, - ) - db.add(ticket) - await db.commit() - - resp = await client.get(BASE, headers=auth_header(admin_token)) - data = resp.json() - assert data["ticket_total"] == 4 - # pending + reviewing = 2 - assert data["ticket_pending"] == 2 - assert data["ticket_executing"] == 1 - # all 4 are within the last month - assert data["ticket_month"] == 4 - - -@pytest.mark.asyncio -async def test_stats_reflect_instance_counts( - client: AsyncClient, db: AsyncSession, admin_token: str -): - """Create instances with different statuses.""" - for i, status in enumerate(["connected", "disconnected", "connected"]): - inst = Instance( - name=f"inst-{i}", - db_type="mysql", - host=f"127.0.0.{i}", - port=3306, - username="root", - password="pw", - database="db", - env="dev", - status=status, - ) - db.add(inst) - await db.commit() - - resp = await client.get(BASE, headers=auth_token_or_admin(admin_token)) - data = resp.json() - assert data["instance_total"] == 3 - assert data["instance_online"] == 2 # connected count - - -@pytest.mark.asyncio -async def test_stats_reflect_user_count( - client: AsyncClient, db: AsyncSession, admin_token: str -): - """Add an extra active user and verify user_total.""" - extra_user = User( - username="extra", - display_name="额外用户", - role="developer", - hashed_password="fake_hash", - is_active=True, - ) - db.add(extra_user) - await db.commit() - - resp = await client.get(BASE, headers=auth_header(admin_token)) - data = resp.json() - # admin + extra = 2 - assert data["user_total"] == 2 - - -@pytest.mark.asyncio -async def test_stats_excludes_inactive_users( - client: AsyncClient, db: AsyncSession, admin_token: str -): - """Inactive users should not be counted in user_total.""" - inactive = User( - username="inactive", - display_name="禁用用户", - role="developer", - hashed_password="fake_hash", - is_active=False, - ) - db.add(inactive) - await db.commit() - - resp = await client.get(BASE, headers=auth_header(admin_token)) - data = resp.json() - # Only admin (active), inactive user should be excluded - assert data["user_total"] == 1 - - -@pytest.mark.asyncio -async def test_stats_reflect_queries_today( - client: AsyncClient, db: AsyncSession, admin_token: str -): - """Add query histories and verify queries_today counts from QueryHistory.""" - from app.models.query_history import QueryHistory - for i in range(5): - qh = QueryHistory( - user_id=1, - username="admin", - instance_id=1, - instance_name="test", - db_type="mysql", - database_name="test_db", - query_content="SELECT 1", - query_type="SELECT", - execution_time_ms=100, - result_rows=1, - is_success=True, - ) - db.add(qh) - await db.commit() - - resp = await client.get(BASE, headers=auth_header(admin_token)) - data = resp.json() - assert data["queries_today"] == 5 - - -@pytest.mark.asyncio -async def test_stats_developer_can_access(client: AsyncClient, developer_token: str): - """Dashboard stats should be accessible to any authenticated user.""" - resp = await client.get(BASE, headers=auth_header(developer_token)) - assert resp.status_code == 200 - - -@pytest.mark.asyncio -async def test_stats_guest_can_access(client: AsyncClient, guest_token: str): - """Dashboard stats should be accessible to any authenticated user.""" - resp = await client.get(BASE, headers=auth_header(guest_token)) - assert resp.status_code == 200 - - -@pytest.mark.asyncio -async def test_stats_no_auth_returns_401(client: AsyncClient): - resp = await client.get(BASE) - assert resp.status_code == 401 - - -# ─── Helper ──────────────────────────────────────────────────────────── - -def auth_token_or_admin(token: str) -> dict: - """Alias for auth_header to avoid confusion in test body.""" - return auth_header(token) diff --git a/backend/tests/test_db_types.py b/backend/tests/test_db_types.py deleted file mode 100644 index 0fd3e5e..0000000 --- a/backend/tests/test_db_types.py +++ /dev/null @@ -1,204 +0,0 @@ -"""数据库类型说明 API 测试 — db_types 端点。 - -覆盖: -- GET /db-types — 列表概览 -- GET /db-types/{db_type} — 详细信息 -- GET /db-types/{db_type}/commands — 常用命令 -- GET /db-types/{db_type}/tips — 使用技巧 -- 404 错误处理 -""" -import pytest -from httpx import AsyncClient, ASGITransport - -from app.main import app - - -@pytest.fixture -def auth_headers(): - """返回认证头。""" - from app.core.security import create_access_token - token = create_access_token({"sub": "1", "role": "admin"}) - return {"Authorization": "Bearer " + token} - - -@pytest.mark.asyncio -async def test_list_db_types(client, admin_token): - """测试获取所有数据库类型概览。""" - resp = await client.get("/api/v1/db-types", headers={"Authorization": "Bearer " + admin_token}) - - assert resp.status_code == 200 - data = resp.json() - assert "items" in data - assert data["total"] == 4 - - types = {item["type"] for item in data["items"]} - assert types == {"mysql", "postgres", "redis", "mongo"} - - # 检查每个条目的结构 - for item in data["items"]: - assert "type" in item - assert "name" in item - assert "icon" in item - assert "description" in item - assert "category" in item - assert "version" in item - assert "supported_operations" in item - assert item["supported_operations"] > 0 - - -@pytest.mark.asyncio -async def test_get_mysql_detail(client, admin_token): - """测试获取 MySQL 详细信息。""" - resp = await client.get("/api/v1/db-types/mysql", headers={"Authorization": "Bearer " + admin_token}) - - assert resp.status_code == 200 - data = resp.json() - assert data["type"] == "mysql" - assert data["name"] == "MySQL" - assert data["category"] == "关系型" - assert len(data["supported_query_types"]) >= 4 - assert len(data["security_rules"]) >= 1 - assert len(data["tips"]) >= 1 - assert len(data["common_commands"]) >= 1 - assert len(data["limitations"]) >= 1 - - # 检查查询类型结构 - for qt in data["supported_query_types"]: - assert "type" in qt - assert "description" in qt - assert "example" in qt - - -@pytest.mark.asyncio -async def test_get_postgres_detail(client, admin_token): - """测试获取 PostgreSQL 详细信息。""" - resp = await client.get("/api/v1/db-types/postgres", headers={"Authorization": "Bearer " + admin_token}) - - assert resp.status_code == 200 - data = resp.json() - assert data["type"] == "postgres" - assert data["name"] == "PostgreSQL" - assert data["category"] == "关系型" - - -@pytest.mark.asyncio -async def test_get_redis_detail(client, admin_token): - """测试获取 Redis 详细信息。""" - resp = await client.get("/api/v1/db-types/redis", headers={"Authorization": "Bearer " + admin_token}) - - assert resp.status_code == 200 - data = resp.json() - assert data["type"] == "redis" - assert data["name"] == "Redis" - assert data["category"] == "键值型" - assert len(data["supported_query_types"]) >= 5 - - -@pytest.mark.asyncio -async def test_get_mongo_detail(client, admin_token): - """测试获取 MongoDB 详细信息。""" - resp = await client.get("/api/v1/db-types/mongo", headers={"Authorization": "Bearer " + admin_token}) - - assert resp.status_code == 200 - data = resp.json() - assert data["type"] == "mongo" - assert data["name"] == "MongoDB" - assert data["category"] == "文档型" - - -@pytest.mark.asyncio -async def test_get_unknown_db_type(client, admin_token): - """测试获取不支持的数据库类型 — 404。""" - resp = await client.get("/api/v1/db-types/oracle", headers={"Authorization": "Bearer " + admin_token}) - - assert resp.status_code == 404 - assert "不支持的数据库类型" in resp.json()["detail"] - - -@pytest.mark.asyncio -async def test_get_commands(client, admin_token): - """测试获取常用命令列表。""" - resp = await client.get("/api/v1/db-types/mysql/commands", headers={"Authorization": "Bearer " + admin_token}) - - assert resp.status_code == 200 - data = resp.json() - assert "items" in data - assert data["total"] >= 5 - - for cmd in data["items"]: - assert "name" in cmd - assert "command" in cmd - - -@pytest.mark.asyncio -async def test_get_commands_redis(client, admin_token): - """测试获取 Redis 常用命令列表。""" - resp = await client.get("/api/v1/db-types/redis/commands", headers={"Authorization": "Bearer " + admin_token}) - - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 5 - - cmd_names = {cmd["name"] for cmd in data["items"]} - assert "查看键数量" in cmd_names or "查看服务器信息" in cmd_names - - -@pytest.mark.asyncio -async def test_get_commands_unknown(client, admin_token): - """测试获取不支持类型的命令 — 404。""" - resp = await client.get("/api/v1/db-types/cassandra/commands", headers={"Authorization": "Bearer " + admin_token}) - - assert resp.status_code == 404 - - -@pytest.mark.asyncio -async def test_get_tips(client, admin_token): - """测试获取使用技巧。""" - resp = await client.get("/api/v1/db-types/mysql/tips", headers={"Authorization": "Bearer " + admin_token}) - - assert resp.status_code == 200 - data = resp.json() - assert "items" in data - assert data["total"] >= 3 - # 每条 tip 是字符串 - for tip in data["items"]: - assert isinstance(tip, str) - assert len(tip) > 0 - - -@pytest.mark.asyncio -async def test_get_tips_mongo(client, admin_token): - """测试获取 MongoDB 使用技巧。""" - resp = await client.get("/api/v1/db-types/mongo/tips", headers={"Authorization": "Bearer " + admin_token}) - - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 2 - - -@pytest.mark.asyncio -async def test_get_tips_unknown(client, admin_token): - """测试获取不支持类型的技巧 — 404。""" - resp = await client.get("/api/v1/db-types/sqlite/tips", headers={"Authorization": "Bearer " + admin_token}) - - assert resp.status_code == 404 - - -@pytest.mark.asyncio -async def test_db_types_require_auth(client): - """测试未认证请求被拒绝。""" - resp = await client.get("/api/v1/db-types") - - assert resp.status_code in (401, 403) - - -@pytest.mark.asyncio -async def test_list_structure_consistency(client, admin_token): - """测试所有类型的列表条目结构一致。""" - resp = await client.get("/api/v1/db-types", headers={"Authorization": "Bearer " + admin_token}) - - data = resp.json() - required_fields = {"type", "name", "icon", "description", "category", "version", "supported_operations", "security_rules_count"} - for item in data["items"]: - assert required_fields.issubset(set(item.keys())), \ - "Missing fields: " + str(required_fields - set(item.keys())) diff --git a/backend/tests/test_dictionary.py b/backend/tests/test_dictionary.py deleted file mode 100644 index b565f25..0000000 --- a/backend/tests/test_dictionary.py +++ /dev/null @@ -1,227 +0,0 @@ -"""数据字典模块测试。""" -import sys -from unittest.mock import patch, AsyncMock, MagicMock - -from httpx import AsyncClient - -from tests.conftest import auth_header -from app.services.db_connector import QueryResult - - -def _mock_query_result(columns, rows): - """Helper: build a QueryResult for mocking execute_query.""" - return QueryResult( - columns=columns, - rows=rows, - total=len(rows), - elapsed_ms=10, - truncated=False, - ) - - -class TestListDatabases: - async def test_list_databases_success( - self, client: AsyncClient, test_instance, admin_token - ): - mock_result = _mock_query_result( - ["Database"], - [ - {"Database": "test_db"}, - {"Database": "app_db"}, - {"Database": "information_schema"}, - ], - ) - with patch( - "app.services.dictionary_service.DBConnector.execute_query", - new_callable=AsyncMock, - return_value=mock_result, - ): - resp = await client.get( - "/api/v1/dictionary/instances/" + str(test_instance.id) + "/databases", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert "databases" in data - db_list = data["databases"] - assert "test_db" in db_list - assert "app_db" in db_list - # system databases should be filtered - assert "information_schema" not in db_list - - async def test_list_databases_not_found( - self, client: AsyncClient, admin_token - ): - resp = await client.get( - "/api/v1/dictionary/instances/99999/databases", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - -class TestListTables: - async def test_list_tables_success( - self, client: AsyncClient, test_instance, admin_token - ): - mock_result = _mock_query_result( - ["TABLE_NAME"], - [{"TABLE_NAME": "users"}, {"TABLE_NAME": "orders"}], - ) - with patch( - "app.services.dictionary_service.DBConnector.execute_query", - new_callable=AsyncMock, - return_value=mock_result, - ): - resp = await client.get( - "/api/v1/dictionary/instances/" + str(test_instance.id) + "/tables", - params={"database": "test_db"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert "tables" in data - assert "users" in data["tables"] - assert "orders" in data["tables"] - - -class TestGetTableDetail: - async def test_table_detail_success( - self, client: AsyncClient, test_instance, admin_token - ): - col_result = _mock_query_result( - ["Field", "Type", "Null", "Key", "Default", "Extra", "Comment"], - [ - { - "Field": "id", - "Type": "int", - "Null": "NO", - "Key": "PRI", - "Default": None, - "Extra": "auto_increment", - "Comment": "primary key", - }, - ], - ) - idx_result = _mock_query_result( - ["Key_name", "Non_unique", "Index_type", "Column_name"], - [ - {"Key_name": "PRIMARY", "Non_unique": 0, "Index_type": "BTREE", "Column_name": "id"}, - ], - ) - ddl_result = _mock_query_result( - ["Table", "Create Table"], - [{"Table": "users", "Create Table": "CREATE TABLE users (id int PRIMARY KEY)"}], - ) - cnt_result = _mock_query_result( - ["TABLE_ROWS"], - [{"TABLE_ROWS": 42}], - ) - - call_count = 0 - results = [col_result, idx_result, ddl_result, cnt_result] - - async def _mock_execute(*args, **kwargs): - nonlocal call_count - r = results[call_count] - call_count += 1 - return r - - with patch( - "app.services.dictionary_service.DBConnector.execute_query", - new=AsyncMock(side_effect=_mock_execute), - ): - resp = await client.get( - "/api/v1/dictionary/instances/" + str(test_instance.id) + "/tables/users", - params={"database": "test_db"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert "columns" in data - assert len(data["columns"]) == 1 - assert data["columns"][0]["Field"] == "id" - assert "indexes" in data - assert "create_ddl" in data - assert "row_count" in data - assert data["row_count"] == 42 - - -class TestExportDictionary: - async def test_export_success( - self, client: AsyncClient, test_instance, admin_token - ): - # list_tables returns 1 table - tbl_result = _mock_query_result( - ["TABLE_NAME"], - [{"TABLE_NAME": "users"}], - ) - # get_table_detail: columns - col_result = _mock_query_result( - ["Field", "Type", "Null", "Key", "Default", "Extra", "Comment"], - [{"Field": "id", "Type": "int", "Null": "NO", "Key": "PRI", "Default": None, "Extra": "", "Comment": ""}], - ) - # indexes - idx_result = _mock_query_result( - ["Key_name", "Non_unique", "Index_type", "Column_name"], - [{"Key_name": "PRIMARY", "Non_unique": 0, "Index_type": "BTREE", "Column_name": "id"}], - ) - # ddl - ddl_result = _mock_query_result( - ["Table", "Create Table"], - [{"Table": "users", "Create Table": "CREATE TABLE users (id int)"}], - ) - # row count - cnt_result = _mock_query_result( - ["TABLE_ROWS"], - [{"TABLE_ROWS": 10}], - ) - - results = [tbl_result, col_result, idx_result, ddl_result, cnt_result] - call_count = 0 - - async def _mock_execute(*args, **kwargs): - nonlocal call_count - r = results[call_count] - call_count += 1 - return r - - with patch( - "app.services.dictionary_service.DBConnector.execute_query", - new=AsyncMock(side_effect=_mock_execute), - ): - resp = await client.get( - "/api/v1/dictionary/instances/" + str(test_instance.id) + "/export", - params={"database": "test_db"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert "tables" in data - assert len(data["tables"]) == 1 - assert data["tables"][0]["table_name"] == "users" - - -class TestPermission: - async def test_guest_forbidden( - self, client: AsyncClient, test_instance, guest_token - ): - resp = await client.get( - "/api/v1/dictionary/instances/" + str(test_instance.id) + "/databases", - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - async def test_developer_allowed( - self, client: AsyncClient, test_instance, developer_token - ): - mock_result = _mock_query_result(["Database"], []) - with patch( - "app.services.dictionary_service.DBConnector.execute_query", - new_callable=AsyncMock, - return_value=mock_result, - ): - resp = await client.get( - "/api/v1/dictionary/instances/" + str(test_instance.id) + "/databases", - headers=auth_header(developer_token), - ) - assert resp.status_code == 200 diff --git a/backend/tests/test_explain.py b/backend/tests/test_explain.py deleted file mode 100644 index 20fcaa2..0000000 --- a/backend/tests/test_explain.py +++ /dev/null @@ -1,994 +0,0 @@ -"""EXPLAIN 服务解析测试 — 测试 MySQL / PostgreSQL / SQLite 解析逻辑 + API endpoint。 - -使用 mock 的数据库连接,不依赖真实数据库。 -""" -import json -import asyncio -import sys -from unittest.mock import patch, AsyncMock, MagicMock - -import pytest -from httpx import AsyncClient - -from app.core.crypto import encrypt_password -from app.models.instance import Instance -from app.services.explain_service import ( - ExplainService, - parse_mysql_explain_json, - parse_postgres_explain_json, - parse_sqlite_explain, - make_plan_node, - _empty_result, -) -from tests.conftest import auth_header - - -# =========================================================================== -# Part 1: MySQL EXPLAIN JSON 解析测试 -# =========================================================================== - - -class TestMySQLExplainParsing: - """测试 MySQL EXPLAIN FORMAT=JSON 结果解析。""" - - def test_simple_table_scan(self): - """测试简单全表扫描。""" - raw = { - "query_block": { - "select_id": 1, - "cost_info": {"query_cost": "0.35"}, - "table": { - "table_name": "users", - "access_type": "ALL", - "rows_examined_per_scan": 1000, - "rows_produced_per_join": 1000, - "filtered": "100.00", - "cost_info": {"read_cost": "0.25"}, - }, - } - } - - result = parse_mysql_explain_json(raw) - - assert result["db_type"] == "mysql" - assert len(result["execution_plan"]) == 1 - - root = result["execution_plan"][0] - assert root["operation"] == "Table Scan" - assert root["table"] == "users" - assert root["rows"] == 1000 - assert root["cost"] == 0.25 - - # 警告检测 - assert any("全表扫描" in w for w in result["warnings"]) - - # 汇总 - summary = result["summary"] - assert summary["has_full_scan"] is True - assert summary["estimated_rows"] == 1000 - assert summary["total_cost"] == 0.25 - - def test_index_lookup(self): - """测试索引查找。""" - raw = { - "query_block": { - "select_id": 1, - "cost_info": {"query_cost": "0.10"}, - "table": { - "table_name": "orders", - "access_type": "ref", - "possible_keys": "idx_user_id", - "key": "idx_user_id", - "used_key_parts": ["user_id"], - "rows_examined_per_scan": 10, - "filtered": "100.00", - }, - } - } - - result = parse_mysql_explain_json(raw) - - root = result["execution_plan"][0] - assert root["operation"] == "Index Lookup" - assert root["table"] == "orders" - assert root["key"] == "idx_user_id" - assert root["rows"] == 10 - - summary = result["summary"] - assert summary["has_full_scan"] is False - assert summary["index_usage_ratio"] == 1.0 # 1 table, 1 index usage - - def test_nested_loop_join(self): - """测试嵌套循环 JOIN。""" - raw = { - "query_block": { - "select_id": 1, - "cost_info": {"query_cost": "1.50"}, - "nested_loop": [ - { - "table": { - "table_name": "users", - "access_type": "ALL", - "rows_examined_per_scan": 100, - } - }, - { - "table": { - "table_name": "orders", - "access_type": "ref", - "key": "idx_user_id", - "rows_examined_per_scan": 5, - } - }, - ], - } - } - - result = parse_mysql_explain_json(raw) - - root = result["execution_plan"][0] - assert root["operation"] == "Query Block" - assert len(root["children"]) == 2 - - child1 = root["children"][0] - assert child1["operation"] == "Table Scan" - assert child1["table"] == "users" - - child2 = root["children"][1] - assert child2["operation"] == "Index Lookup" - assert child2["table"] == "orders" - assert child2["key"] == "idx_user_id" - - def test_const_and_eq_ref(self): - """测试 const / eq_ref 类型映射。""" - # 直接测试 _mysql_access_type_to_operation - from app.services.explain_service import _mysql_access_type_to_operation - assert _mysql_access_type_to_operation("const") == "Const Row" - assert _mysql_access_type_to_operation("eq_ref") == "Unique Lookup" - assert _mysql_access_type_to_operation("range") == "Index Range Scan" - assert _mysql_access_type_to_operation("ALL") == "Table Scan" - assert _mysql_access_type_to_operation("index") == "Full Index Scan" - - def test_json_string_input(self): - """测试字符串 JSON 输入。""" - raw_str = json.dumps({ - "query_block": { - "select_id": 1, - "table": { - "table_name": "t", - "access_type": "ALL", - "rows_examined_per_scan": 50, - }, - } - }) - - result = parse_mysql_explain_json(raw_str) - assert result["db_type"] == "mysql" - assert result["execution_plan"][0]["table"] == "t" - - def test_invalid_json_string(self): - """测试无效 JSON 字符串。""" - result = parse_mysql_explain_json("not valid json{{{") - assert result["db_type"] == "mysql" - assert len(result["warnings"]) > 0 - assert any("解析失败" in w for w in result["warnings"]) - - def test_empty_result(self): - """测试空结果。""" - result = parse_mysql_explain_json({}) - assert result["db_type"] == "mysql" - assert result["execution_plan"] == [] - - def test_total_cost_uses_max(self): - """测试 total_cost 取最大值。""" - raw = { - "query_block": { - "select_id": 1, - "cost_info": {"query_cost": "10.5"}, - "nested_loop": [ - { - "table": { - "table_name": "a", - "access_type": "ALL", - "rows_examined_per_scan": 100, - "cost_info": {"read_cost": "5.0"}, - } - }, - { - "table": { - "table_name": "b", - "access_type": "ref", - "rows_examined_per_scan": 10, - "cost_info": {"read_cost": "3.0"}, - } - }, - ], - } - } - - result = parse_mysql_explain_json(raw) - # total_cost should be the max of all costs - assert result["summary"]["total_cost"] == 10.5 - - -# =========================================================================== -# Part 2: PostgreSQL EXPLAIN JSON 解析测试 -# =========================================================================== - - -class TestPostgreSQLExplainParsing: - """测试 PostgreSQL EXPLAIN (FORMAT JSON) 结果解析。""" - - def test_seq_scan(self): - """测试顺序扫描。""" - raw = [ - { - "Plan": { - "Node Type": "Seq Scan", - "Relation Name": "users", - "Alias": "users", - "Startup Cost": 0.00, - "Total Cost": 15.50, - "Plan Rows": 1000, - "Plan Width": 68, - } - } - ] - - result = parse_postgres_explain_json(raw) - - assert result["db_type"] == "postgres" - assert len(result["execution_plan"]) == 1 - - root = result["execution_plan"][0] - assert root["operation"] == "Table Scan" - assert root["table"] == "users" - assert root["rows"] == 1000 - assert root["cost"] == 15.50 - - assert any("顺序扫描" in w for w in result["warnings"]) - assert result["summary"]["has_full_scan"] is True - - def test_index_scan(self): - """测试索引扫描。""" - raw = [ - { - "Plan": { - "Node Type": "Index Scan", - "Relation Name": "orders", - "Alias": "orders", - "Index Name": "idx_user_id", - "Startup Cost": 0.15, - "Total Cost": 8.50, - "Plan Rows": 10, - "Plan Width": 48, - "Index Cond": "(user_id = 42)", - } - } - ] - - result = parse_postgres_explain_json(raw) - - root = result["execution_plan"][0] - assert root["operation"] == "Index Scan" - assert root["table"] == "orders" - assert root["key"] == "idx_user_id" - assert root["rows"] == 10 - assert "索引条件" in (root["extra"] or "") - - def test_join_with_children(self): - """测试 JOIN 嵌套结构。""" - raw = [ - { - "Plan": { - "Node Type": "Nested Loop", - "Startup Cost": 0.15, - "Total Cost": 25.50, - "Plan Rows": 100, - "Plan Width": 120, - "Plans": [ - { - "Node Type": "Seq Scan", - "Relation Name": "users", - "Total Cost": 12.00, - "Plan Rows": 100, - }, - { - "Node Type": "Index Scan", - "Relation Name": "orders", - "Index Name": "idx_user_id", - "Total Cost": 0.30, - "Plan Rows": 1, - "Index Cond": "(user_id = users.id)", - }, - ], - } - } - ] - - result = parse_postgres_explain_json(raw) - - root = result["execution_plan"][0] - assert root["operation"] == "Nested Loop Join" - assert len(root["children"]) == 2 - - assert root["children"][0]["operation"] == "Table Scan" - assert root["children"][0]["table"] == "users" - - assert root["children"][1]["operation"] == "Index Scan" - assert root["children"][1]["table"] == "orders" - assert root["children"][1]["key"] == "idx_user_id" - - def test_hash_join(self): - """测试 Hash Join。""" - raw = [ - { - "Plan": { - "Node Type": "Hash Join", - "Join Type": "Inner", - "Relation Name": None, - "Hash Cond": "users.id = orders.user_id", - "Total Cost": 50.00, - "Plan Rows": 500, - "Plans": [ - { - "Node Type": "Seq Scan", - "Relation Name": "users", - "Total Cost": 15.00, - "Plan Rows": 1000, - }, - { - "Node Type": "Hash", - "Total Cost": 20.00, - "Plan Rows": 500, - "Plans": [ - { - "Node Type": "Seq Scan", - "Relation Name": "orders", - "Total Cost": 18.00, - "Plan Rows": 500, - } - ], - }, - ], - } - } - ] - - result = parse_postgres_explain_json(raw) - root = result["execution_plan"][0] - assert "Inner" in root["operation"] - assert "Hash Join" in root["operation"] - assert len(root["children"]) == 2 - # Hash has a nested child - hash_child = root["children"][1] - assert hash_child["operation"] == "Hash" - assert len(hash_child["children"]) == 1 - assert hash_child["children"][0]["table"] == "orders" - - def test_sort_and_aggregate(self): - """测试排序和聚合。""" - raw = [ - { - "Plan": { - "Node Type": "Aggregate", - "Total Cost": 30.00, - "Plan Rows": 5, - "Group Key": ["department"], - "Plans": [ - { - "Node Type": "Sort", - "Total Cost": 28.00, - "Plan Rows": 1000, - "Sort Key": ["salary DESC"], - "Sort Method": "quicksort", - "Plans": [ - { - "Node Type": "Seq Scan", - "Relation Name": "employees", - "Total Cost": 15.00, - "Plan Rows": 1000, - } - ], - } - ], - } - } - ] - - result = parse_postgres_explain_json(raw) - root = result["execution_plan"][0] - assert root["operation"] == "Aggregate" - assert "分组键" in (root["extra"] or "") - - sort_child = root["children"][0] - assert sort_child["operation"] == "Sort" - assert "排序键" in (sort_child["extra"] or "") - - seq_child = sort_child["children"][0] - assert seq_child["operation"] == "Table Scan" - - def test_json_string_input(self): - """测试 JSON 字符串输入。""" - raw_str = json.dumps([ - {"Plan": {"Node Type": "Seq Scan", "Relation Name": "t", "Total Cost": 5.0, "Plan Rows": 10}} - ]) - result = parse_postgres_explain_json(raw_str) - assert result["db_type"] == "postgres" - assert result["execution_plan"][0]["table"] == "t" - - def test_invalid_json_string(self): - """测试无效 JSON 字符串。""" - result = parse_postgres_explain_json("invalid{{{") - assert any("解析失败" in w for w in result["warnings"]) - - def test_pg_node_type_mapping(self): - """测试 PG 节点类型映射。""" - from app.services.explain_service import _pg_node_type_to_operation - assert _pg_node_type_to_operation("Seq Scan") == "Table Scan" - assert _pg_node_type_to_operation("Index Scan") == "Index Scan" - assert _pg_node_type_to_operation("Index Only Scan") == "Index Only Scan" - assert _pg_node_type_to_operation("Hash Join") == "Hash Join" - assert _pg_node_type_to_operation("Sort") == "Sort" - assert _pg_node_type_to_operation("Aggregate") == "Aggregate" - assert _pg_node_type_to_operation("Limit") == "Limit" - assert _pg_node_type_to_operation("UnknownType") == "UnknownType" - - def test_actual_rows_and_time(self): - """测试 ANALYZE 包含的实际行数和耗时。""" - raw = [ - { - "Plan": { - "Node Type": "Seq Scan", - "Relation Name": "big_table", - "Total Cost": 100.0, - "Plan Rows": 50000, - "Actual Rows": 49998, - "Actual Total Time": 23.456, - } - } - ] - result = parse_postgres_explain_json(raw) - root = result["execution_plan"][0] - assert root.get("actual_rows") == 49998 - assert root.get("actual_time_ms") == 23.456 - assert "实际行数" in (root["extra"] or "") - assert "实际耗时" in (root["extra"] or "") - - -# =========================================================================== -# Part 3: SQLite EXPLAIN QUERY PLAN 解析测试 -# =========================================================================== - - -class TestSQLiteExplainParsing: - """测试 SQLite EXPLAIN QUERY PLAN 结果解析。""" - - def test_table_scan(self): - """测试全表扫描。""" - rows = [ - {"id": 3, "parent": 0, "notused": 0, "detail": "SCAN TABLE users"}, - ] - result = parse_sqlite_explain(rows) - - assert result["db_type"] == "sqlite" - assert len(result["execution_plan"]) == 1 - - root = result["execution_plan"][0] - assert root["operation"] == "Table Scan" - assert root["table"] == "users" - assert any("全表扫描" in w for w in result["warnings"]) - - def test_index_search(self): - """测试索引搜索。""" - rows = [ - {"id": 2, "parent": 0, "notused": 0, "detail": "SEARCH TABLE users USING INDEX idx_name (name=?)"}, - ] - result = parse_sqlite_explain(rows) - - root = result["execution_plan"][0] - assert root["operation"] == "Index Lookup" - assert root["table"] == "users" - assert root["key"] == "idx_name" - - def test_covering_index(self): - """测试覆盖索引。""" - rows = [ - {"id": 2, "parent": 0, "notused": 0, "detail": "SEARCH TABLE users USING COVERING INDEX idx_name (name=?)"}, - ] - result = parse_sqlite_explain(rows) - - root = result["execution_plan"][0] - assert root["key"] == "idx_name" - assert "Covering Index" in (root["extra"] or "") - - def test_subquery(self): - """测试子查询。""" - rows = [ - {"id": 3, "parent": 0, "notused": 0, "detail": "SCAN TABLE users"}, - {"id": 6, "parent": 0, "notused": 0, "detail": "CORRELATED SCALAR SUBQUERY 2"}, - ] - result = parse_sqlite_explain(rows) - assert len(result["execution_plan"]) == 2 - - def test_empty_rows(self): - """测试空结果。""" - result = parse_sqlite_explain([]) - assert result["db_type"] == "sqlite" - assert result["execution_plan"] == [] - - -# =========================================================================== -# Part 4: 统一辅助函数测试 -# =========================================================================== - - -class TestHelperFunctions: - """测试辅助函数。""" - - def test_make_plan_node_defaults(self): - """测试 make_plan_node 默认值。""" - node = make_plan_node(id=1, operation="Table Scan") - assert node["id"] == 1 - assert node["operation"] == "Table Scan" - assert node["table"] is None - assert node["rows"] is None - assert node["cost"] is None - assert node["key"] is None - assert node["extra"] is None - assert node["children"] == [] - - def test_make_plan_node_full(self): - """测试 make_plan_node 完整参数。""" - node = make_plan_node( - id=2, - operation="Index Scan", - table="orders", - rows=100, - cost=5.5, - key="idx_id", - extra="Using index", - children=[{"id": 3, "operation": "Child"}], - alias="o", - ) - assert node["table"] == "orders" - assert node["rows"] == 100 - assert node["cost"] == 5.5 - assert node["key"] == "idx_id" - assert len(node["children"]) == 1 - assert node["alias"] == "o" - - def test_empty_result(self): - """测试空结果构造。""" - result = _empty_result("mysql", {"foo": "bar"}, error="test error") - assert result["db_type"] == "mysql" - assert result["execution_plan"] == [] - assert "test error" in result["warnings"] - assert result["raw_result"] == {"foo": "bar"} - - def test_empty_result_no_error(self): - """测试无 error 的空结果。""" - result = _empty_result("postgres", None) - assert "EXPLAIN 结果为空" in result["warnings"][0] - - -# =========================================================================== -# Part 5: ExplainService.execute_explain 路由测试 (mock DB) -# =========================================================================== - - -class TestExplainEndpoint: - """测试 POST /api/v1/sql-assistant/explain API endpoint。""" - - @pytest.fixture - async def mysql_instance(self, db): - """创建 MySQL 测试实例。""" - inst = Instance( - name="TestMySQL", - db_type="mysql", - host="127.0.0.1", - port=3306, - username="root", - password=encrypt_password("test123"), - database="test_db", - env="development", - status="disconnected", - ) - db.add(inst) - await db.commit() - await db.refresh(inst) - return inst - - @pytest.fixture - async def pg_instance(self, db): - """创建 PostgreSQL 测试实例。""" - inst = Instance( - name="TestPG", - db_type="postgres", - host="127.0.0.1", - port=5432, - username="postgres", - password=encrypt_password("test123"), - database="test_db", - env="development", - status="disconnected", - ) - db.add(inst) - await db.commit() - await db.refresh(inst) - return inst - - @pytest.fixture - async def sqlite_instance(self, db): - """创建 SQLite 测试实例。""" - inst = Instance( - name="TestSQLite", - db_type="sqlite", - host="/tmp/test.db", - port=0, - username="", - password="", - database="", - env="development", - status="disconnected", - ) - db.add(inst) - await db.commit() - await db.refresh(inst) - return inst - - async def test_explain_mysql_success(self, client, admin_token, mysql_instance): - """测试 MySQL EXPLAIN 成功。""" - # Mock aiomysql - mock_cursor = AsyncMock() - mock_cursor.execute = AsyncMock() - mock_cursor.fetchall = AsyncMock(return_value=[ - {"EXPLAIN": json.dumps({ - "query_block": { - "select_id": 1, - "cost_info": {"query_cost": "0.35"}, - "table": { - "table_name": "users", - "access_type": "ALL", - "rows_examined_per_scan": 1000, - "filtered": "100.00", - }, - } - })} - ]) - mock_cursor.description = [("EXPLAIN",)] - mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) - mock_cursor.__aexit__ = AsyncMock(return_value=False) - - mock_conn = AsyncMock() - mock_conn.cursor = MagicMock(return_value=mock_cursor) - mock_conn.close = MagicMock() - - with patch("app.services.explain_service.aiomysql") as mock_aiomysql: - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - mock_aiomysql.DictCursor = MagicMock() - - resp = await client.post( - "/api/v1/sql-assistant/explain", - json={ - "instance_id": mysql_instance.id, - "database": "test_db", - "query": "SELECT * FROM users", - }, - headers=auth_header(admin_token), - ) - - assert resp.status_code == 200 - data = resp.json() - assert data["db_type"] == "mysql" - assert len(data["execution_plan"]) == 1 - assert data["execution_plan"][0]["operation"] == "Table Scan" - assert data["execution_plan"][0]["table"] == "users" - assert "elapsed_ms" in data - assert isinstance(data["summary"], dict) - - async def test_explain_postgres_success(self, client, admin_token, pg_instance): - """测试 PostgreSQL EXPLAIN 成功。""" - mock_conn = AsyncMock() - mock_conn.fetchval = AsyncMock(return_value=json.dumps([ - { - "Plan": { - "Node Type": "Seq Scan", - "Relation Name": "users", - "Total Cost": 15.50, - "Plan Rows": 1000, - } - } - ])) - mock_conn.close = AsyncMock() - - with patch("app.services.explain_service.asyncpg") as mock_asyncpg: - mock_asyncpg.connect = AsyncMock(return_value=mock_conn) - - resp = await client.post( - "/api/v1/sql-assistant/explain", - json={ - "instance_id": pg_instance.id, - "database": "test_db", - "query": "SELECT * FROM users", - }, - headers=auth_header(admin_token), - ) - - assert resp.status_code == 200 - data = resp.json() - assert data["db_type"] == "postgres" - assert len(data["execution_plan"]) == 1 - assert data["execution_plan"][0]["operation"] == "Table Scan" - assert data["execution_plan"][0]["table"] == "users" - - async def test_explain_empty_query(self, client, admin_token, mysql_instance): - """测试空查询报错。""" - resp = await client.post( - "/api/v1/sql-assistant/explain", - json={ - "instance_id": mysql_instance.id, - "database": "test_db", - "query": "", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 400 - - async def test_explain_non_select_query(self, client, admin_token, mysql_instance): - """测试非 SELECT 查询报错。""" - mock_conn = AsyncMock() - mock_conn.cursor = MagicMock() - mock_conn.close = MagicMock() - - with patch("app.services.explain_service.aiomysql") as mock_aiomysql: - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - mock_aiomysql.DictCursor = MagicMock() - - resp = await client.post( - "/api/v1/sql-assistant/explain", - json={ - "instance_id": mysql_instance.id, - "database": "test_db", - "query": "DELETE FROM users WHERE id = 1", - }, - headers=auth_header(admin_token), - ) - - assert resp.status_code == 400 - assert "SELECT" in resp.json()["detail"] or "WITH" in resp.json()["detail"] - - async def test_explain_instance_not_found(self, client, admin_token): - """测试不存在的实例。""" - resp = await client.post( - "/api/v1/sql-assistant/explain", - json={ - "instance_id": 99999, - "database": "test_db", - "query": "SELECT 1", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - async def test_explain_guest_forbidden(self, client, guest_token, mysql_instance): - """测试访客无权限。""" - resp = await client.post( - "/api/v1/sql-assistant/explain", - json={ - "instance_id": mysql_instance.id, - "database": "test_db", - "query": "SELECT 1", - }, - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - async def test_explain_with_with_clause(self, client, admin_token, pg_instance): - """测试 WITH (CTE) 查询。""" - mock_conn = AsyncMock() - mock_conn.fetchval = AsyncMock(return_value=json.dumps([ - { - "Plan": { - "Node Type": "CTE Scan", - "Total Cost": 5.0, - "Plan Rows": 10, - } - } - ])) - mock_conn.close = AsyncMock() - - with patch("app.services.explain_service.asyncpg") as mock_asyncpg: - mock_asyncpg.connect = AsyncMock(return_value=mock_conn) - - resp = await client.post( - "/api/v1/sql-assistant/explain", - json={ - "instance_id": pg_instance.id, - "database": "test_db", - "query": "WITH cte AS (SELECT 1) SELECT * FROM cte", - }, - headers=auth_header(admin_token), - ) - - assert resp.status_code == 200 - data = resp.json() - assert data["db_type"] == "postgres" - - async def test_explain_unsupported_db_type(self, client, admin_token, db): - """测试不支持的 db_type。""" - # Create a Redis instance - inst = Instance( - name="TestRedis", - db_type="redis", - host="127.0.0.1", - port=6379, - username="", - password=encrypt_password(""), - database="0", - env="development", - status="disconnected", - ) - db.add(inst) - await db.commit() - await db.refresh(inst) - - resp = await client.post( - "/api/v1/sql-assistant/explain", - json={ - "instance_id": inst.id, - "database": "0", - "query": "SELECT * FROM users", - }, - headers=auth_header(admin_token), - ) - # Either 400 or 500 depending on the error path - assert resp.status_code in (400, 500) - - -# =========================================================================== -# Part 6: ExplainService 单元测试 (直接 mock 连接) -# =========================================================================== - - -class TestExplainServiceUnit: - """直接测试 ExplainService 的路由逻辑。""" - - def _make_instance(self, db_type="mysql", **overrides): - defaults = dict( - name="Test", - db_type=db_type, - host="127.0.0.1", - port=3306, - username="root", - password="test123", - database="test_db", - ) - defaults.update(overrides) - inst = MagicMock(spec=Instance) - for k, v in defaults.items(): - setattr(inst, k, v) - return inst - - @pytest.mark.asyncio - async def test_mysql_explain_service(self): - """测试 ExplainService MySQL 路径。""" - instance = self._make_instance(db_type="mysql") - - mock_cursor = AsyncMock() - mock_cursor.execute = AsyncMock() - mock_cursor.fetchall = AsyncMock(return_value=[ - {"EXPLAIN": json.dumps({ - "query_block": { - "select_id": 1, - "table": { - "table_name": "t", - "access_type": "ALL", - "rows_examined_per_scan": 50, - }, - } - })} - ]) - mock_cursor.description = [("EXPLAIN",)] - mock_cursor.__aenter__ = AsyncMock(return_value=mock_cursor) - mock_cursor.__aexit__ = AsyncMock(return_value=False) - - mock_conn = AsyncMock() - mock_conn.cursor = MagicMock(return_value=mock_cursor) - mock_conn.close = MagicMock() - - with patch("app.services.explain_service.aiomysql") as mock_aiomysql: - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - mock_aiomysql.DictCursor = MagicMock() - - result = await ExplainService.execute_explain( - instance, "test_db", "SELECT * FROM t" - ) - - assert result["db_type"] == "mysql" - assert result["execution_plan"][0]["operation"] == "Table Scan" - - @pytest.mark.asyncio - async def test_postgres_explain_service(self): - """测试 ExplainService PostgreSQL 路径。""" - instance = self._make_instance(db_type="postgres", port=5432) - - mock_conn = AsyncMock() - mock_conn.fetchval = AsyncMock(return_value=json.dumps([ - {"Plan": {"Node Type": "Seq Scan", "Relation Name": "t", "Total Cost": 5.0, "Plan Rows": 10}} - ])) - mock_conn.close = AsyncMock() - - with patch("app.services.explain_service.asyncpg") as mock_asyncpg: - mock_asyncpg.connect = AsyncMock(return_value=mock_conn) - - result = await ExplainService.execute_explain( - instance, "test_db", "SELECT * FROM t" - ) - - assert result["db_type"] == "postgres" - assert result["execution_plan"][0]["operation"] == "Table Scan" - - @pytest.mark.asyncio - async def test_unsupported_db_type(self): - """测试不支持的 db_type。""" - instance = self._make_instance(db_type="redis") - - with pytest.raises(ValueError, match="暂不支持"): - await ExplainService.execute_explain(instance, "0", "SELECT * FROM key") - - @pytest.mark.asyncio - async def test_non_select_rejected(self): - """测试非 SELECT/WITH 查询被拒绝。""" - instance = self._make_instance(db_type="mysql") - - with pytest.raises(ValueError, match="SELECT"): - await ExplainService.execute_explain(instance, "test_db", "INSERT INTO t VALUES (1)") - - @pytest.mark.asyncio - async def test_with_query_accepted(self): - """测试 WITH (CTE) 查询被接受。""" - instance = self._make_instance(db_type="postgres", port=5432) - - mock_conn = AsyncMock() - mock_conn.fetchval = AsyncMock(return_value=json.dumps([ - {"Plan": {"Node Type": "CTE Scan", "Total Cost": 1.0, "Plan Rows": 1}} - ])) - mock_conn.close = AsyncMock() - - with patch("app.services.explain_service.asyncpg") as mock_asyncpg: - mock_asyncpg.connect = AsyncMock(return_value=mock_conn) - - result = await ExplainService.execute_explain( - instance, "test_db", "WITH cte AS (SELECT 1) SELECT * FROM cte" - ) - - assert result["db_type"] == "postgres" - - @pytest.mark.asyncio - async def test_sqlite_explain_service(self): - """测试 ExplainService SQLite 路径。""" - instance = self._make_instance(db_type="sqlite", host="/tmp/test_explain.db", port=0) - - mock_rows = [ - {"id": 2, "parent": 0, "notused": 0, "detail": "SEARCH TABLE users USING INDEX idx_id (id=?)"}, - ] - - with patch("app.services.explain_service.asyncio") as mock_asyncio_mod: - # We need to mock asyncio.get_event_loop().run_in_executor() - mock_loop = MagicMock() - mock_loop.run_in_executor = AsyncMock(return_value=mock_rows) - mock_asyncio_mod.get_event_loop.return_value = mock_loop - mock_asyncio_mod.wait_for = AsyncMock(return_value=mock_rows) - # Keep the real TimeoutError - mock_asyncio_mod.TimeoutError = asyncio.TimeoutError - - # Also need to patch sqlite3 inside the module - import sqlite3 as real_sqlite3 - - result = await ExplainService._explain_sqlite(instance, "SELECT * FROM users WHERE id = 1") - - assert result["db_type"] == "sqlite" - assert result["execution_plan"][0]["operation"] == "Index Lookup" - assert result["execution_plan"][0]["table"] == "users" - assert result["execution_plan"][0]["key"] == "idx_id" diff --git a/backend/tests/test_impact.py b/backend/tests/test_impact.py deleted file mode 100644 index 8cc8c47..0000000 --- a/backend/tests/test_impact.py +++ /dev/null @@ -1,250 +0,0 @@ -"""影响分析端点测试 — POST /api/v1/impact/analyze。""" -import pytest -from unittest.mock import patch, AsyncMock - -from app.services.impact_analyzer import ImpactAnalysis -from tests.conftest import auth_header - - -# ───────────────────────── helper ───────────────────────── - -def _make_analysis(**overrides) -> ImpactAnalysis: - """构造一个默认的 ImpactAnalysis 对象,可按需覆盖字段。""" - defaults = dict( - sql_type="UPDATE", - estimated_rows=50, - estimated_duration_ms=200, - table_size_mb=32.5, - table_row_count=10000, - index_usage=["PRIMARY"], - has_full_scan=False, - risk_level="low", - warnings=[], - ) - defaults.update(overrides) - return ImpactAnalysis(**defaults) - - -def _payload(instance_id: int, **overrides) -> dict: - """构造分析请求体。""" - body = { - "instance_id": instance_id, - "database": "test_db", - "sql_content": "UPDATE users SET name='test' WHERE id=1;", - } - body.update(overrides) - return body - - -# ───────────────────────── 测试用例 ───────────────────────── - - -class TestImpactAnalyze: - """影响分析端点基本测试。""" - - @pytest.mark.asyncio - async def test_analyze_success(self, client, db, admin_token, test_instance): - """正常分析 — 返回完整影响分析结果。""" - analysis = _make_analysis() - - with patch( - "app.api.v1.endpoints.impact.ImpactAnalyzer.analyze", - new_callable=AsyncMock, - return_value=analysis, - ): - resp = await client.post( - "/api/v1/impact/analyze", - json=_payload(test_instance.id), - headers=auth_header(admin_token), - ) - - assert resp.status_code == 200 - data = resp.json() - # 验证所有字段都存在且类型正确 - assert data["sql_type"] == "UPDATE" - assert data["estimated_rows"] == 50 - assert data["estimated_duration_ms"] == 200 - assert data["table_size_mb"] == 32.5 - assert data["table_row_count"] == 10000 - assert data["index_usage"] == ["PRIMARY"] - assert data["has_full_scan"] is False - assert data["risk_level"] == "low" - assert data["warnings"] == [] - - @pytest.mark.asyncio - async def test_analyze_instance_not_found(self, client, db, admin_token): - """实例不存在 — 返回 404。""" - resp = await client.post( - "/api/v1/impact/analyze", - json=_payload(instance_id=99999), - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - assert "不存在" in resp.json()["detail"] - - @pytest.mark.asyncio - async def test_analyze_requires_auth(self, client, db, test_instance): - """未携带 Token — 返回 401。""" - resp = await client.post( - "/api/v1/impact/analyze", - json=_payload(test_instance.id), - ) - assert resp.status_code == 401 - - @pytest.mark.asyncio - async def test_analyze_developer_allowed(self, client, db, developer_token, test_instance): - """developer 角色也能调用(任何登录用户均可使用影响分析)。""" - analysis = _make_analysis(sql_type="SELECT") - - with patch( - "app.api.v1.endpoints.impact.ImpactAnalyzer.analyze", - new_callable=AsyncMock, - return_value=analysis, - ): - resp = await client.post( - "/api/v1/impact/analyze", - json=_payload(test_instance.id, sql_content="SELECT * FROM users LIMIT 10;"), - headers=auth_header(developer_token), - ) - - assert resp.status_code == 200 - assert resp.json()["sql_type"] == "SELECT" - - @pytest.mark.asyncio - async def test_analyze_missing_fields(self, client, db, admin_token, test_instance): - """请求体缺少必填字段 — 返回 422 校验错误。""" - # 缺少 sql_content - resp = await client.post( - "/api/v1/impact/analyze", - json={"instance_id": test_instance.id, "database": "test_db"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 422 - - @pytest.mark.asyncio - async def test_analyze_high_risk_update(self, client, db, admin_token, test_instance): - """高风险 UPDATE(全表扫描 + 大量行)— 返回 high/critical 风险等级。""" - analysis = _make_analysis( - sql_type="UPDATE", - estimated_rows=50000, - estimated_duration_ms=12000, - table_size_mb=200.0, - table_row_count=500000, - has_full_scan=True, - risk_level="high", - warnings=["UPDATE 缺少 WHERE 条件,将影响全表 500000 行", "可能触发全表扫描,建议添加索引条件"], - ) - - with patch( - "app.api.v1.endpoints.impact.ImpactAnalyzer.analyze", - new_callable=AsyncMock, - return_value=analysis, - ): - resp = await client.post( - "/api/v1/impact/analyze", - json=_payload( - test_instance.id, - sql_content="UPDATE users SET status=0;", - ), - headers=auth_header(admin_token), - ) - - assert resp.status_code == 200 - data = resp.json() - assert data["risk_level"] == "high" - assert data["has_full_scan"] is True - assert len(data["warnings"]) > 0 - - @pytest.mark.asyncio - async def test_analyze_critical_drop(self, client, db, admin_token, test_instance): - """DROP 语句 — 风险等级应为 critical。""" - analysis = _make_analysis( - sql_type="DROP", - risk_level="critical", - warnings=["DROP 操作不可逆,请确认已备份数据"], - ) - - with patch( - "app.api.v1.endpoints.impact.ImpactAnalyzer.analyze", - new_callable=AsyncMock, - return_value=analysis, - ): - resp = await client.post( - "/api/v1/impact/analyze", - json=_payload( - test_instance.id, - sql_content="DROP TABLE users;", - ), - headers=auth_header(admin_token), - ) - - assert resp.status_code == 200 - data = resp.json() - assert data["risk_level"] == "critical" - assert any("不可逆" in w for w in data["warnings"]) - - @pytest.mark.asyncio - async def test_analyze_alter_large_table(self, client, db, admin_token, test_instance): - """ALTER 大表 — 应标记为 critical 并建议使用在线变更工具。""" - analysis = _make_analysis( - sql_type="ALTER", - estimated_rows=2000000, - estimated_duration_ms=300000, - table_size_mb=2048.0, - table_row_count=2000000, - risk_level="critical", - warnings=[ - "ALTER TABLE 操作表大小为 2048.0MB,建议使用 pt-online-schema-change 或 gh-ost 进行在线变更", - ], - ) - - with patch( - "app.api.v1.endpoints.impact.ImpactAnalyzer.analyze", - new_callable=AsyncMock, - return_value=analysis, - ): - resp = await client.post( - "/api/v1/impact/analyze", - json=_payload( - test_instance.id, - sql_content="ALTER TABLE big_table ADD COLUMN age INT;", - ), - headers=auth_header(admin_token), - ) - - assert resp.status_code == 200 - data = resp.json() - assert data["sql_type"] == "ALTER" - assert data["risk_level"] == "critical" - assert data["table_size_mb"] > 1024 - assert any("pt-online-schema-change" in w or "gh-ost" in w for w in data["warnings"]) - - @pytest.mark.asyncio - async def test_analyze_response_contains_all_fields(self, client, db, admin_token, test_instance): - """验证响应包含 ImpactAnalysisResponse 中定义的所有字段。""" - analysis = _make_analysis( - index_usage=["PRIMARY", "idx_name"], - warnings=["建议添加 LIMIT 条件"], - ) - - with patch( - "app.api.v1.endpoints.impact.ImpactAnalyzer.analyze", - new_callable=AsyncMock, - return_value=analysis, - ): - resp = await client.post( - "/api/v1/impact/analyze", - json=_payload(test_instance.id), - headers=auth_header(admin_token), - ) - - assert resp.status_code == 200 - data = resp.json() - expected_keys = { - "sql_type", "estimated_rows", "estimated_duration_ms", - "table_size_mb", "table_row_count", "index_usage", - "has_full_scan", "risk_level", "warnings", - } - assert set(data.keys()) == expected_keys - assert data["index_usage"] == ["PRIMARY", "idx_name"] - assert data["warnings"] == ["建议添加 LIMIT 条件"] diff --git a/backend/tests/test_instance_access_control.py b/backend/tests/test_instance_access_control.py deleted file mode 100644 index d135365..0000000 --- a/backend/tests/test_instance_access_control.py +++ /dev/null @@ -1,476 +0,0 @@ -"""P0 安全测试: 实例访问控制 — 权限组过滤、403 拒绝、admin 旁路。 - -测试矩阵: -- GET /instances 权限组过滤(admin看全部, developer只看自己组+公开) -- GET /instances/{id} 权限检查(有权限/无权限/公开实例) -- GET /instances/{id}/databases 权限检查 -- GET /instances/{id}/databases/{db}/tables 权限检查 -""" -import pytest -from app.models.instance import Instance -from app.models.instance_group import InstanceGroup, user_groups -from app.core.security import hash_password, create_access_token -from app.models.user import User - - -# ── Fixtures ────────────────────────────────────────────── - -@pytest.fixture -async def group_with_instance(db): - """创建一个权限组 + 一个关联的私有实例""" - group = InstanceGroup( - name="生产MySQL组", - description="生产环境MySQL", - db_type="mysql", - ) - db.add(group) - await db.flush() - - instance = Instance( - name="生产MySQL", - db_type="mysql", - host="10.0.0.1", - port=3306, - username="root", - password="encrypted_prod_pw", - database="prod_db", - env="production", - status="disconnected", - group_id=group.id, - ) - db.add(instance) - await db.flush() - await db.commit() - await db.refresh(group) - await db.refresh(instance) - return group, instance - - -@pytest.fixture -async def public_instance(db): - """创建一个无权限组的公开实例""" - instance = Instance( - name="开发MySQL", - db_type="mysql", - host="127.0.0.1", - port=3306, - username="root", - password="encrypted_dev_pw", - database="dev_db", - env="development", - status="disconnected", - group_id=None, - ) - db.add(instance) - await db.commit() - await db.refresh(instance) - return instance - - -@pytest.fixture -async def dev_in_group(db, developer_user, group_with_instance): - """将 developer 用户加入权限组""" - group, instance = group_with_instance - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, - group_id=group.id, - ) - ) - await db.commit() - return developer_user, group, instance - - -@pytest.fixture -async def dev_not_in_any_group(db, developer_user): - """developer 用户不属于任何权限组""" - return developer_user - - -# ── Test: GET /instances 权限组过滤 ─────────────────────── - -class TestListInstancesPermissionFilter: - """GET /instances — 按权限组过滤实例列表""" - - @pytest.mark.asyncio - async def test_admin_sees_all_instances( - self, client, admin_token, group_with_instance, public_instance - ): - """admin 能看到所有实例(包括有权限组的和公开的)""" - resp = await client.get( - "/api/v1/instances", - headers={"Authorization": f"Bearer {admin_token}"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 2 - names = {i["name"] for i in data} - assert "生产MySQL" in names - assert "开发MySQL" in names - - @pytest.mark.asyncio - async def test_developer_in_group_sees_own_plus_public( - self, client, dev_in_group, public_instance - ): - """属于权限组的 developer 能看到自己组的实例 + 公开实例""" - dev_user, group, group_instance = dev_in_group - token = create_access_token(data={"sub": str(dev_user.id), "role": dev_user.role}) - - resp = await client.get( - "/api/v1/instances", - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 2 - names = {i["name"] for i in data} - assert "生产MySQL" in names # 自己权限组内的 - assert "开发MySQL" in names # 公开的 - - @pytest.mark.asyncio - async def test_developer_not_in_group_sees_only_public( - self, client, developer_user, developer_token, - group_with_instance, public_instance - ): - """不属于任何权限组的 developer 只能看到公开实例""" - resp = await client.get( - "/api/v1/instances", - headers={"Authorization": f"Bearer {developer_token}"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 1 - assert data[0]["name"] == "开发MySQL" - - @pytest.mark.asyncio - async def test_developer_no_group_no_public( - self, client, developer_token, group_with_instance - ): - """无权限组 + 无公开实例 = 空列表""" - resp = await client.get( - "/api/v1/instances", - headers={"Authorization": f"Bearer {developer_token}"}, - ) - assert resp.status_code == 200 - assert resp.json() == [] - - @pytest.mark.asyncio - async def test_guest_sees_only_public( - self, client, guest_user, guest_token, - group_with_instance, public_instance - ): - """guest 只能看到公开实例""" - resp = await client.get( - "/api/v1/instances", - headers={"Authorization": f"Bearer {guest_token}"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 1 - assert data[0]["name"] == "开发MySQL" - - @pytest.mark.asyncio - async def test_filter_by_db_type_with_permission( - self, client, dev_in_group, public_instance - ): - """权限过滤 + db_type 过滤组合""" - dev_user, group, group_instance = dev_in_group - token = create_access_token(data={"sub": str(dev_user.id), "role": dev_user.role}) - - resp = await client.get( - "/api/v1/instances?db_type=mysql", - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - assert len(resp.json()) == 2 # 两个都是 mysql - - @pytest.mark.asyncio - async def test_filter_by_env_with_permission( - self, client, dev_in_group, public_instance - ): - """权限过滤 + env 过滤组合""" - dev_user, group, group_instance = dev_in_group - token = create_access_token(data={"sub": str(dev_user.id), "role": dev_user.role}) - - # 只看 production - resp = await client.get( - "/api/v1/instances?env=production", - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 1 - assert data[0]["name"] == "生产MySQL" - - -# ── Test: GET /instances/{id} 权限检查 ──────────────────── - -class TestGetInstancePermission: - """GET /instances/{id} — 单实例访问权限检查""" - - @pytest.mark.asyncio - async def test_admin_can_access_any_instance( - self, client, admin_token, group_with_instance - ): - """admin 能访问任何实例(包括有权限组的)""" - _, instance = group_with_instance - resp = await client.get( - f"/api/v1/instances/{instance.id}", - headers={"Authorization": f"Bearer {admin_token}"}, - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "生产MySQL" - - @pytest.mark.asyncio - async def test_developer_in_group_can_access( - self, client, dev_in_group - ): - """属于权限组的 developer 能访问组内实例""" - dev_user, group, instance = dev_in_group - token = create_access_token(data={"sub": str(dev_user.id), "role": dev_user.role}) - - resp = await client.get( - f"/api/v1/instances/{instance.id}", - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "生产MySQL" - - @pytest.mark.asyncio - async def test_developer_not_in_group_gets_403( - self, client, developer_token, group_with_instance - ): - """不属于权限组的 developer 访问受限实例 → 403""" - _, instance = group_with_instance - resp = await client.get( - f"/api/v1/instances/{instance.id}", - headers={"Authorization": f"Bearer {developer_token}"}, - ) - assert resp.status_code == 403 - assert "权限" in resp.json()["detail"] - - @pytest.mark.asyncio - async def test_anyone_can_access_public_instance( - self, client, developer_token, public_instance - ): - """任何认证用户都能访问公开实例(group_id=None)""" - resp = await client.get( - f"/api/v1/instances/{public_instance.id}", - headers={"Authorization": f"Bearer {developer_token}"}, - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "开发MySQL" - - @pytest.mark.asyncio - async def test_guest_can_access_public_instance( - self, client, guest_token, public_instance - ): - """guest 也能访问公开实例""" - resp = await client.get( - f"/api/v1/instances/{public_instance.id}", - headers={"Authorization": f"Bearer {guest_token}"}, - ) - assert resp.status_code == 200 - - @pytest.mark.asyncio - async def test_nonexistent_instance_returns_404( - self, client, admin_token - ): - """不存在的实例 → 404(不是 403)""" - resp = await client.get( - "/api/v1/instances/99999", - headers={"Authorization": f"Bearer {admin_token}"}, - ) - assert resp.status_code == 404 - - -# ── Test: GET /instances/{id}/databases 权限检查 ────────── - -class TestListDatabasesPermission: - """GET /instances/{id}/databases — 数据库列表访问控制""" - - @pytest.mark.asyncio - async def test_developer_not_in_group_denied( - self, client, developer_token, group_with_instance - ): - """非权限组成员无法访问 databases 端点""" - _, instance = group_with_instance - resp = await client.get( - f"/api/v1/instances/{instance.id}/databases", - headers={"Authorization": f"Bearer {developer_token}"}, - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_developer_in_group_allowed( - self, client, dev_in_group - ): - """权限组成员可以访问 databases 端点(即使连接失败也是 500 不是 403)""" - dev_user, group, instance = dev_in_group - token = create_access_token(data={"sub": str(dev_user.id), "role": dev_user.role}) - - # 注意:测试环境没有真实 MySQL,InstanceService.list_databases 会连接超时 - # 所以我们只验证权限检查通过(不是 403),不验证实际结果 - # 改用更短的超时检查:如果返回 403 说明权限没通过 - # 直接验证 get_instance 端点作为替代(因为 databases 会卡在真实连接) - resp = await client.get( - f"/api/v1/instances/{instance.id}", - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "生产MySQL" - - -# ── Test: GET /instances/{id}/databases/{db}/tables 权限检查 ── - -class TestListTablesPermission: - """GET /instances/{id}/databases/{db}/tables — 表列表访问控制""" - - @pytest.mark.asyncio - async def test_developer_not_in_group_denied( - self, client, developer_token, group_with_instance - ): - """非权限组成员无法访问 tables 端点""" - _, instance = group_with_instance - resp = await client.get( - f"/api/v1/instances/{instance.id}/databases/test_db/tables", - headers={"Authorization": f"Bearer {developer_token}"}, - ) - assert resp.status_code == 403 - - -# ── Test: 多权限组交叉 ──────────────────────────────────── - -class TestMultipleGroupCrossAccess: - """多权限组场景测试""" - - @pytest.mark.asyncio - async def test_user_in_group_a_cannot_access_group_b_instance( - self, client, db, developer_user - ): - """属于组A的用户不能访问组B的实例""" - # 创建组A(用户属于) - group_a = InstanceGroup(name="组A", db_type="mysql") - db.add(group_a) - await db.flush() - - instance_a = Instance( - name="组A实例", db_type="mysql", host="10.0.0.1", port=3306, - username="root", password="pw", database="db_a", - status="disconnected", group_id=group_a.id, - ) - db.add(instance_a) - - # 创建组B(用户不属于) - group_b = InstanceGroup(name="组B", db_type="mysql") - db.add(group_b) - await db.flush() - - instance_b = Instance( - name="组B实例", db_type="mysql", host="10.0.0.2", port=3306, - username="root", password="pw", database="db_b", - status="disconnected", group_id=group_b.id, - ) - db.add(instance_b) - - # 公开实例 - instance_pub = Instance( - name="公开实例", db_type="mysql", host="127.0.0.1", port=3306, - username="root", password="pw", database="db_pub", - status="disconnected", group_id=None, - ) - db.add(instance_pub) - - # developer 加入组A - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, group_id=group_a.id - ) - ) - await db.commit() - - token = create_access_token(data={"sub": str(developer_user.id), "role": developer_user.role}) - - # 列表应该只有 组A实例 + 公开实例 - resp = await client.get( - "/api/v1/instances", - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - names = {i["name"] for i in resp.json()} - assert "组A实例" in names - assert "公开实例" in names - assert "组B实例" not in names - - # 直接访问组B实例 → 403 - resp = await client.get( - f"/api/v1/instances/{instance_b.id}", - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 403 - - # 直接访问组A实例 → 200 - resp = await client.get( - f"/api/v1/instances/{instance_a.id}", - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - - # 直接访问公开实例 → 200 - resp = await client.get( - f"/api/v1/instances/{instance_pub.id}", - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 200 - - -# ── Test: permissions.py 工具函数 ───────────────────────── - -class TestPermissionUtilities: - """测试 permissions.py 中的工具函数""" - - @pytest.mark.asyncio - async def test_get_user_accessible_group_ids_admin(self, db, admin_user): - """admin 返回空列表""" - from app.core.permissions import get_user_accessible_group_ids - result = await get_user_accessible_group_ids(admin_user, db) - assert result == [] - - @pytest.mark.asyncio - async def test_get_user_accessible_group_ids_with_groups( - self, db, developer_user - ): - """普通用户返回其所属权限组列表""" - from app.core.permissions import get_user_accessible_group_ids - - group = InstanceGroup(name="测试组", db_type="mysql") - db.add(group) - await db.flush() - - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, group_id=group.id - ) - ) - await db.commit() - - result = await get_user_accessible_group_ids(developer_user, db) - assert len(result) == 1 - assert group.id in result - - @pytest.mark.asyncio - async def test_get_instance_or_403_admin(self, db, admin_user, test_instance): - """admin 通过 get_instance_or_403 能获取任何实例""" - from app.core.permissions import get_instance_or_403 - instance = await get_instance_or_403(test_instance.id, admin_user, db) - assert instance.id == test_instance.id - - @pytest.mark.asyncio - async def test_get_instance_or_403_not_found(self, db, admin_user): - """不存在的实例 → 404""" - from app.core.permissions import get_instance_or_403 - from fastapi import HTTPException - - with pytest.raises(HTTPException) as exc_info: - await get_instance_or_403(99999, admin_user, db) - assert exc_info.value.status_code == 404 diff --git a/backend/tests/test_instance_detail.py b/backend/tests/test_instance_detail.py deleted file mode 100644 index c599289..0000000 --- a/backend/tests/test_instance_detail.py +++ /dev/null @@ -1,343 +0,0 @@ -"""Tests for instance detail API endpoints and backup API.""" -import sys -from unittest.mock import patch, AsyncMock, MagicMock -from contextlib import asynccontextmanager - -import pytest -from httpx import AsyncClient - -from tests.conftest import auth_header - - -def _make_aiomysql_mock(): - """Create a mock aiomysql module.""" - aiomysql = MagicMock() - aiomysql.DictCursor = MagicMock() - return aiomysql - - -def _mock_mysql_connection(rows=None, description=None): - """Create a mock MySQL connection that returns given rows.""" - if rows is None: - rows = [] - - mock_cursor = AsyncMock() - mock_cursor.execute = AsyncMock() - mock_cursor.fetchall = AsyncMock(return_value=rows) - mock_cursor.description = description - - @asynccontextmanager - async def mock_cursor_ctx(*args, **kwargs): - yield mock_cursor - - mock_conn = AsyncMock() - mock_conn.cursor = mock_cursor_ctx - mock_conn.close = MagicMock() - - return mock_conn, mock_cursor - - -class TestListDatabases: - async def test_list_databases_success(self, client: AsyncClient, test_instance, admin_token): - mock_conn, _ = _mock_mysql_connection([ - {"Database": "test_db"}, - {"Database": "myapp"}, - {"Database": "information_schema"}, - ]) - - mock_aiomysql = _make_aiomysql_mock() - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.get( - "/api/v1/instances/" + str(test_instance.id) + "/detail/databases", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert "test_db" in data - assert "myapp" in data - # information_schema should be filtered out - assert "information_schema" not in data - - async def test_list_databases_requires_admin(self, client: AsyncClient, test_instance, developer_token): - resp = await client.get( - "/api/v1/instances/" + str(test_instance.id) + "/detail/databases", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -class TestListTables: - async def test_list_tables_success(self, client: AsyncClient, test_instance, admin_token): - mock_conn, _ = _mock_mysql_connection([ - {"Name": "users", "Engine": "InnoDB", "Rows": 100, "Data_length": 16384, - "Index_length": 0, "Comment": "", "Collation": "utf8mb4_general_ci", - "Create_time": "2024-01-01", "Update_time": None}, - ]) - - mock_aiomysql = _make_aiomysql_mock() - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.get( - "/api/v1/instances/" + str(test_instance.id) + "/detail/tables", - params={"database": "test_db"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 1 - assert data[0]["name"] == "users" - assert data[0]["engine"] == "InnoDB" - assert data[0]["rows"] == 100 - - -class TestTableDetail: - async def test_table_detail_success(self, client: AsyncClient, test_instance, admin_token): - # Need three separate connections for DDL, columns, indexes - ddl_conn, _ = _mock_mysql_connection([ - {"Table": "users", "Create Table": "CREATE TABLE users (id INT PRIMARY KEY)"}, - ]) - col_conn, _ = _mock_mysql_connection([ - {"Field": "id", "Type": "int", "Null": "NO", "Key": "PRI", "Default": None, "Extra": "auto_increment"}, - ]) - idx_conn, _ = _mock_mysql_connection([ - {"Table": "users", "Non_unique": 0, "Key_name": "PRIMARY", "Column_name": "id"}, - ]) - - call_count = 0 - - async def mock_connect(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - return ddl_conn - elif call_count == 2: - return col_conn - else: - return idx_conn - - mock_aiomysql = _make_aiomysql_mock() - mock_aiomysql.connect = mock_connect - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.get( - "/api/v1/instances/" + str(test_instance.id) + "/detail/table-detail", - params={"database": "test_db", "table": "users"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert "ddl" in data - assert "columns" in data - assert "indexes" in data - assert "CREATE TABLE" in data["ddl"] - - -class TestListUsers: - async def test_list_users_success(self, client: AsyncClient, test_instance, admin_token): - mock_conn, _ = _mock_mysql_connection([ - {"user": "root", "host": "localhost"}, - {"user": "admin", "host": "%"}, - ]) - - mock_aiomysql = _make_aiomysql_mock() - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.get( - "/api/v1/instances/" + str(test_instance.id) + "/detail/users", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 2 - assert data[0]["user"] == "root" - - -class TestShowGrants: - async def test_show_grants_success(self, client: AsyncClient, test_instance, admin_token): - mock_conn, _ = _mock_mysql_connection([ - {"Grants for root@localhost": "GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost'"}, - ]) - - mock_aiomysql = _make_aiomysql_mock() - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.get( - "/api/v1/instances/" + str(test_instance.id) + "/detail/grants", - params={"user": "root", "host": "localhost"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["user"] == "root" - assert len(data["grants"]) >= 1 - - -class TestProcesslist: - async def test_show_processlist_success(self, client: AsyncClient, test_instance, admin_token): - mock_conn, _ = _mock_mysql_connection([ - {"Id": 1, "User": "root", "Host": "localhost", "db": "test_db", - "Command": "Query", "Time": 10, "State": "executing", "Info": "SELECT 1"}, - ]) - - mock_aiomysql = _make_aiomysql_mock() - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.get( - "/api/v1/instances/" + str(test_instance.id) + "/detail/processlist", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 1 - assert data[0]["Id"] == 1 - - -class TestKillProcess: - async def test_kill_process_success(self, client: AsyncClient, test_instance, admin_token): - mock_conn, _ = _mock_mysql_connection([]) - - mock_aiomysql = _make_aiomysql_mock() - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.post( - "/api/v1/instances/" + str(test_instance.id) + "/detail/processlist/123/kill", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["pid"] == 123 - assert "已终止" in data["message"] - - -class TestShowStatus: - async def test_show_status_success(self, client: AsyncClient, test_instance, admin_token): - mock_conn, _ = _mock_mysql_connection([ - {"Variable_name": "Threads_connected", "Value": "5"}, - {"Variable_name": "Queries", "Value": "1000"}, - {"Variable_name": "Slow_queries", "Value": "10"}, - {"Variable_name": "Uptime", "Value": "86400"}, - ]) - - mock_aiomysql = _make_aiomysql_mock() - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.get( - "/api/v1/instances/" + str(test_instance.id) + "/detail/status", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert "key_metrics" in data - assert data["key_metrics"]["Threads_connected"] == "5" - - -class TestShowVariables: - async def test_show_variables_success(self, client: AsyncClient, test_instance, admin_token): - mock_conn, _ = _mock_mysql_connection([ - {"Variable_name": "max_connections", "Value": "151"}, - {"Variable_name": "slow_query_log", "Value": "ON"}, - ]) - - mock_aiomysql = _make_aiomysql_mock() - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.get( - "/api/v1/instances/" + str(test_instance.id) + "/detail/variables", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 2 - - -class TestBackupAPI: - async def test_create_backup(self, client: AsyncClient, test_instance, admin_token): - resp = await client.post( - "/api/v1/backups", - headers=auth_header(admin_token), - json={ - "title": "测试备份", - "instance_id": test_instance.id, - "database": "test_db", - "remark": "自动备份", - }, - ) - assert resp.status_code == 201 - data = resp.json() - assert data["title"] == "测试备份" - assert data["status"] == "pending" - assert data["database"] == "test_db" - - async def test_list_backups(self, client: AsyncClient, test_instance, admin_token): - # Create a backup first - await client.post( - "/api/v1/backups", - headers=auth_header(admin_token), - json={ - "title": "备份1", - "instance_id": test_instance.id, - "database": "test_db", - }, - ) - resp = await client.get( - "/api/v1/backups", - params={"instance_id": test_instance.id}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data) >= 1 - - async def test_get_backup_detail(self, client: AsyncClient, test_instance, admin_token): - # Create a backup - create_resp = await client.post( - "/api/v1/backups", - headers=auth_header(admin_token), - json={ - "title": "备份详情测试", - "instance_id": test_instance.id, - "database": "test_db", - }, - ) - backup_id = create_resp.json()["id"] - - resp = await client.get( - "/api/v1/backups/" + str(backup_id), - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["id"] == backup_id - assert data["title"] == "备份详情测试" - - async def test_get_backup_not_found(self, client: AsyncClient, admin_token): - resp = await client.get( - "/api/v1/backups/99999", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - async def test_backup_requires_admin(self, client: AsyncClient, developer_token): - resp = await client.get( - "/api/v1/backups", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -class TestInstanceNotFound: - async def test_databases_instance_not_found(self, client: AsyncClient, admin_token): - resp = await client.get( - "/api/v1/instances/99999/detail/databases", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 diff --git a/backend/tests/test_instance_fields.py b/backend/tests/test_instance_fields.py deleted file mode 100644 index ee1a200..0000000 --- a/backend/tests/test_instance_fields.py +++ /dev/null @@ -1,144 +0,0 @@ -"""测试 Instance 新增字段:access_mode, workflow_id, excluded_databases, mask_fields""" -import pytest -from tests.conftest import auth_header - - -@pytest.mark.asyncio -async def test_create_instance_with_full_fields(client, admin_token): - """创建数据源:完整字段""" - res = await client.post("/api/v1/instances", json={ - "name": "prod-mysql-只读库", - "db_type": "mysql", - "host": "mysql.example.com", - "port": 3306, - "username": "admin", - "password": "***", - "env": "production", - "access_mode": "read", - "excluded_databases": ["sys", "information_schema", "mysql"], - "mask_fields": [ - {"table": "users", "field": "phone", "rule": "mask"}, - {"table": "users", "field": "id_card", "rule": "hide"}, - ], - }, headers=auth_header(admin_token)) - assert res.status_code == 201 - data = res.json() - assert data["access_mode"] == "read" - assert data["workflow_id"] is None - assert data["excluded_databases"] == ["sys", "information_schema", "mysql"] - assert len(data["mask_fields"]) == 2 - assert data["mask_fields"][0]["table"] == "users" - - -@pytest.mark.asyncio -async def test_create_instance_default_access_mode(client, admin_token): - """默认连接模式为 readwrite""" - res = await client.post("/api/v1/instances", json={ - "name": "test-default", - "db_type": "mysql", - "host": "127.0.0.1", - "port": 3306, - "username": "root", - "password": "***", - }, headers=auth_header(admin_token)) - assert res.status_code == 201 - assert res.json()["access_mode"] == "readwrite" - - -@pytest.mark.asyncio -async def test_list_instance_includes_new_fields(client, admin_token): - """列表接口返回新字段""" - await client.post("/api/v1/instances", json={ - "name": "list-test", - "db_type": "postgres", - "host": "10.0.0.1", - "port": 5432, - "username": "postgres", - "password": "***", - "access_mode": "write", - }, headers=auth_header(admin_token)) - - res = await client.get("/api/v1/instances", headers=auth_header(admin_token)) - assert res.status_code == 200 - items = res.json() - found = [i for i in items if i["name"] == "list-test"] - assert len(found) == 1 - assert found[0]["access_mode"] == "write" - assert "workflow_id" in found[0] - - -@pytest.mark.asyncio -async def test_update_instance_fields(client, admin_token): - """更新字段""" - create = await client.post("/api/v1/instances", json={ - "name": "update-fields", - "db_type": "mysql", - "host": "127.0.0.1", - "port": 3306, - "username": "root", - "password": "***", - }, headers=auth_header(admin_token)) - iid = create.json()["id"] - - res = await client.put(f"/api/v1/instances/{iid}", json={ - "access_mode": "read", - "excluded_databases": ["test_db"], - }, headers=auth_header(admin_token)) - assert res.status_code == 200 - data = res.json() - assert data["access_mode"] == "read" - assert data["excluded_databases"] == ["test_db"] - - -@pytest.mark.asyncio -async def test_instance_null_json_fields(client, admin_token): - """不传新字段时,返回 null""" - res = await client.post("/api/v1/instances", json={ - "name": "minimal", - "db_type": "mysql", - "host": "127.0.0.1", - "port": 3306, - "username": "root", - "password": "***", - }, headers=auth_header(admin_token)) - assert res.status_code == 201 - data = res.json() - assert data["workflow_id"] is None - assert data["excluded_databases"] is None - assert data["mask_fields"] is None - - -@pytest.mark.asyncio -async def test_instance_workflow_id_association(client, admin_token): - """数据源关联审批流程""" - # 创建 workflow - wf_res = await client.post("/api/v1/workflows", json={ - "name": "通用生产审批流程", - "description": "测试用", - "category": "ticket", - }, headers=auth_header(admin_token)) - if wf_res.status_code == 201: - wf_id = wf_res.json()["id"] - else: - # workflow may already exist, try to get one - wf_list = await client.get("/api/v1/workflows", headers=auth_header(admin_token)) - workflows = wf_list.json() - wf_id = workflows[0]["id"] if workflows else None - - if not wf_id: - pytest.skip("No workflow available") - - # 创建带 workflow_id 的实例 - res = await client.post("/api/v1/instances", json={ - "name": "wf-instance", - "db_type": "mysql", - "host": "127.0.0.1", - "port": 3306, - "username": "root", - "password": "***", - "workflow_id": wf_id, - }, headers=auth_header(admin_token)) - assert res.status_code == 201 - data = res.json() - assert data["workflow_id"] == wf_id - assert data["workflow_name"] is not None diff --git a/backend/tests/test_instance_group.py b/backend/tests/test_instance_group.py deleted file mode 100644 index 6d0e6ab..0000000 --- a/backend/tests/test_instance_group.py +++ /dev/null @@ -1,486 +0,0 @@ -import pytest -import pytest_asyncio -from httpx import ASGITransport, AsyncClient - -from app.core.security import hash_password, create_access_token -from app.models.user import User -from app.models.instance import Instance -from app.models.instance_group import InstanceGroup, instance_group_members -from tests.conftest import auth_header - - -@pytest.mark.asyncio -async def test_create_instance_group(client: AsyncClient, admin_token: str): - resp = await client.post( - "/api/v1/instance-groups", - json={ - "name": "生产MySQL组", - "description": "生产环境MySQL实例", - "db_type": "mysql", - "permissions": ["query:select", "query:insert", "ddl:create_table"], - "is_active": True, - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - assert data["name"] == "生产MySQL组" - assert data["db_type"] == "mysql" - assert "query:select" in data["permissions"] - - -@pytest.mark.asyncio -async def test_create_instance_group_duplicate_name(client: AsyncClient, admin_token: str): - payload = {"name": "重复组", "db_type": "mysql"} - resp1 = await client.post("/api/v1/instance-groups", json=payload, headers=auth_header(admin_token)) - assert resp1.status_code == 201 - resp2 = await client.post("/api/v1/instance-groups", json=payload, headers=auth_header(admin_token)) - assert resp2.status_code == 400 - - -@pytest.mark.asyncio -async def test_list_instance_groups(client: AsyncClient, admin_token: str): - # Create two groups - await client.post( - "/api/v1/instance-groups", - json={"name": "组A", "db_type": "mysql"}, - headers=auth_header(admin_token), - ) - await client.post( - "/api/v1/instance-groups", - json={"name": "组B", "db_type": "redis"}, - headers=auth_header(admin_token), - ) - resp = await client.get("/api/v1/instance-groups", headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert len(data) >= 2 - - -@pytest.mark.asyncio -async def test_list_instance_groups_filter_db_type(client: AsyncClient, admin_token: str): - await client.post( - "/api/v1/instance-groups", - json={"name": "过滤MySQL", "db_type": "mysql"}, - headers=auth_header(admin_token), - ) - resp = await client.get( - "/api/v1/instance-groups", - params={"db_type": "mysql"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert all(g["db_type"] == "mysql" for g in data) - - -@pytest.mark.asyncio -async def test_get_instance_group(client: AsyncClient, admin_token: str): - create_resp = await client.post( - "/api/v1/instance-groups", - json={"name": "详情组", "db_type": "pg", "permissions": ["query:select"]}, - headers=auth_header(admin_token), - ) - group_id = create_resp.json()["id"] - resp = await client.get( - f"/api/v1/instance-groups/{group_id}", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["name"] == "详情组" - assert data["permissions"] == ["query:select"] - - -@pytest.mark.asyncio -async def test_get_instance_group_not_found(client: AsyncClient, admin_token: str): - resp = await client.get("/api/v1/instance-groups/9999", headers=auth_header(admin_token)) - assert resp.status_code == 404 - - -@pytest.mark.asyncio -async def test_update_instance_group(client: AsyncClient, admin_token: str): - create_resp = await client.post( - "/api/v1/instance-groups", - json={"name": "更新前", "db_type": "mysql"}, - headers=auth_header(admin_token), - ) - group_id = create_resp.json()["id"] - resp = await client.put( - f"/api/v1/instance-groups/{group_id}", - json={"name": "更新后", "description": "已更新"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["name"] == "更新后" - assert data["description"] == "已更新" - - -@pytest.mark.asyncio -async def test_delete_instance_group(client: AsyncClient, admin_token: str): - create_resp = await client.post( - "/api/v1/instance-groups", - json={"name": "删除组", "db_type": "redis"}, - headers=auth_header(admin_token), - ) - group_id = create_resp.json()["id"] - resp = await client.delete( - f"/api/v1/instance-groups/{group_id}", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - # Verify deleted - get_resp = await client.get( - f"/api/v1/instance-groups/{group_id}", - headers=auth_header(admin_token), - ) - assert get_resp.status_code == 404 - - -@pytest.mark.asyncio -async def test_assign_instances_to_group(client: AsyncClient, admin_token: str, db): - # Create group - create_resp = await client.post( - "/api/v1/instance-groups", - json={"name": "实例关联组", "db_type": "mysql"}, - headers=auth_header(admin_token), - ) - group_id = create_resp.json()["id"] - - # Create instance directly - inst = Instance( - name="测试实例1", db_type="mysql", host="127.0.0.1", - port=3306, username="root", password="test", database="test", - ) - db.add(inst) - await db.commit() - await db.refresh(inst) - - resp = await client.post( - f"/api/v1/instance-groups/{group_id}/instances", - json={"instance_ids": [inst.id], "access_type": "readonly"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - # Verify via group detail - detail_resp = await client.get( - f"/api/v1/instance-groups/{group_id}", - headers=auth_header(admin_token), - ) - data = detail_resp.json() - assert data["readonly_instance_count"] >= 1 - assert any(i["id"] == inst.id for i in data["readonly_instances"]) - - -@pytest.mark.asyncio -async def test_assign_write_instances_to_group(client: AsyncClient, admin_token: str, db): - create_resp = await client.post( - "/api/v1/instance-groups", - json={"name": "写入实例组", "db_type": "mysql"}, - headers=auth_header(admin_token), - ) - group_id = create_resp.json()["id"] - - inst = Instance( - name="写入实例", db_type="mysql", host="127.0.0.1", - port=3306, username="root", password="test", database="test", - ) - db.add(inst) - await db.commit() - await db.refresh(inst) - - resp = await client.post( - f"/api/v1/instance-groups/{group_id}/instances", - json={"instance_ids": [inst.id], "access_type": "write"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - detail_resp = await client.get( - f"/api/v1/instance-groups/{group_id}", - headers=auth_header(admin_token), - ) - data = detail_resp.json() - assert data["write_instance_count"] >= 1 - assert any(i["id"] == inst.id for i in data["write_instances"]) - - -@pytest.mark.asyncio -async def test_assign_instances_default_readonly(client: AsyncClient, admin_token: str, db): - """Test that access_type defaults to readonly when not specified.""" - create_resp = await client.post( - "/api/v1/instance-groups", - json={"name": "默认只读组", "db_type": "mysql"}, - headers=auth_header(admin_token), - ) - group_id = create_resp.json()["id"] - - inst = Instance( - name="默认只读实例", db_type="mysql", host="127.0.0.1", - port=3306, username="root", password="test", database="test", - ) - db.add(inst) - await db.commit() - await db.refresh(inst) - - # Send without access_type — should default to readonly - resp = await client.post( - f"/api/v1/instance-groups/{group_id}/instances", - json={"instance_ids": [inst.id]}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - detail_resp = await client.get( - f"/api/v1/instance-groups/{group_id}", - headers=auth_header(admin_token), - ) - data = detail_resp.json() - assert data["readonly_instance_count"] >= 1 - - -@pytest.mark.asyncio -async def test_remove_instance_from_group(client: AsyncClient, admin_token: str, db): - # Create group + instance - create_resp = await client.post( - "/api/v1/instance-groups", - json={"name": "移除实例组", "db_type": "mysql"}, - headers=auth_header(admin_token), - ) - group_id = create_resp.json()["id"] - - inst = Instance( - name="移除实例", db_type="mysql", host="127.0.0.1", - port=3306, username="root", password="test", database="test", - ) - db.add(inst) - await db.commit() - await db.refresh(inst) - - # Assign first - await client.post( - f"/api/v1/instance-groups/{group_id}/instances", - json={"instance_ids": [inst.id], "access_type": "readonly"}, - headers=auth_header(admin_token), - ) - # Remove - resp = await client.delete( - f"/api/v1/instance-groups/{group_id}/instances/{inst.id}", - params={"access_type": "readonly"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - -@pytest.mark.asyncio -async def test_assign_instances_on_create(client: AsyncClient, admin_token: str, db): - """Test creating a group with readonly_instance_ids and write_instance_ids.""" - inst1 = Instance( - name="创建时只读实例", db_type="mysql", host="127.0.0.1", - port=3306, username="root", password="test", database="test", - ) - inst2 = Instance( - name="创建时写入实例", db_type="mysql", host="127.0.0.1", - port=3307, username="root", password="test", database="test", - ) - db.add(inst1) - db.add(inst2) - await db.commit() - await db.refresh(inst1) - await db.refresh(inst2) - - resp = await client.post( - "/api/v1/instance-groups", - json={ - "name": "创建时关联实例组", - "db_type": "mysql", - "readonly_instance_ids": [inst1.id], - "write_instance_ids": [inst2.id], - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - assert data["readonly_instance_count"] == 1 - assert data["write_instance_count"] == 1 - - # Verify detail - detail = await client.get( - f"/api/v1/instance-groups/{data['id']}", - headers=auth_header(admin_token), - ) - detail_data = detail.json() - assert len(detail_data["readonly_instances"]) == 1 - assert len(detail_data["write_instances"]) == 1 - - -@pytest.mark.asyncio -async def test_update_group_members(client: AsyncClient, admin_token: str, db): - """Test updating a group's instance members.""" - inst1 = Instance( - name="更新成员实例1", db_type="mysql", host="127.0.0.1", - port=3306, username="root", password="test", database="test", - ) - inst2 = Instance( - name="更新成员实例2", db_type="mysql", host="127.0.0.1", - port=3307, username="root", password="test", database="test", - ) - db.add(inst1) - db.add(inst2) - await db.commit() - await db.refresh(inst1) - await db.refresh(inst2) - - create_resp = await client.post( - "/api/v1/instance-groups", - json={ - "name": "更新成员组", - "db_type": "mysql", - "readonly_instance_ids": [inst1.id], - }, - headers=auth_header(admin_token), - ) - group_id = create_resp.json()["id"] - - # Update to replace members - resp = await client.put( - f"/api/v1/instance-groups/{group_id}", - json={ - "readonly_instance_ids": [inst2.id], - "write_instance_ids": [inst1.id], - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - detail = await client.get( - f"/api/v1/instance-groups/{group_id}", - headers=auth_header(admin_token), - ) - data = detail.json() - assert data["readonly_instance_count"] == 1 - assert data["write_instance_count"] == 1 - assert data["readonly_instances"][0]["id"] == inst2.id - assert data["write_instances"][0]["id"] == inst1.id - - -@pytest.mark.asyncio -async def test_assign_users_to_group(client: AsyncClient, admin_token: str, admin_user): - create_resp = await client.post( - "/api/v1/instance-groups", - json={"name": "用户关联组", "db_type": "all"}, - headers=auth_header(admin_token), - ) - group_id = create_resp.json()["id"] - - resp = await client.post( - f"/api/v1/instance-groups/{group_id}/users", - json={"user_ids": [admin_user.id], "permissions": ["query:select"]}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - # Verify via group users - users_resp = await client.get( - f"/api/v1/instance-groups/{group_id}/users", - headers=auth_header(admin_token), - ) - assert users_resp.status_code == 200 - users_data = users_resp.json() - assert any(u["id"] == admin_user.id for u in users_data) - - -@pytest.mark.asyncio -async def test_update_user_groups(client: AsyncClient, admin_token: str, admin_user): - # Create two groups - g1 = await client.post( - "/api/v1/instance-groups", - json={"name": "用户组1", "db_type": "mysql"}, - headers=auth_header(admin_token), - ) - g2 = await client.post( - "/api/v1/instance-groups", - json={"name": "用户组2", "db_type": "redis"}, - headers=auth_header(admin_token), - ) - g1_id = g1.json()["id"] - g2_id = g2.json()["id"] - - resp = await client.put( - f"/api/v1/instance-groups/users/{admin_user.id}/groups", - json={"group_ids": [g1_id, g2_id]}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - -@pytest.mark.asyncio -async def test_non_admin_cannot_create_group(client: AsyncClient, developer_token: str): - resp = await client.post( - "/api/v1/instance-groups", - json={"name": "非管理员组", "db_type": "mysql"}, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_non_admin_cannot_delete_group(client: AsyncClient, admin_token: str, developer_token: str): - create_resp = await client.post( - "/api/v1/instance-groups", - json={"name": "禁止删除组", "db_type": "mysql"}, - headers=auth_header(admin_token), - ) - group_id = create_resp.json()["id"] - resp = await client.delete( - f"/api/v1/instance-groups/{group_id}", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_group_db_type_validation(client: AsyncClient, admin_token: str): - resp = await client.post( - "/api/v1/instance-groups", - json={"name": "无效类型", "db_type": "oracle"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 422 - - -@pytest.mark.asyncio -async def test_same_instance_readonly_and_write(client: AsyncClient, admin_token: str, db): - """Test that the same instance can be both readonly and write in the same group.""" - inst = Instance( - name="双重实例", db_type="mysql", host="127.0.0.1", - port=3306, username="root", password="test", database="test", - ) - db.add(inst) - await db.commit() - await db.refresh(inst) - - create_resp = await client.post( - "/api/v1/instance-groups", - json={ - "name": "双重权限组", - "db_type": "mysql", - "readonly_instance_ids": [inst.id], - "write_instance_ids": [inst.id], - }, - headers=auth_header(admin_token), - ) - assert create_resp.status_code == 201 - data = create_resp.json() - assert data["readonly_instance_count"] == 1 - assert data["write_instance_count"] == 1 - - detail = await client.get( - f"/api/v1/instance-groups/{data['id']}", - headers=auth_header(admin_token), - ) - detail_data = detail.json() - assert any(i["id"] == inst.id for i in detail_data["readonly_instances"]) - assert any(i["id"] == inst.id for i in detail_data["write_instances"]) diff --git a/backend/tests/test_instance_permission.py b/backend/tests/test_instance_permission.py deleted file mode 100644 index 0175f57..0000000 --- a/backend/tests/test_instance_permission.py +++ /dev/null @@ -1,389 +0,0 @@ -"""P1 操作级权限测试: InstanceGroup.permissions 字段生效 - -测试矩阵: -- admin 不受 permissions 限制 -- 组没配置 permissions → 允许所有(向后兼容) -- 组配置了 ["query:select"] → 只有 select 允许,ticket:create 被拒绝 -- 用户级覆盖权限优先于组默认 -- 公开实例不受 permissions 限制 -- 集成测试: query/ticket 端点权限检查 -""" -import json -import sys -import pytest -from contextlib import asynccontextmanager -from fastapi import HTTPException -from unittest.mock import patch, AsyncMock, MagicMock - -from app.models.instance import Instance -from app.models.instance_group import InstanceGroup, user_groups -from app.core.security import create_access_token - - -# ── Fixtures ────────────────────────────────────────────── - -@pytest.fixture -async def group_with_permissions(db): - """创建带 permissions 配置的权限组""" - group = InstanceGroup( - name="只读组", - db_type="mysql", - permissions=json.dumps(["query:select", "instance:detail"]), - ) - db.add(group) - await db.flush() - - instance = Instance( - name="生产MySQL", db_type="mysql", host="10.0.0.1", port=3306, - username="root", password="pw", database="prod_db", - status="disconnected", group_id=group.id, - ) - db.add(instance) - await db.commit() - await db.refresh(group) - await db.refresh(instance) - return group, instance - - -@pytest.fixture -async def group_no_permissions(db): - """创建没有 permissions 配置的权限组(向后兼容)""" - group = InstanceGroup(name="默认组", db_type="mysql", permissions=None) - db.add(group) - await db.flush() - - instance = Instance( - name="测试MySQL", db_type="mysql", host="127.0.0.1", port=3306, - username="root", password="pw", database="test_db", - status="disconnected", group_id=group.id, - ) - db.add(instance) - await db.commit() - await db.refresh(group) - await db.refresh(instance) - return group, instance - - -# ── 单元测试: check_instance_permission ────────────────── - -class TestCheckInstancePermission: - """check_instance_permission 核心逻辑测试""" - - @pytest.mark.asyncio - async def test_admin_bypasses_permission_check( - self, db, admin_user, group_with_permissions - ): - """admin 不受 permissions 限制""" - from app.core.permissions import check_instance_permission - group, instance = group_with_permissions - # ticket:create 不在组 permissions 中,但 admin 应该通过 - await check_instance_permission(admin_user, instance, "ticket:create", db) - - @pytest.mark.asyncio - async def test_public_instance_allows_all(self, db, developer_user): - """公开实例不受 permissions 限制""" - from app.core.permissions import check_instance_permission - instance = Instance( - name="公开", db_type="mysql", host="127.0.0.1", port=3306, - username="root", password="pw", database="pub_db", - status="disconnected", group_id=None, - ) - db.add(instance) - await db.commit() - await check_instance_permission(developer_user, instance, "ticket:create", db) - - @pytest.mark.asyncio - async def test_allowed_action_passes( - self, db, developer_user, group_with_permissions - ): - """用户在组内,操作在 permissions 列表中 → 通过""" - from app.core.permissions import check_instance_permission - group, instance = group_with_permissions - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, group_id=group.id - ) - ) - await db.commit() - # query:select 在组 permissions 中 - await check_instance_permission(developer_user, instance, "query:select", db) - - @pytest.mark.asyncio - async def test_denied_action_raises_403( - self, db, developer_user, group_with_permissions - ): - """用户在组内,操作不在 permissions 列表中 → 403""" - from app.core.permissions import check_instance_permission - group, instance = group_with_permissions - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, group_id=group.id - ) - ) - await db.commit() - # ticket:create 不在组 permissions ["query:select", "instance:detail"] 中 - with pytest.raises(HTTPException) as exc_info: - await check_instance_permission( - developer_user, instance, "ticket:create", db - ) - assert exc_info.value.status_code == 403 - - @pytest.mark.asyncio - async def test_no_permissions_config_allows_all( - self, db, developer_user, group_no_permissions - ): - """组没有配置 permissions → 允许所有操作(向后兼容)""" - from app.core.permissions import check_instance_permission - group, instance = group_no_permissions - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, group_id=group.id - ) - ) - await db.commit() - # ticket:create 应该被允许,因为组没有配置限制 - await check_instance_permission(developer_user, instance, "ticket:create", db) - - @pytest.mark.asyncio - async def test_user_override_permissions( - self, db, developer_user, group_with_permissions - ): - """用户级覆盖权限优先于组默认""" - from app.core.permissions import check_instance_permission - group, instance = group_with_permissions - # 组 permissions = ["query:select", "instance:detail"] - # 用户覆盖为 ["query:select"] — 只给查询权限 - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, - group_id=group.id, - permissions=json.dumps(["query:select"]), - ) - ) - await db.commit() - - # query:select 通过(用户级覆盖包含) - await check_instance_permission(developer_user, instance, "query:select", db) - - # instance:detail 被拒绝(组默认有,但用户覆盖只给了 query:select) - with pytest.raises(HTTPException) as exc_info: - await check_instance_permission( - developer_user, instance, "instance:detail", db - ) - assert exc_info.value.status_code == 403 - - -# ── 单元测试: _get_effective_permissions ────────────────── - -class TestGetEffectivePermissions: - """_get_effective_permissions 权限解析逻辑""" - - @pytest.mark.asyncio - async def test_returns_all_when_no_config( - self, db, developer_user, group_no_permissions - ): - """无权限配置时返回全部权限""" - from app.core.permissions import _get_effective_permissions, PERMISSION_ACTIONS - group, instance = group_no_permissions - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, group_id=group.id - ) - ) - await db.commit() - result = await _get_effective_permissions(developer_user, instance, db) - assert result == PERMISSION_ACTIONS - - @pytest.mark.asyncio - async def test_returns_group_permissions( - self, db, developer_user, group_with_permissions - ): - """有组权限配置时返回组权限""" - from app.core.permissions import _get_effective_permissions - group, instance = group_with_permissions - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, group_id=group.id - ) - ) - await db.commit() - result = await _get_effective_permissions(developer_user, instance, db) - assert result == {"query:select", "instance:detail"} - - @pytest.mark.asyncio - async def test_user_override_takes_precedence( - self, db, developer_user, group_with_permissions - ): - """用户级覆盖优先于组默认""" - from app.core.permissions import _get_effective_permissions - group, instance = group_with_permissions - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, - group_id=group.id, - permissions=json.dumps(["query:select"]), - ) - ) - await db.commit() - result = await _get_effective_permissions(developer_user, instance, db) - assert result == {"query:select"} - - @pytest.mark.asyncio - async def test_admin_not_in_group( - self, db, admin_user, group_with_permissions - ): - """admin 不在任何组,但 check_instance_permission 不调用此函数(admin bypass)""" - # admin 在 check_instance_permission 中直接 return,不会调用 _get_effective_permissions - pass - - -# ── 集成测试: API 端点权限检查 ──────────────────────────── - -class TestPermissionEndpointIntegration: - """权限检查在实际 API 端点中的集成""" - - @pytest.mark.asyncio - async def test_query_with_permission( - self, client, db, developer_user, group_with_permissions - ): - """有 query:select 权限 → 查询不被 403(mock DB连接避免挂起)""" - group, instance = group_with_permissions - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, group_id=group.id - ) - ) - await db.commit() - token = create_access_token( - data={"sub": str(developer_user.id), "role": developer_user.role} - ) - - # Mock aiomysql 防止真实连接挂起(见 skill #20, #37) - mock_cursor = AsyncMock() - mock_cursor.description = [("1",)] - mock_cursor.fetchall.return_value = [{"1": 1}] - mock_cursor.close = AsyncMock() - - @asynccontextmanager - async def mock_cursor_ctx(*a, **kw): - yield mock_cursor - - mock_conn = AsyncMock() - mock_conn.cursor = mock_cursor_ctx - mock_conn.commit = AsyncMock() - mock_conn.rollback = AsyncMock() - mock_conn.close = AsyncMock() - - mock_aiomysql = MagicMock() - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - mock_aiomysql.DictCursor = MagicMock() - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.post( - "/api/v1/query", - json={ - "instance_id": instance.id, - "database": "test", - "sql": "SELECT 1", - }, - headers={"Authorization": f"Bearer {token}"}, - ) - # 不应是 403(权限通过,mock 返回成功) - assert resp.status_code != 403 - - @pytest.mark.asyncio - async def test_ticket_create_without_permission( - self, client, db, developer_user, group_with_permissions - ): - """没有 ticket:create 权限 → 创建工单 403""" - group, instance = group_with_permissions - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, group_id=group.id - ) - ) - await db.commit() - token = create_access_token( - data={"sub": str(developer_user.id), "role": developer_user.role} - ) - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "测试工单标题", - "db_type": "mysql", - "instance_id": instance.id, - "target_database": "test", - "sql_content": "ALTER TABLE t ADD COLUMN c INT", - "reason": "测试创建工单权限", - }, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 403 - assert "ticket:create" in resp.json()["detail"] - - @pytest.mark.asyncio - async def test_ticket_create_with_permission( - self, client, db, developer_user, group_no_permissions - ): - """组没配置 permissions → 创建工单不被 403(向后兼容)""" - group, instance = group_no_permissions - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, group_id=group.id - ) - ) - await db.commit() - token = create_access_token( - data={"sub": str(developer_user.id), "role": developer_user.role} - ) - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "测试工单标题", - "db_type": "mysql", - "instance_id": instance.id, - "target_database": "test", - "sql_content": "ALTER TABLE t ADD COLUMN c INT", - "reason": "测试创建工单权限", - }, - headers={"Authorization": f"Bearer {token}"}, - ) - # 不应是 403(可能 400 因为数据校验,但权限通过) - assert resp.status_code != 403 - - @pytest.mark.asyncio - async def test_query_denied_for_group_without_select( - self, client, db, developer_user - ): - """组 permissions 只有 ticket:create → query 被拒绝""" - group = InstanceGroup( - name="工单组", - db_type="mysql", - permissions=json.dumps(["ticket:create"]), - ) - db.add(group) - await db.flush() - - instance = Instance( - name="工单MySQL", db_type="mysql", host="10.0.0.1", port=3306, - username="root", password="pw", database="prod_db", - status="disconnected", group_id=group.id, - ) - db.add(instance) - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, group_id=group.id - ) - ) - await db.commit() - - token = create_access_token( - data={"sub": str(developer_user.id), "role": developer_user.role} - ) - resp = await client.post( - "/api/v1/query", - json={"instance_id": instance.id, "database": "test", "sql": "SELECT 1"}, - headers={"Authorization": f"Bearer {token}"}, - ) - assert resp.status_code == 403 - assert "query:select" in resp.json()["detail"] diff --git a/backend/tests/test_instances.py b/backend/tests/test_instances.py deleted file mode 100644 index 679cd28..0000000 --- a/backend/tests/test_instances.py +++ /dev/null @@ -1,192 +0,0 @@ -import sys -import types -from unittest.mock import patch, AsyncMock, MagicMock - -from httpx import AsyncClient - -from tests.conftest import auth_header - - -def _make_aiomysql_mock(): - aiomysql = MagicMock() - aiomysql.connect = AsyncMock() - aiomysql.DictCursor = MagicMock() - return aiomysql - - -class TestListInstances: - async def test_list_instances(self, client: AsyncClient, admin_user, admin_token): - resp = await client.get("/api/v1/instances", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert isinstance(resp.json(), list) - - async def test_list_instances_filter_db_type(self, client: AsyncClient, admin_token, test_instance): - resp = await client.get( - "/api/v1/instances?db_type=mysql", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert all(i["db_type"] == "mysql" for i in data) - - async def test_list_instances_filter_env(self, client: AsyncClient, admin_token, test_instance): - resp = await client.get( - "/api/v1/instances?env=development", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert all(i["env"] == "development" for i in data) - - async def test_list_instances_non_admin_no_group( - self, client: AsyncClient, developer_user, developer_token, test_instance - ): - """非 admin 且无权限组,只能看到公开实例(group_id=None)。""" - resp = await client.get( - "/api/v1/instances", - headers=auth_header(developer_token), - ) - assert resp.status_code == 200 - # test_instance has no group_id, so it should be visible - data = resp.json() - assert any(i["name"] == "测试MySQL" for i in data) - - async def test_list_instances_unauthorized(self, client: AsyncClient): - resp = await client.get("/api/v1/instances") - assert resp.status_code == 401 - - -class TestCreateInstance: - async def test_create_instance(self, client: AsyncClient, admin_token): - resp = await client.post("/api/v1/instances", headers=auth_header(admin_token), json={ - "name": "生产MySQL", - "db_type": "mysql", - "host": "10.0.0.1", - "port": 3306, - "username": "root", - "password": "secret", - "database": "prod", - "env": "production", - }) - assert resp.status_code == 201 - data = resp.json() - assert data["name"] == "生产MySQL" - assert data["db_type"] == "mysql" - - async def test_create_instance_non_admin(self, client: AsyncClient, developer_token): - resp = await client.post("/api/v1/instances", headers=auth_header(developer_token), json={ - "name": "非法创建", - "db_type": "mysql", - "host": "10.0.0.1", - "port": 3306, - "username": "root", - "password": "secret", - }) - assert resp.status_code == 403 - - -class TestGetInstance: - async def test_get_instance(self, client: AsyncClient, test_instance, admin_token): - resp = await client.get( - f"/api/v1/instances/{test_instance.id}", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "测试MySQL" - - async def test_get_nonexistent_instance(self, client: AsyncClient, admin_token): - resp = await client.get("/api/v1/instances/9999", headers=auth_header(admin_token)) - assert resp.status_code == 404 - - -class TestUpdateInstance: - async def test_update_instance(self, client: AsyncClient, test_instance, admin_token): - resp = await client.put( - f"/api/v1/instances/{test_instance.id}", - headers=auth_header(admin_token), - json={"name": "改名MySQL"}, - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "改名MySQL" - - async def test_update_instance_not_found(self, client: AsyncClient, admin_token): - resp = await client.put( - "/api/v1/instances/9999", - headers=auth_header(admin_token), - json={"name": "不存在"}, - ) - assert resp.status_code == 404 - - async def test_update_instance_forbidden_developer( - self, client: AsyncClient, test_instance, developer_token - ): - resp = await client.put( - f"/api/v1/instances/{test_instance.id}", - headers=auth_header(developer_token), - json={"name": "非法修改"}, - ) - assert resp.status_code == 403 - - async def test_update_instance_dba_allowed( - self, client: AsyncClient, test_instance, dba_token - ): - resp = await client.put( - f"/api/v1/instances/{test_instance.id}", - headers=auth_header(dba_token), - json={"description": "DBA修改的描述"}, - ) - assert resp.status_code == 200 - - -class TestDeleteInstance: - async def test_delete_instance(self, client: AsyncClient, test_instance, admin_token): - resp = await client.delete( - f"/api/v1/instances/{test_instance.id}", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - async def test_delete_instance_not_found(self, client: AsyncClient, admin_token): - resp = await client.delete( - "/api/v1/instances/9999", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - async def test_delete_instance_forbidden_developer( - self, client: AsyncClient, test_instance, developer_token - ): - resp = await client.delete( - f"/api/v1/instances/{test_instance.id}", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -class TestConnectionTest: - async def test_connection_test(self, client: AsyncClient, test_instance, admin_token): - from contextlib import asynccontextmanager - - mock_cursor = AsyncMock() - mock_cursor.execute = AsyncMock() - mock_cursor.fetchone.return_value = ("8.0.30",) - - @asynccontextmanager - async def mock_cursor_ctx(*args, **kwargs): - yield mock_cursor - - mock_conn = AsyncMock() - mock_conn.cursor = mock_cursor_ctx - mock_conn.close = MagicMock() - - mock_aiomysql = _make_aiomysql_mock() - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.post( - f"/api/v1/instances/{test_instance.id}/test", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is True diff --git a/backend/tests/test_ldap_config.py b/backend/tests/test_ldap_config.py deleted file mode 100644 index 888d6ed..0000000 --- a/backend/tests/test_ldap_config.py +++ /dev/null @@ -1,363 +0,0 @@ -"""LDAP 配置管理端点测试 — 覆盖 GET/PUT /api/v1/ldap-config 和 POST /test""" -import pytest -from httpx import AsyncClient - -from tests.conftest import auth_header - -# ── 合法的 LDAP 配置载荷 ────────────────────────────────────────── -VALID_LDAP_PAYLOAD = { - "ldap_host": "ldap.example.com", - "ldap_port": 389, - "use_ldaps": False, - "admin_dn": "cn=admin,dc=example,dc=com", - "admin_password": "admin_password_123", - "search_filter": "(uid=%s)", - "base_dn": "ou=users,dc=example,dc=com", - "user_mapping": {"username": "uid", "display_name": "cn", "email": "mail"}, -} - - -# ──────────────────────────────────────────────────────────────────── -# 1. 权限校验:非 admin 角色应被拒绝 (403) -# ──────────────────────────────────────────────────────────────────── - -@pytest.mark.asyncio -async def test_get_ldap_config_developer_forbidden( - client: AsyncClient, developer_token: str -): - """developer 角色访问 GET /ldap-config 应返回 403""" - resp = await client.get( - "/api/v1/ldap-config", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_put_ldap_config_developer_forbidden( - client: AsyncClient, developer_token: str -): - """developer 角色访问 PUT /ldap-config 应返回 403""" - resp = await client.put( - "/api/v1/ldap-config", - json=VALID_LDAP_PAYLOAD, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_test_ldap_connection_developer_forbidden( - client: AsyncClient, developer_token: str -): - """developer 角色访问 POST /ldap-config/test 应返回 403""" - resp = await client.post( - "/api/v1/ldap-config/test", - json={"test_user": "zhangsan", "test_password": "pass"}, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_ldap_endpoints_require_authentication(client: AsyncClient): - """未携带 Token 访问任何 LDAP 端点应返回 401""" - resp_get = await client.get("/api/v1/ldap-config") - assert resp_get.status_code in (401, 403) - - resp_put = await client.put("/api/v1/ldap-config", json=VALID_LDAP_PAYLOAD) - assert resp_put.status_code in (401, 403) - - resp_test = await client.post( - "/api/v1/ldap-config/test", - json={"test_user": "u", "test_password": "p"}, - ) - assert resp_test.status_code in (401, 403) - - -# ──────────────────────────────────────────────────────────────────── -# 2. PUT 创建配置(首次 upsert) -# ──────────────────────────────────────────────────────────────────── - -@pytest.mark.asyncio -async def test_create_ldap_config_success(client: AsyncClient, admin_token: str): - """admin 首次 PUT 创建 LDAP 配置,返回 200 且字段正确""" - resp = await client.put( - "/api/v1/ldap-config", - json=VALID_LDAP_PAYLOAD, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - - # 基本字段断言 - assert data["ldap_host"] == "ldap.example.com" - assert data["ldap_port"] == 389 - assert data["use_ldaps"] is False - assert data["admin_dn"] == "cn=admin,dc=example,dc=com" - assert data["search_filter"] == "(uid=%s)" - assert data["base_dn"] == "ou=users,dc=example,dc=com" - - # user_mapping 应被解析为 JSON dict - assert data["user_mapping"]["username"] == "uid" - assert data["user_mapping"]["display_name"] == "cn" - assert data["user_mapping"]["email"] == "mail" - - # 时间戳字段应存在 - assert data["created_at"] is not None - assert data["updated_at"] is not None - - -# ──────────────────────────────────────────────────────────────────── -# 3. 密码脱敏:返回值中密码应为 ****** -# ──────────────────────────────────────────────────────────────────── - -@pytest.mark.asyncio -async def test_ldap_config_password_masked(client: AsyncClient, admin_token: str): - """创建配置后返回的 admin_password 必须脱敏为 ******""" - resp = await client.put( - "/api/v1/ldap-config", - json={ - "ldap_host": "ldap.mask.com", - "ldap_port": 389, - "admin_dn": "cn=admin,dc=mask,dc=com", - "admin_password": "super_secret_密码", - "base_dn": "dc=mask,dc=com", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["admin_password"] == "******" - - -# ──────────────────────────────────────────────────────────────────── -# 4. GET 获取配置(已存在 / 不存在) -# ──────────────────────────────────────────────────────────────────── - -@pytest.mark.asyncio -async def test_get_ldap_config_after_create(client: AsyncClient, admin_token: str): - """先创建,再 GET 应返回相同配置""" - await client.put( - "/api/v1/ldap-config", - json=VALID_LDAP_PAYLOAD, - headers=auth_header(admin_token), - ) - resp = await client.get( - "/api/v1/ldap-config", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["ldap_host"] == "ldap.example.com" - - -@pytest.mark.asyncio -async def test_get_ldap_config_not_found(client: AsyncClient, admin_token: str): - """未创建配置时 GET 应返回 404""" - resp = await client.get( - "/api/v1/ldap-config", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - -# ──────────────────────────────────────────────────────────────────── -# 5. PUT 更新配置(第二次 upsert) -# ──────────────────────────────────────────────────────────────────── - -@pytest.mark.asyncio -async def test_update_existing_ldap_config(client: AsyncClient, admin_token: str): - """对已有配置再次 PUT 应覆盖更新""" - # 第一次创建 - await client.put( - "/api/v1/ldap-config", - json={ - "ldap_host": "ldap.old.com", - "ldap_port": 389, - "admin_dn": "cn=admin,dc=old,dc=com", - "admin_password": "oldpass", - "base_dn": "dc=old,dc=com", - }, - headers=auth_header(admin_token), - ) - - # 第二次更新 - resp = await client.put( - "/api/v1/ldap-config", - json={ - "ldap_host": "ldap.new.com", - "ldap_port": 636, - "use_ldaps": True, - "admin_dn": "cn=admin,dc=new,dc=com", - "admin_password": "newpass", - "search_filter": "(sAMAccountName=%s)", - "base_dn": "dc=new,dc=com", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["ldap_host"] == "ldap.new.com" - assert data["ldap_port"] == 636 - assert data["use_ldaps"] is True - assert data["search_filter"] == "(sAMAccountName=%s)" - - -# ──────────────────────────────────────────────────────────────────── -# 6. 输入校验:非法字段值应返回 422 -# ──────────────────────────────────────────────────────────────────── - -@pytest.mark.asyncio -async def test_create_ldap_config_missing_required_fields( - client: AsyncClient, admin_token: str -): - """缺少必填字段(ldap_host)应返回 422""" - resp = await client.put( - "/api/v1/ldap-config", - json={ - "ldap_port": 389, - "admin_dn": "cn=admin,dc=example,dc=com", - "admin_password": "pass", - "base_dn": "dc=example,dc=com", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 422 - - -@pytest.mark.asyncio -async def test_create_ldap_config_port_out_of_range( - client: AsyncClient, admin_token: str -): - """端口号超出范围 (>65535) 应返回 422""" - payload = {**VALID_LDAP_PAYLOAD, "ldap_port": 99999} - resp = await client.put( - "/api/v1/ldap-config", - json=payload, - headers=auth_header(admin_token), - ) - assert resp.status_code == 422 - - -@pytest.mark.asyncio -async def test_create_ldap_config_empty_host(client: AsyncClient, admin_token: str): - """ldap_host 为空字符串应返回 422""" - payload = {**VALID_LDAP_PAYLOAD, "ldap_host": ""} - resp = await client.put( - "/api/v1/ldap-config", - json=payload, - headers=auth_header(admin_token), - ) - assert resp.status_code == 422 - - -@pytest.mark.asyncio -async def test_create_ldap_config_empty_password(client: AsyncClient, admin_token: str): - """admin_password 为空字符串应返回 422""" - payload = {**VALID_LDAP_PAYLOAD, "admin_password": ""} - resp = await client.put( - "/api/v1/ldap-config", - json=payload, - headers=auth_header(admin_token), - ) - assert resp.status_code == 422 - - -# ──────────────────────────────────────────────────────────────────── -# 7. POST /test — 测试 LDAP 连接 -# ──────────────────────────────────────────────────────────────────── - -@pytest.mark.asyncio -async def test_ldap_connection_real_failure(client: AsyncClient, admin_token: str): - """配置存在时 POST /test 应尝试真实连接,测试环境无 LDAP 服务器则返回 success=False""" - await client.put( - "/api/v1/ldap-config", - json=VALID_LDAP_PAYLOAD, - headers=auth_header(admin_token), - ) - - resp = await client.post( - "/api/v1/ldap-config/test", - json={"test_user": "zhangsan", "test_password": "zhang123"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - # 测试环境无真实 LDAP 服务器,连接应失败 - assert data["success"] is False - # 消息中应包含连接错误信息 - assert any(kw in data["message"] for kw in ["error", "fail", "未安装", "连接失败", "LDAP"]) - - -@pytest.mark.asyncio -async def test_ldap_connection_test_without_config( - client: AsyncClient, admin_token: str -): - """未创建配置时 POST /test 应返回 404""" - resp = await client.post( - "/api/v1/ldap-config/test", - json={"test_user": "user1", "test_password": "pass1"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - -@pytest.mark.asyncio -async def test_ldap_connection_test_missing_test_user( - client: AsyncClient, admin_token: str -): - """POST /test 缺少 test_user 应返回 422""" - await client.put( - "/api/v1/ldap-config", - json=VALID_LDAP_PAYLOAD, - headers=auth_header(admin_token), - ) - - resp = await client.post( - "/api/v1/ldap-config/test", - json={"test_password": "pass"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 422 - - -# ──────────────────────────────────────────────────────────────────── -# 8. 可选字段与默认值 -# ──────────────────────────────────────────────────────────────────── - -@pytest.mark.asyncio -async def test_create_ldap_config_with_defaults(client: AsyncClient, admin_token: str): - """仅传必填字段,可选字段应有合理默认值""" - resp = await client.put( - "/api/v1/ldap-config", - json={ - "ldap_host": "ldap.defaults.com", - "admin_dn": "cn=admin,dc=defaults,dc=com", - "admin_password": "password123", - "base_dn": "dc=defaults,dc=com", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - # ldap_port 默认 389 - assert data["ldap_port"] == 389 - # use_ldaps 默认 False - assert data["use_ldaps"] is False - # search_filter 未传,应为 None - assert data["search_filter"] is None - # user_mapping 未传,应为 None - assert data["user_mapping"] is None - - -@pytest.mark.asyncio -async def test_ldap_config_use_ldaps_flag(client: AsyncClient, admin_token: str): - """创建配置时 use_ldaps=True 应正确保存""" - payload = {**VALID_LDAP_PAYLOAD, "use_ldaps": True, "ldap_port": 636} - resp = await client.put( - "/api/v1/ldap-config", - json=payload, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["use_ldaps"] is True - assert resp.json()["ldap_port"] == 636 diff --git a/backend/tests/test_masking_rule.py b/backend/tests/test_masking_rule.py deleted file mode 100644 index 736232a..0000000 --- a/backend/tests/test_masking_rule.py +++ /dev/null @@ -1,681 +0,0 @@ -"""Tests for Masking Rule CRUD API — data masking rule management.""" -from typing import Optional - -import pytest - -from tests.conftest import auth_header - -# ---------- helpers ---------- - -BASE = "/api/v1/instances" -RULES_PREFIX = "/api/v1/instances" # list/create use /{id}/masking-rules - -SAMPLE_RULE = { - "database": "test_db", - "table_name": "users", - "column_name": "phone", - "mask_type": "partial", - "mask_config": {"show_first": 3, "show_last": 2}, - "is_enabled": True, -} - - -async def _create_rule(client, instance_id: int, token: str, overrides: Optional[dict] = None): - """Helper: create a single masking rule and return the response.""" - payload = {**SAMPLE_RULE} - if overrides: - payload.update(overrides) - return await client.post( - f"{BASE}/{instance_id}/masking-rules", - json=payload, - headers=auth_header(token), - ) - - -# ===================================================================== -# TestListMaskingRules -# ===================================================================== - -class TestListMaskingRules: - """GET /instances/{instance_id}/masking-rules""" - - @pytest.mark.asyncio - async def test_list_empty(self, client, admin_token, test_instance): - resp = await client.get( - f"{BASE}/{test_instance.id}/masking-rules", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 0 - assert data["items"] == [] - - @pytest.mark.asyncio - async def test_list_returns_created_rules(self, client, admin_token, test_instance): - await _create_rule(client, test_instance.id, admin_token) - resp = await client.get( - f"{BASE}/{test_instance.id}/masking-rules", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 1 - assert len(data["items"]) == 1 - assert data["items"][0]["column_name"] == "phone" - - @pytest.mark.asyncio - async def test_list_filter_by_database(self, client, admin_token, test_instance): - await _create_rule(client, test_instance.id, admin_token) - resp = await client.get( - f"{BASE}/{test_instance.id}/masking-rules", - params={"database": "test_db"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["total"] == 1 - - # Wrong database → empty - resp2 = await client.get( - f"{BASE}/{test_instance.id}/masking-rules", - params={"database": "other_db"}, - headers=auth_header(admin_token), - ) - assert resp2.json()["total"] == 0 - - @pytest.mark.asyncio - async def test_list_filter_by_table_name(self, client, admin_token, test_instance): - await _create_rule(client, test_instance.id, admin_token) - resp = await client.get( - f"{BASE}/{test_instance.id}/masking-rules", - params={"table_name": "users"}, - headers=auth_header(admin_token), - ) - assert resp.json()["total"] == 1 - - @pytest.mark.asyncio - async def test_list_filter_by_keyword(self, client, admin_token, test_instance): - await _create_rule(client, test_instance.id, admin_token) - resp = await client.get( - f"{BASE}/{test_instance.id}/masking-rules", - params={"keyword": "phone"}, - headers=auth_header(admin_token), - ) - assert resp.json()["total"] == 1 - - # Non-matching keyword - resp2 = await client.get( - f"{BASE}/{test_instance.id}/masking-rules", - params={"keyword": "email"}, - headers=auth_header(admin_token), - ) - assert resp2.json()["total"] == 0 - - @pytest.mark.asyncio - async def test_list_pagination(self, client, admin_token, test_instance): - # Create 3 rules - for col in ["phone", "email", "id_card"]: - await _create_rule( - client, test_instance.id, admin_token, - {"column_name": col}, - ) - # Page 1, size 2 - resp = await client.get( - f"{BASE}/{test_instance.id}/masking-rules", - params={"page": 1, "page_size": 2}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 3 - assert len(data["items"]) == 2 - - # Page 2, size 2 - resp2 = await client.get( - f"{BASE}/{test_instance.id}/masking-rules", - params={"page": 2, "page_size": 2}, - headers=auth_header(admin_token), - ) - assert resp2.json()["total"] == 3 - assert len(resp2.json()["items"]) == 1 - - @pytest.mark.asyncio - async def test_list_any_authenticated_role(self, client, developer_token, test_instance): - """All authenticated users should be able to list rules.""" - resp = await client.get( - f"{BASE}/{test_instance.id}/masking-rules", - headers=auth_header(developer_token), - ) - assert resp.status_code == 200 - - @pytest.mark.asyncio - async def test_list_nonexistent_instance(self, client, admin_token): - resp = await client.get( - f"{BASE}/99999/masking-rules", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_list_unauthenticated(self, client, test_instance): - resp = await client.get(f"{BASE}/{test_instance.id}/masking-rules") - assert resp.status_code == 401 - - -# ===================================================================== -# TestCreateMaskingRule -# ===================================================================== - -class TestCreateMaskingRule: - """POST /instances/{instance_id}/masking-rules""" - - @pytest.mark.asyncio - async def test_create_success_admin(self, client, admin_token, test_instance): - resp = await _create_rule(client, test_instance.id, admin_token) - assert resp.status_code == 201 - data = resp.json() - assert data["database"] == "test_db" - assert data["table_name"] == "users" - assert data["column_name"] == "phone" - assert data["mask_type"] == "partial" - assert data["is_enabled"] is True - assert data["instance_id"] == test_instance.id - assert "id" in data - - @pytest.mark.asyncio - async def test_create_success_dba(self, client, dba_token, test_instance): - resp = await _create_rule(client, test_instance.id, dba_token) - assert resp.status_code == 201 - - @pytest.mark.asyncio - async def test_create_developer_forbidden(self, client, developer_token, test_instance): - resp = await _create_rule(client, test_instance.id, developer_token) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_create_guest_forbidden(self, client, guest_token, test_instance): - resp = await _create_rule(client, test_instance.id, guest_token) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_create_duplicate_returns_409(self, client, admin_token, test_instance): - await _create_rule(client, test_instance.id, admin_token) - resp = await _create_rule(client, test_instance.id, admin_token) - assert resp.status_code == 409 - - @pytest.mark.asyncio - async def test_create_nonexistent_instance(self, client, admin_token): - resp = await _create_rule(client, 99999, admin_token) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_create_with_mask_config(self, client, admin_token, test_instance): - resp = await _create_rule( - client, test_instance.id, admin_token, - {"mask_type": "regex", "mask_config": {"pattern": r"\d{4}", "replace": "****"}}, - ) - assert resp.status_code == 201 - assert resp.json()["mask_config"] is not None - - @pytest.mark.asyncio - async def test_create_without_mask_config(self, client, admin_token, test_instance): - payload = { - "database": "mydb", - "table_name": "t", - "column_name": "col1", - "mask_type": "hash", - } - resp = await client.post( - f"{BASE}/{test_instance.id}/masking-rules", - json=payload, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - assert resp.json()["mask_config"] is None - - @pytest.mark.asyncio - async def test_create_invalid_mask_type(self, client, admin_token, test_instance): - resp = await _create_rule( - client, test_instance.id, admin_token, - {"mask_type": "invalid_type"}, - ) - assert resp.status_code == 422 - - @pytest.mark.asyncio - async def test_create_empty_database(self, client, admin_token, test_instance): - resp = await _create_rule( - client, test_instance.id, admin_token, - {"database": ""}, - ) - assert resp.status_code == 422 - - @pytest.mark.asyncio - async def test_create_default_is_enabled_true(self, client, admin_token, test_instance): - payload = { - "database": "db1", - "table_name": "tbl1", - "column_name": "col1", - "mask_type": "full", - } - resp = await client.post( - f"{BASE}/{test_instance.id}/masking-rules", - json=payload, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - assert resp.json()["is_enabled"] is True - - @pytest.mark.asyncio - async def test_create_is_enabled_false(self, client, admin_token, test_instance): - resp = await _create_rule( - client, test_instance.id, admin_token, - {"is_enabled": False, "column_name": "disabled_col"}, - ) - assert resp.status_code == 201 - assert resp.json()["is_enabled"] is False - - @pytest.mark.asyncio - async def test_create_same_column_different_instance(self, client, admin_token, db, test_instance): - """Same (db, table, column) on different instance should not conflict.""" - from app.models.instance import Instance - instance2 = Instance( - name="Instance2", db_type="mysql", host="10.0.0.1", - port=3306, username="root", password="pw", - database="test_db", env="development", status="disconnected", - ) - db.add(instance2) - await db.commit() - await db.refresh(instance2) - - r1 = await _create_rule(client, test_instance.id, admin_token) - assert r1.status_code == 201 - - r2 = await _create_rule(client, instance2.id, admin_token) - assert r2.status_code == 201 - - -# ===================================================================== -# TestUpdateMaskingRule -# ===================================================================== - -class TestUpdateMaskingRule: - """PUT /instances/masking-rules/{rule_id}""" - - @pytest.mark.asyncio - async def test_update_success(self, client, admin_token, test_instance): - create = await _create_rule(client, test_instance.id, admin_token) - rule_id = create.json()["id"] - - resp = await client.put( - f"{BASE}/masking-rules/{rule_id}", - json={"mask_type": "hash"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["mask_type"] == "hash" - - @pytest.mark.asyncio - async def test_update_is_enabled(self, client, admin_token, test_instance): - create = await _create_rule(client, test_instance.id, admin_token) - rule_id = create.json()["id"] - assert create.json()["is_enabled"] is True - - resp = await client.put( - f"{BASE}/masking-rules/{rule_id}", - json={"is_enabled": False}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["is_enabled"] is False - - @pytest.mark.asyncio - async def test_update_dba_allowed(self, client, dba_token, test_instance): - create = await _create_rule(client, test_instance.id, dba_token) - rule_id = create.json()["id"] - - resp = await client.put( - f"{BASE}/masking-rules/{rule_id}", - json={"mask_type": "full"}, - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - @pytest.mark.asyncio - async def test_update_developer_forbidden(self, client, admin_token, developer_token, test_instance): - create = await _create_rule(client, test_instance.id, admin_token) - rule_id = create.json()["id"] - - resp = await client.put( - f"{BASE}/masking-rules/{rule_id}", - json={"mask_type": "full"}, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_update_guest_forbidden(self, client, admin_token, guest_token, test_instance): - create = await _create_rule(client, test_instance.id, admin_token) - rule_id = create.json()["id"] - - resp = await client.put( - f"{BASE}/masking-rules/{rule_id}", - json={"mask_type": "full"}, - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_update_nonexistent_rule(self, client, admin_token): - resp = await client.put( - f"{BASE}/masking-rules/99999", - json={"mask_type": "hash"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_update_duplicate_conflict_409(self, client, admin_token, test_instance): - """Update a rule so it conflicts with another existing rule → 409.""" - r1 = await _create_rule(client, test_instance.id, admin_token) - r2 = await _create_rule( - client, test_instance.id, admin_token, - {"column_name": "email"}, - ) - rule2_id = r2.json()["id"] - - # Try to update r2's column_name to "phone" (same as r1) → conflict - resp = await client.put( - f"{BASE}/masking-rules/{rule2_id}", - json={"column_name": "phone"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 409 - - @pytest.mark.asyncio - async def test_update_partial_fields(self, client, admin_token, test_instance): - create = await _create_rule(client, test_instance.id, admin_token) - rule_id = create.json()["id"] - - resp = await client.put( - f"{BASE}/masking-rules/{rule_id}", - json={"database": "new_db"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["database"] == "new_db" - # Other fields unchanged - assert resp.json()["table_name"] == "users" - assert resp.json()["mask_type"] == "partial" - - -# ===================================================================== -# TestDeleteMaskingRule -# ===================================================================== - -class TestDeleteMaskingRule: - """DELETE /instances/masking-rules/{rule_id}""" - - @pytest.mark.asyncio - async def test_delete_success(self, client, admin_token, test_instance): - create = await _create_rule(client, test_instance.id, admin_token) - rule_id = create.json()["id"] - - resp = await client.delete( - f"{BASE}/masking-rules/{rule_id}", - headers=auth_header(admin_token), - ) - assert resp.status_code == 204 - - # Verify deleted — list should be empty - list_resp = await client.get( - f"{BASE}/{test_instance.id}/masking-rules", - headers=auth_header(admin_token), - ) - assert list_resp.json()["total"] == 0 - - @pytest.mark.asyncio - async def test_delete_dba_allowed(self, client, dba_token, test_instance): - create = await _create_rule(client, test_instance.id, dba_token) - rule_id = create.json()["id"] - - resp = await client.delete( - f"{BASE}/masking-rules/{rule_id}", - headers=auth_header(dba_token), - ) - assert resp.status_code == 204 - - @pytest.mark.asyncio - async def test_delete_developer_forbidden(self, client, admin_token, developer_token, test_instance): - create = await _create_rule(client, test_instance.id, admin_token) - rule_id = create.json()["id"] - - resp = await client.delete( - f"{BASE}/masking-rules/{rule_id}", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_delete_guest_forbidden(self, client, admin_token, guest_token, test_instance): - create = await _create_rule(client, test_instance.id, admin_token) - rule_id = create.json()["id"] - - resp = await client.delete( - f"{BASE}/masking-rules/{rule_id}", - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_delete_nonexistent_rule(self, client, admin_token): - resp = await client.delete( - f"{BASE}/masking-rules/99999", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_delete_twice(self, client, admin_token, test_instance): - create = await _create_rule(client, test_instance.id, admin_token) - rule_id = create.json()["id"] - - resp1 = await client.delete( - f"{BASE}/masking-rules/{rule_id}", - headers=auth_header(admin_token), - ) - assert resp1.status_code == 204 - - resp2 = await client.delete( - f"{BASE}/masking-rules/{rule_id}", - headers=auth_header(admin_token), - ) - assert resp2.status_code == 404 - - -# ===================================================================== -# TestBatchCreateMaskingRules -# ===================================================================== - -class TestBatchCreateMaskingRules: - """POST /instances/masking-rules/batch?instance_id=X""" - - @pytest.mark.asyncio - async def test_batch_create_success(self, client, admin_token, test_instance): - payload = { - "rules": [ - { - "database": "db1", "table_name": "users", - "column_name": "phone", "mask_type": "partial", - }, - { - "database": "db1", "table_name": "users", - "column_name": "email", "mask_type": "full", - }, - ] - } - resp = await client.post( - f"{BASE}/masking-rules/batch", - json=payload, - params={"instance_id": test_instance.id}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - assert data["total"] == 2 - assert len(data["items"]) == 2 - - @pytest.mark.asyncio - async def test_batch_create_skips_duplicates(self, client, admin_token, test_instance): - # Pre-create one rule - await _create_rule(client, test_instance.id, admin_token) - - payload = { - "rules": [ - { - "database": "test_db", "table_name": "users", - "column_name": "phone", "mask_type": "partial", - }, - { - "database": "db1", "table_name": "t1", - "column_name": "email", "mask_type": "full", - }, - ] - } - resp = await client.post( - f"{BASE}/masking-rules/batch", - json=payload, - params={"instance_id": test_instance.id}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - # Should have skipped the duplicate, only 1 created - assert data["total"] == 1 - assert data["items"][0]["column_name"] == "email" - - @pytest.mark.asyncio - async def test_batch_create_all_duplicates_returns_409(self, client, admin_token, test_instance): - await _create_rule(client, test_instance.id, admin_token) - - payload = { - "rules": [ - { - "database": "test_db", "table_name": "users", - "column_name": "phone", "mask_type": "partial", - }, - ] - } - resp = await client.post( - f"{BASE}/masking-rules/batch", - json=payload, - params={"instance_id": test_instance.id}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 409 - - @pytest.mark.asyncio - async def test_batch_create_developer_forbidden(self, client, developer_token, test_instance): - payload = { - "rules": [ - { - "database": "db1", "table_name": "t1", - "column_name": "col1", "mask_type": "hash", - }, - ] - } - resp = await client.post( - f"{BASE}/masking-rules/batch", - json=payload, - params={"instance_id": test_instance.id}, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_batch_create_guest_forbidden(self, client, guest_token, test_instance): - payload = { - "rules": [ - { - "database": "db1", "table_name": "t1", - "column_name": "col1", "mask_type": "hash", - }, - ] - } - resp = await client.post( - f"{BASE}/masking-rules/batch", - json=payload, - params={"instance_id": test_instance.id}, - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_batch_create_nonexistent_instance(self, client, admin_token): - payload = { - "rules": [ - { - "database": "db1", "table_name": "t1", - "column_name": "col1", "mask_type": "hash", - }, - ] - } - resp = await client.post( - f"{BASE}/masking-rules/batch", - json=payload, - params={"instance_id": 99999}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_batch_create_dba_allowed(self, client, dba_token, test_instance): - payload = { - "rules": [ - { - "database": "db1", "table_name": "t1", - "column_name": "col1", "mask_type": "truncate", - }, - ] - } - resp = await client.post( - f"{BASE}/masking-rules/batch", - json=payload, - params={"instance_id": test_instance.id}, - headers=auth_header(dba_token), - ) - assert resp.status_code == 201 - - @pytest.mark.asyncio - async def test_batch_create_empty_rules_rejected(self, client, admin_token, test_instance): - """Empty rules list should fail validation (min_length=1).""" - resp = await client.post( - f"{BASE}/masking-rules/batch", - json={"rules": []}, - params={"instance_id": test_instance.id}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 422 - - @pytest.mark.asyncio - async def test_batch_create_with_internal_duplicates(self, client, admin_token, test_instance): - """Two identical rules in the same batch — first succeeds, second skipped.""" - payload = { - "rules": [ - { - "database": "db2", "table_name": "t2", - "column_name": "secret", "mask_type": "full", - }, - { - "database": "db2", "table_name": "t2", - "column_name": "secret", "mask_type": "hash", - }, - ] - } - resp = await client.post( - f"{BASE}/masking-rules/batch", - json=payload, - params={"instance_id": test_instance.id}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - assert data["total"] == 1 diff --git a/backend/tests/test_mongo.py b/backend/tests/test_mongo.py deleted file mode 100644 index 9a838e9..0000000 --- a/backend/tests/test_mongo.py +++ /dev/null @@ -1,711 +0,0 @@ -"""MongoDB 管理 API 端点测试 — 所有 MongoConnector 方法均 mock。""" -import pytest -from unittest.mock import AsyncMock, patch - -from tests.conftest import auth_header -from app.models.instance import Instance - - -# ── 辅助工具 ────────────────────────────────────────────────────── - -MONGO_INSTANCE_DATA = { - "name": "test-mongo", - "db_type": "mongo", - "host": "127.0.0.1", - "port": 27017, - "username": "root", - "password": "test", - "database": "test_mongo", - "env": "development", - "status": "disconnected", -} - -MYSQL_INSTANCE_DATA = { - "name": "Test MySQL", - "db_type": "mysql", - "host": "127.0.0.1", - "port": 3306, - "username": "root", - "password": "test123", - "database": "test_db", - "env": "development", -} - - -async def _create_mongo_instance(client, admin_token): - """创建一个 mongo 类型的实例并返回其 ID。""" - resp = await client.post( - "/api/v1/instances", - json=MONGO_INSTANCE_DATA, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - return resp.json()["id"] - - -async def _create_mysql_instance(client, admin_token): - """创建一个 mysql 类型的实例并返回其 ID。""" - resp = await client.post( - "/api/v1/instances", - json=MYSQL_INSTANCE_DATA, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - return resp.json()["id"] - - -# ==================================================================== -# 1. 数据库列表端点 GET /mongo/{id}/databases -# ==================================================================== - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_list_databases_success(mock_cls, client, admin_token): - """管理员列出 mongo 数据库 — 正常返回。""" - mock_cls.list_databases = AsyncMock( - return_value=["test_mongo", "myapp", "analytics"] - ) - iid = await _create_mongo_instance(client, admin_token) - - resp = await client.get( - f"/api/v1/mongo/{iid}/databases", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert "databases" in data - assert data["databases"] == ["test_mongo", "myapp", "analytics"] - mock_cls.list_databases.assert_awaited_once() - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_list_databases_empty(mock_cls, client, admin_token): - """数据库列表为空时返回空列表。""" - mock_cls.list_databases = AsyncMock(return_value=[]) - iid = await _create_mongo_instance(client, admin_token) - - resp = await client.get( - f"/api/v1/mongo/{iid}/databases", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["databases"] == [] - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_list_databases_connector_error(mock_cls, client, admin_token): - """MongoConnector 抛出异常时返回 500。""" - mock_cls.list_databases = AsyncMock(side_effect=Exception("连接超时")) - iid = await _create_mongo_instance(client, admin_token) - - resp = await client.get( - f"/api/v1/mongo/{iid}/databases", - headers=auth_header(admin_token), - ) - assert resp.status_code == 500 - assert "获取数据库列表失败" in resp.json()["detail"] - - -# ==================================================================== -# 2. 集合列表端点 GET /mongo/{id}/databases/{db}/collections -# ==================================================================== - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_list_collections_success(mock_cls, client, admin_token): - """列出指定数据库下的集合 — 正常返回。""" - mock_cls.list_collections = AsyncMock( - return_value=["users", "orders", "products"] - ) - iid = await _create_mongo_instance(client, admin_token) - - resp = await client.get( - f"/api/v1/mongo/{iid}/databases/myapp/collections", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["database"] == "myapp" - assert data["collections"] == ["users", "orders", "products"] - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_list_collections_error(mock_cls, client, admin_token): - """获取集合列表失败时返回 500。""" - mock_cls.list_collections = AsyncMock(side_effect=Exception("权限不足")) - iid = await _create_mongo_instance(client, admin_token) - - resp = await client.get( - f"/api/v1/mongo/{iid}/databases/myapp/collections", - headers=auth_header(admin_token), - ) - assert resp.status_code == 500 - assert "获取集合列表失败" in resp.json()["detail"] - - -# ==================================================================== -# 3. 集合统计 GET /mongo/{id}/collections/{coll}/stats?database=xxx -# ==================================================================== - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_collection_stats_success(mock_cls, client, admin_token): - """获取集合统计信息 — 正常返回。""" - mock_stats = { - "namespace": "myapp.users", - "document_count": 5000, - "size_bytes": 1048576, - "avg_document_size_bytes": 209, - "storage_size_bytes": 524288, - "index_count": 3, - "index_size_bytes": 131072, - "capped": False, - } - mock_cls.get_collection_stats = AsyncMock(return_value=mock_stats) - iid = await _create_mongo_instance(client, admin_token) - - resp = await client.get( - f"/api/v1/mongo/{iid}/collections/users/stats?database=myapp", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["document_count"] == 5000 - assert data["index_count"] == 3 - mock_cls.get_collection_stats.assert_awaited_once() - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_collection_stats_uses_instance_default_db(mock_cls, client, admin_token): - """未传 database 参数时使用实例默认数据库。""" - mock_stats = {"namespace": "test_mongo.users", "document_count": 100} - mock_cls.get_collection_stats = AsyncMock(return_value=mock_stats) - iid = await _create_mongo_instance(client, admin_token) - - # 实例默认 database = "test_mongo",不传 database 参数 - resp = await client.get( - f"/api/v1/mongo/{iid}/collections/users/stats", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["document_count"] == 100 - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_collection_stats_no_database(mock_cls, client, admin_token, db): - """实例无默认数据库且未传 database 参数 — 返回 400。""" - mock_cls.get_collection_stats = AsyncMock(return_value={}) - iid = await _create_mongo_instance(client, admin_token) - - # 手动清空实例的 database 字段 - from sqlalchemy import select - result = await db.execute( - select(Instance).where(Instance.id == iid) - ) - inst = result.scalar_one() - inst.database = None - await db.commit() - - resp = await client.get( - f"/api/v1/mongo/{iid}/collections/users/stats", - headers=auth_header(admin_token), - ) - assert resp.status_code == 400 - assert "未指定数据库名称" in resp.json()["detail"] - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_collection_stats_error(mock_cls, client, admin_token): - """获取集合统计异常时返回 500。""" - mock_cls.get_collection_stats = AsyncMock( - side_effect=Exception("集合不存在") - ) - iid = await _create_mongo_instance(client, admin_token) - - resp = await client.get( - f"/api/v1/mongo/{iid}/collections/users/stats?database=myapp", - headers=auth_header(admin_token), - ) - assert resp.status_code == 500 - assert "获取集合统计失败" in resp.json()["detail"] - - -# ==================================================================== -# 4. 只读查询 POST /mongo/{id}/query -# ==================================================================== - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_query_find_success(mock_cls, client, admin_token): - """执行 find 查询 — 正常返回。""" - mock_result = [{"_id": "1", "name": "Alice"}, {"_id": "2", "name": "Bob"}] - mock_cls.execute_query_json = AsyncMock(return_value=mock_result) - iid = await _create_mongo_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "users", - "operation": "find", - "filter": {"age": {"$gt": 18}}, - "limit": 100, - } - resp = await client.post( - f"/api/v1/mongo/{iid}/query", - json=body, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is True - assert data["operation"] == "find" - assert len(data["result"]) == 2 - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_query_aggregate_success(mock_cls, client, admin_token): - """执行 aggregate 聚合查询 — 正常返回。""" - mock_result = [{"_id": "groupA", "total": 42}] - mock_cls.execute_query_json = AsyncMock(return_value=mock_result) - iid = await _create_mongo_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "orders", - "operation": "aggregate", - "pipeline": [{"$group": {"_id": "$category", "total": {"$sum": 1}}}], - } - resp = await client.post( - f"/api/v1/mongo/{iid}/query", - json=body, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["operation"] == "aggregate" - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_query_write_operation_rejected(mock_cls, client, admin_token): - """查询端点拒绝写操作(如 insertOne)— 返回 403。""" - mock_cls.execute_query_json = AsyncMock(return_value={}) - iid = await _create_mongo_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "users", - "operation": "insertOne", - "document": {"name": "hack"}, - } - resp = await client.post( - f"/api/v1/mongo/{iid}/query", - json=body, - headers=auth_header(admin_token), - ) - assert resp.status_code == 403 - assert "写操作" in resp.json()["detail"] or "禁止" in resp.json()["detail"] - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_query_unknown_operation_rejected(mock_cls, client, admin_token): - """查询端点拒绝未知操作 — 返回 403。""" - mock_cls.execute_query_json = AsyncMock(return_value={}) - iid = await _create_mongo_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "users", - "operation": "dropCollection", - } - resp = await client.post( - f"/api/v1/mongo/{iid}/query", - json=body, - headers=auth_header(admin_token), - ) - assert resp.status_code == 403 - assert "未知" in resp.json()["detail"] - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_query_no_database(mock_cls, client, admin_token, db): - """查询时无数据库名称 — 返回 400。""" - mock_cls.execute_query_json = AsyncMock(return_value=[]) - iid = await _create_mongo_instance(client, admin_token) - - # 清空实例默认 database - from sqlalchemy import select - result = await db.execute( - select(Instance).where(Instance.id == iid) - ) - inst = result.scalar_one() - inst.database = None - await db.commit() - - body = { - "collection": "users", - "operation": "find", - } - resp = await client.post( - f"/api/v1/mongo/{iid}/query", - json=body, - headers=auth_header(admin_token), - ) - assert resp.status_code == 400 - assert "未指定数据库名称" in resp.json()["detail"] - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_query_connector_error(mock_cls, client, admin_token): - """查询端点 connector 异常 — 返回 500。""" - mock_cls.execute_query_json = AsyncMock( - side_effect=Exception("cursor timeout") - ) - iid = await _create_mongo_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "users", - "operation": "find", - } - resp = await client.post( - f"/api/v1/mongo/{iid}/query", - json=body, - headers=auth_header(admin_token), - ) - assert resp.status_code == 500 - assert "查询执行失败" in resp.json()["detail"] - - -# ==================================================================== -# 5. 变更操作 POST /mongo/{id}/execute (仅 admin/dba) -# ==================================================================== - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_execute_insert_one_admin(mock_cls, client, admin_token): - """admin 执行 insertOne 变更 — 正常返回。""" - mock_cls.execute_change_json = AsyncMock( - return_value="507f1f77bcf86cd799439011" - ) - iid = await _create_mongo_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "users", - "operation": "insertOne", - "document": {"name": "Alice", "age": 25}, - } - resp = await client.post( - f"/api/v1/mongo/{iid}/execute", - json=body, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is True - assert data["operation"] == "insertOne" - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_execute_delete_many_dba(mock_cls, client, dba_token, admin_token): - """dba 执行 deleteMany 变更 — 正常返回。""" - mock_cls.execute_change_json = AsyncMock(return_value="删除 3 条文档") - iid = await _create_mongo_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "logs", - "operation": "deleteMany", - "filter": {"level": "debug"}, - } - resp = await client.post( - f"/api/v1/mongo/{iid}/execute", - json=body, - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - assert resp.json()["success"] is True - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_execute_update_many_success(mock_cls, client, admin_token): - """admin 执行 updateMany 变更 — 正常返回。""" - mock_cls.execute_change_json = AsyncMock( - return_value="匹配 5 条,修改 5 条" - ) - iid = await _create_mongo_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "users", - "operation": "updateMany", - "filter": {"status": "inactive"}, - "update": {"$set": {"status": "archived"}}, - } - resp = await client.post( - f"/api/v1/mongo/{iid}/execute", - json=body, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert "匹配 5 条" in resp.json()["result"] - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_execute_no_database(mock_cls, client, admin_token, db): - """变更操作无数据库名称 — 返回 400。""" - mock_cls.execute_change_json = AsyncMock(return_value="") - iid = await _create_mongo_instance(client, admin_token) - - # 清空实例默认 database - from sqlalchemy import select - result = await db.execute( - select(Instance).where(Instance.id == iid) - ) - inst = result.scalar_one() - inst.database = None - await db.commit() - - body = { - "collection": "users", - "operation": "insertOne", - "document": {"name": "test"}, - } - resp = await client.post( - f"/api/v1/mongo/{iid}/execute", - json=body, - headers=auth_header(admin_token), - ) - assert resp.status_code == 400 - assert "未指定数据库名称" in resp.json()["detail"] - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_execute_connector_error(mock_cls, client, admin_token): - """变更操作 connector 异常 — 返回 500。""" - mock_cls.execute_change_json = AsyncMock( - side_effect=Exception("写入失败") - ) - iid = await _create_mongo_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "users", - "operation": "insertOne", - "document": {"name": "test"}, - } - resp = await client.post( - f"/api/v1/mongo/{iid}/execute", - json=body, - headers=auth_header(admin_token), - ) - assert resp.status_code == 500 - assert "变更执行失败" in resp.json()["detail"] - - -# ==================================================================== -# 6. 测试连接 POST /mongo/{id}/test-connection -# ==================================================================== - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_test_connection_success(mock_cls, client, admin_token): - """测试连接成功 — 返回带 version 的结果。""" - from app.services.mongo_connector import ConnectionResult - mock_result = ConnectionResult( - success=True, version="7.0.4", latency_ms=12, message="连接成功" - ) - mock_cls.test_connection = AsyncMock(return_value=mock_result) - iid = await _create_mongo_instance(client, admin_token) - - resp = await client.post( - f"/api/v1/mongo/{iid}/test-connection", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is True - assert data["version"] == "7.0.4" - assert data["latency_ms"] == 12 - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_test_connection_failure(mock_cls, client, admin_token): - """测试连接失败 — 返回 500。""" - mock_cls.test_connection = AsyncMock( - side_effect=Exception("network unreachable") - ) - iid = await _create_mongo_instance(client, admin_token) - - resp = await client.post( - f"/api/v1/mongo/{iid}/test-connection", - headers=auth_header(admin_token), - ) - assert resp.status_code == 500 - assert "连接测试失败" in resp.json()["detail"] - - -# ==================================================================== -# 7. 实例验证:非 mongo 类型 / 实例不存在 -# ==================================================================== - -@pytest.mark.asyncio -async def test_non_mongo_instance_rejected(client, admin_token): - """MySQL 实例访问 mongo 端点 — 返回 400。""" - iid = await _create_mysql_instance(client, admin_token) - - resp = await client.get( - f"/api/v1/mongo/{iid}/databases", - headers=auth_header(admin_token), - ) - assert resp.status_code == 400 - assert "MongoDB" in resp.json()["detail"] - - -@pytest.mark.asyncio -async def test_non_mongo_instance_rejected_query(client, admin_token): - """MySQL 实例访问 mongo 查询端点 — 返回 400。""" - iid = await _create_mysql_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "users", - "operation": "find", - } - resp = await client.post( - f"/api/v1/mongo/{iid}/query", - json=body, - headers=auth_header(admin_token), - ) - assert resp.status_code == 400 - - -@pytest.mark.asyncio -async def test_non_mongo_instance_rejected_execute(client, admin_token): - """MySQL 实例访问 mongo 变更端点 — 返回 400。""" - iid = await _create_mysql_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "users", - "operation": "insertOne", - "document": {"name": "test"}, - } - resp = await client.post( - f"/api/v1/mongo/{iid}/execute", - json=body, - headers=auth_header(admin_token), - ) - assert resp.status_code == 400 - - -@pytest.mark.asyncio -async def test_instance_not_found(client, admin_token): - """不存在的实例 ID — 返回 404。""" - resp = await client.get( - "/api/v1/mongo/99999/databases", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - assert "不存在" in resp.json()["detail"] - - -# ==================================================================== -# 8. 权限检查:developer 不能执行变更操作 -# ==================================================================== - -@pytest.mark.asyncio -async def test_execute_requires_admin_or_dba(client, developer_token, admin_token): - """developer 角色无法执行变更操作 — 返回 403。""" - iid = await _create_mongo_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "users", - "operation": "insertOne", - "document": {"name": "unauthorized"}, - } - resp = await client.post( - f"/api/v1/mongo/{iid}/execute", - json=body, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_query_allowed_for_developer(mock_cls, client, developer_token, admin_token): - """developer 可以执行只读查询(get_current_user 即可)。""" - mock_cls.execute_query_json = AsyncMock(return_value=[{"name": "Alice"}]) - iid = await _create_mongo_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "users", - "operation": "find", - } - resp = await client.post( - f"/api/v1/mongo/{iid}/query", - json=body, - headers=auth_header(developer_token), - ) - assert resp.status_code == 200 - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_test_connection_allowed_for_developer(mock_cls, client, developer_token, admin_token): - """developer 可以测试连接(get_current_user 即可)。""" - from app.services.mongo_connector import ConnectionResult - mock_result = ConnectionResult( - success=True, version="7.0.0", latency_ms=5, message="连接成功" - ) - mock_cls.test_connection = AsyncMock(return_value=mock_result) - iid = await _create_mongo_instance(client, admin_token) - - resp = await client.post( - f"/api/v1/mongo/{iid}/test-connection", - headers=auth_header(developer_token), - ) - assert resp.status_code == 200 - - -# ==================================================================== -# 9. dba 角色可以执行变更操作 -# ==================================================================== - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.mongo.MongoConnector") -async def test_execute_dba_allowed(mock_cls, client, dba_token, admin_token): - """dba 角色可以执行变更操作 — 正常返回。""" - mock_cls.execute_change_json = AsyncMock(return_value="507f1f77bcf86cd799439011") - iid = await _create_mongo_instance(client, admin_token) - - body = { - "database": "myapp", - "collection": "users", - "operation": "insertOne", - "document": {"name": "dba_user"}, - } - resp = await client.post( - f"/api/v1/mongo/{iid}/execute", - json=body, - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - assert resp.json()["success"] is True diff --git a/backend/tests/test_notification.py b/backend/tests/test_notification.py deleted file mode 100644 index eab9cca..0000000 --- a/backend/tests/test_notification.py +++ /dev/null @@ -1,463 +0,0 @@ -"""Comprehensive tests for NotificationChannel CRUD, permissions, config masking, and test-send.""" -import pytest -from unittest.mock import AsyncMock, patch -from httpx import AsyncClient - -from tests.conftest import auth_header - -BASE = "/api/v1/notifications" - -# ─── Helpers ──────────────────────────────────────────────────────────── - -DINGTALK_PAYLOAD = { - "name": "钉钉告警通道", - "channel_type": "dingtalk", - "config": { - "webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=abc123", - "secret": "SEC_MY_DINGTALK_SECRET", - }, - "is_enabled": True, -} - -FEISHU_PAYLOAD = { - "name": "飞书通知通道", - "channel_type": "webhook_feishu", - "config": { - "webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/xxx", - "secret": "FEISHU_SIGN_SECRET", - }, -} - -EMAIL_PAYLOAD = { - "name": "邮件通知通道", - "channel_type": "email", - "config": { - "smtp_host": "smtp.example.com", - "smtp_port": 465, - "smtp_user": "alert@example.com", - "smtp_password": "super_secret_pass", - "from_addr": "alert@example.com", - "use_tls": True, - }, -} - - -# ─── CREATE (POST /notifications) ────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_create_dingtalk_channel(client: AsyncClient, admin_token: str): - resp = await client.post(BASE, json=DINGTALK_PAYLOAD, headers=auth_header(admin_token)) - assert resp.status_code == 201 - data = resp.json() - assert data["name"] == "钉钉告警通道" - assert data["channel_type"] == "dingtalk" - assert data["is_enabled"] is True - assert "id" in data - assert data["config"]["webhook_url"] == DINGTALK_PAYLOAD["config"]["webhook_url"] - # secret must be masked - assert data["config"]["secret"] == "******" - - -@pytest.mark.asyncio -async def test_create_feishu_channel(client: AsyncClient, admin_token: str): - resp = await client.post(BASE, json=FEISHU_PAYLOAD, headers=auth_header(admin_token)) - assert resp.status_code == 201 - assert resp.json()["channel_type"] == "webhook_feishu" - - -@pytest.mark.asyncio -async def test_create_email_channel(client: AsyncClient, admin_token: str): - resp = await client.post(BASE, json=EMAIL_PAYLOAD, headers=auth_header(admin_token)) - assert resp.status_code == 201 - data = resp.json() - assert data["channel_type"] == "email" - # smtp_password must be masked - assert data["config"]["smtp_password"] == "******" - # non-sensitive fields preserved - assert data["config"]["smtp_host"] == "smtp.example.com" - assert data["config"]["smtp_port"] == 465 - - -@pytest.mark.asyncio -async def test_create_channel_default_enabled(client: AsyncClient, admin_token: str): - payload = { - "name": "默认启用", - "channel_type": "dingtalk", - "config": {"webhook_url": "https://example.com/hook"}, - } - resp = await client.post(BASE, json=payload, headers=auth_header(admin_token)) - assert resp.status_code == 201 - assert resp.json()["is_enabled"] is True - - -@pytest.mark.asyncio -async def test_create_channel_disabled(client: AsyncClient, admin_token: str): - payload = { - "name": "已禁用通道", - "channel_type": "dingtalk", - "config": {"webhook_url": "https://example.com/hook"}, - "is_enabled": False, - } - resp = await client.post(BASE, json=payload, headers=auth_header(admin_token)) - assert resp.status_code == 201 - assert resp.json()["is_enabled"] is False - - -@pytest.mark.asyncio -async def test_create_channel_invalid_type(client: AsyncClient, admin_token: str): - payload = { - "name": "坏通道", - "channel_type": "slack", - "config": {"webhook_url": "https://example.com"}, - } - resp = await client.post(BASE, json=payload, headers=auth_header(admin_token)) - assert resp.status_code == 422 - - -@pytest.mark.asyncio -async def test_create_channel_empty_name(client: AsyncClient, admin_token: str): - payload = { - "name": "", - "channel_type": "dingtalk", - "config": {"webhook_url": "https://example.com"}, - } - resp = await client.post(BASE, json=payload, headers=auth_header(admin_token)) - assert resp.status_code == 422 - - -@pytest.mark.asyncio -async def test_create_channel_name_too_long(client: AsyncClient, admin_token: str): - payload = { - "name": "x" * 101, - "channel_type": "dingtalk", - "config": {"webhook_url": "https://example.com"}, - } - resp = await client.post(BASE, json=payload, headers=auth_header(admin_token)) - assert resp.status_code == 422 - - -# ─── LIST (GET /notifications) ───────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_list_channels_empty(client: AsyncClient, admin_token: str): - resp = await client.get(BASE, headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert resp.json() == [] - - -@pytest.mark.asyncio -async def test_list_channels_returns_all(client: AsyncClient, admin_token: str): - for payload in [DINGTALK_PAYLOAD, FEISHU_PAYLOAD, EMAIL_PAYLOAD]: - await client.post(BASE, json=payload, headers=auth_header(admin_token)) - - resp = await client.get(BASE, headers=auth_header(admin_token)) - assert resp.status_code == 200 - items = resp.json() - assert len(items) == 3 - types = {ch["channel_type"] for ch in items} - assert types == {"dingtalk", "webhook_feishu", "email"} - - -@pytest.mark.asyncio -async def test_list_channels_config_masked(client: AsyncClient, admin_token: str): - await client.post(BASE, json=DINGTALK_PAYLOAD, headers=auth_header(admin_token)) - - resp = await client.get(BASE, headers=auth_header(admin_token)) - items = resp.json() - assert len(items) == 1 - assert items[0]["config"]["secret"] == "******" - - -# ─── UPDATE (PUT /notifications/{channel_id}) ────────────────────────── - - -@pytest.mark.asyncio -async def test_update_channel_name(client: AsyncClient, admin_token: str): - create = await client.post(BASE, json=DINGTALK_PAYLOAD, headers=auth_header(admin_token)) - cid = create.json()["id"] - - resp = await client.put( - f"{BASE}/{cid}", - json={"name": "更新后的钉钉通道"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "更新后的钉钉通道" - - -@pytest.mark.asyncio -async def test_update_channel_toggle_enabled(client: AsyncClient, admin_token: str): - create = await client.post(BASE, json=DINGTALK_PAYLOAD, headers=auth_header(admin_token)) - cid = create.json()["id"] - - resp = await client.put( - f"{BASE}/{cid}", - json={"is_enabled": False}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["is_enabled"] is False - - -@pytest.mark.asyncio -async def test_update_channel_config(client: AsyncClient, admin_token: str): - create = await client.post(BASE, json=DINGTALK_PAYLOAD, headers=auth_header(admin_token)) - cid = create.json()["id"] - - new_config = {"webhook_url": "https://new-hook.example.com", "secret": "NEW_SECRET"} - resp = await client.put( - f"{BASE}/{cid}", - json={"config": new_config}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["config"]["webhook_url"] == "https://new-hook.example.com" - # secret masked in response - assert data["config"]["secret"] == "******" - - -@pytest.mark.asyncio -async def test_update_nonexistent_channel(client: AsyncClient, admin_token: str): - resp = await client.put( - f"{BASE}/99999", - json={"name": "不存在"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - -@pytest.mark.asyncio -async def test_update_channel_invalid_type(client: AsyncClient, admin_token: str): - create = await client.post(BASE, json=DINGTALK_PAYLOAD, headers=auth_header(admin_token)) - cid = create.json()["id"] - - resp = await client.put( - f"{BASE}/{cid}", - json={"channel_type": "invalid_type"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 422 - - -# ─── DELETE (DELETE /notifications/{channel_id}) ─────────────────────── - - -@pytest.mark.asyncio -async def test_delete_channel(client: AsyncClient, admin_token: str): - create = await client.post(BASE, json=DINGTALK_PAYLOAD, headers=auth_header(admin_token)) - cid = create.json()["id"] - - resp = await client.delete(f"{BASE}/{cid}", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert "message" in resp.json() - - # Verify it's gone from the list - items = (await client.get(BASE, headers=auth_header(admin_token))).json() - assert all(ch["id"] != cid for ch in items) - - -@pytest.mark.asyncio -async def test_delete_nonexistent_channel(client: AsyncClient, admin_token: str): - resp = await client.delete(f"{BASE}/99999", headers=auth_header(admin_token)) - assert resp.status_code == 404 - - -# ─── TEST SEND (POST /notifications/{channel_id}/test) ───────────────── - - -@pytest.mark.asyncio -@patch("app.services.notification_sender.NotificationSender.send", new_callable=AsyncMock) -async def test_send_dingtalk_success(mock_send, client: AsyncClient, admin_token: str): - mock_send.return_value = {"success": True, "message": "钉钉测试消息发送成功"} - create = await client.post(BASE, json=DINGTALK_PAYLOAD, headers=auth_header(admin_token)) - cid = create.json()["id"] - - resp = await client.post(f"{BASE}/{cid}/test", headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is True - assert "钉钉" in data["message"] - - -@pytest.mark.asyncio -async def test_send_dingtalk_missing_webhook(client: AsyncClient, admin_token: str): - payload = { - "name": "缺少webhook的钉钉", - "channel_type": "dingtalk", - "config": {"secret": "abc"}, - } - create = await client.post(BASE, json=payload, headers=auth_header(admin_token)) - cid = create.json()["id"] - - resp = await client.post(f"{BASE}/{cid}/test", headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is False - assert "webhook_url" in data["message"] - - -@pytest.mark.asyncio -@patch("app.services.notification_sender.NotificationSender.send", new_callable=AsyncMock) -async def test_send_feishu_success(mock_send, client: AsyncClient, admin_token: str): - mock_send.return_value = {"success": True, "message": "飞书测试消息发送成功"} - create = await client.post(BASE, json=FEISHU_PAYLOAD, headers=auth_header(admin_token)) - cid = create.json()["id"] - - resp = await client.post(f"{BASE}/{cid}/test", headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is True - assert "飞书" in data["message"] - - -@pytest.mark.asyncio -async def test_send_feishu_missing_webhook(client: AsyncClient, admin_token: str): - payload = { - "name": "缺少webhook的飞书", - "channel_type": "webhook_feishu", - "config": {}, - } - create = await client.post(BASE, json=payload, headers=auth_header(admin_token)) - cid = create.json()["id"] - - resp = await client.post(f"{BASE}/{cid}/test", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert resp.json()["success"] is False - assert "webhook_url" in resp.json()["message"] - - -@pytest.mark.asyncio -@patch("app.services.notification_sender.NotificationSender.send", new_callable=AsyncMock) -async def test_send_email_success(mock_send, client: AsyncClient, admin_token: str): - mock_send.return_value = {"success": True, "message": "邮件测试消息发送成功"} - create = await client.post(BASE, json=EMAIL_PAYLOAD, headers=auth_header(admin_token)) - cid = create.json()["id"] - - resp = await client.post(f"{BASE}/{cid}/test", headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is True - assert "邮件" in data["message"] - - -@pytest.mark.asyncio -async def test_send_email_missing_smtp_config(client: AsyncClient, admin_token: str): - payload = { - "name": "不完整的邮件", - "channel_type": "email", - "config": {"smtp_host": "smtp.example.com"}, - # missing smtp_port, smtp_user, from_addr - } - create = await client.post(BASE, json=payload, headers=auth_header(admin_token)) - cid = create.json()["id"] - - resp = await client.post(f"{BASE}/{cid}/test", headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is False - assert "smtp_user" in data["message"] - - -@pytest.mark.asyncio -async def test_send_test_nonexistent_channel(client: AsyncClient, admin_token: str): - resp = await client.post(f"{BASE}/99999/test", headers=auth_header(admin_token)) - assert resp.status_code == 404 - - -# ─── PERMISSION CHECKS (all admin-only, others get 403) ──────────────── - - -@pytest.mark.asyncio -async def test_permission_dba_list_forbidden(client: AsyncClient, dba_token: str): - resp = await client.get(BASE, headers=auth_header(dba_token)) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_permission_developer_list_forbidden(client: AsyncClient, developer_token: str): - resp = await client.get(BASE, headers=auth_header(developer_token)) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_permission_guest_list_forbidden(client: AsyncClient, guest_token: str): - resp = await client.get(BASE, headers=auth_header(guest_token)) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_permission_dba_create_forbidden(client: AsyncClient, dba_token: str): - resp = await client.post(BASE, json=DINGTALK_PAYLOAD, headers=auth_header(dba_token)) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_permission_developer_update_forbidden(client: AsyncClient, developer_token: str): - resp = await client.put(f"{BASE}/1", json={"name": "x"}, headers=auth_header(developer_token)) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_permission_guest_delete_forbidden(client: AsyncClient, guest_token: str): - resp = await client.delete(f"{BASE}/1", headers=auth_header(guest_token)) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_permission_dba_test_send_forbidden(client: AsyncClient, dba_token: str): - resp = await client.post(f"{BASE}/1/test", headers=auth_header(dba_token)) - assert resp.status_code == 403 - - -# ─── EDGE CASES ───────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_no_auth_returns_401(client: AsyncClient): - """All endpoints require authentication — without a token expect 401.""" - for method, url in [ - ("get", BASE), - ("post", BASE), - ("put", f"{BASE}/1"), - ("delete", f"{BASE}/1"), - ("post", f"{BASE}/1/test"), - ]: - resp = await getattr(client, method)(url) - assert resp.status_code == 401, f"{method.upper()} {url} should be 401, got {resp.status_code}" - - -@pytest.mark.asyncio -async def test_config_without_sensitive_keys(client: AsyncClient, admin_token: str): - """Config with no sensitive keys should pass through unchanged.""" - payload = { - "name": "无敏感配置", - "channel_type": "dingtalk", - "config": {"webhook_url": "https://example.com/hook", "extra": "value"}, - } - resp = await client.post(BASE, json=payload, headers=auth_header(admin_token)) - assert resp.status_code == 201 - config = resp.json()["config"] - assert config["webhook_url"] == "https://example.com/hook" - assert config["extra"] == "value" - assert "secret" not in config - - -@pytest.mark.asyncio -async def test_update_partial_preserves_others(client: AsyncClient, admin_token: str): - """Updating only name should not change config or channel_type.""" - create = await client.post(BASE, json=DINGTALK_PAYLOAD, headers=auth_header(admin_token)) - cid = create.json()["id"] - - resp = await client.put( - f"{BASE}/{cid}", - json={"name": "只改名字"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["name"] == "只改名字" - assert data["channel_type"] == "dingtalk" - assert data["config"]["webhook_url"] == DINGTALK_PAYLOAD["config"]["webhook_url"] diff --git a/backend/tests/test_permission_workflow.py b/backend/tests/test_permission_workflow.py deleted file mode 100644 index 5107c42..0000000 --- a/backend/tests/test_permission_workflow.py +++ /dev/null @@ -1,467 +0,0 @@ -"""权限组 + 审批流 全链路测试。 - -覆盖: -1. 查询权限: 用户只能查自己被授权的数据源 -2. 工单权限: 用户只能对被授权的数据源提交变更 -3. 审批流: 工单创建自动启动审批流,多级审批 -""" -import json -import sys -import pytest -from contextlib import asynccontextmanager -from unittest.mock import patch, AsyncMock, MagicMock - -from app.core.security import create_access_token, hash_password -from app.models.user import User -from app.models.instance import Instance -from app.models.instance_group import InstanceGroup, user_groups -from app.models.ticket import Ticket -from app.models.workflow import WorkflowDefinition, WorkflowStep, TicketApproval -from tests.conftest import auth_header - - -def _make_aiomysql_mock(): - aiomysql = MagicMock() - aiomysql.connect = AsyncMock() - aiomysql.DictCursor = MagicMock() - return aiomysql - - -def _make_mock_conn(): - mock_cursor = AsyncMock() - mock_cursor.description = [("id",), ("name",)] - mock_cursor.fetchall.return_value = [{"id": 1, "name": "test"}] - mock_cursor.execute = AsyncMock() - - @asynccontextmanager - async def mock_cursor_ctx(*args, **kwargs): - yield mock_cursor - - mock_conn = AsyncMock() - mock_conn.cursor = mock_cursor_ctx - mock_conn.close = MagicMock() - return mock_conn - - -# ── Fixtures ───────────────────────────────────────────────────────── - -@pytest.fixture -async def grouped_instance(db): - inst = Instance( - name="生产MySQL", - db_type="mysql", - host="10.0.0.1", - port=3306, - username="root", - password="test123", - database="prod_db", - env="production", - status="disconnected", - ) - db.add(inst) - await db.commit() - await db.refresh(inst) - return inst - - -@pytest.fixture -async def public_instance(db): - inst = Instance( - name="测试PG", - db_type="postgres", - host="127.0.0.1", - port=5432, - username="postgres", - password="test123", - database="test_db", - env="development", - status="disconnected", - ) - db.add(inst) - await db.commit() - await db.refresh(inst) - return inst - - -@pytest.fixture -async def dev_group(db, grouped_instance): - group = InstanceGroup( - name="开发组", - description="开发人员可用数据源", - db_type="mysql", - permissions=json.dumps(["query:select", "ticket:create"]), - ) - db.add(group) - await db.commit() - await db.refresh(group) - - grouped_instance.group_id = group.id - await db.commit() - await db.refresh(grouped_instance) - return group - - -@pytest.fixture -async def dev_in_group(db, dev_group, developer_user): - await db.execute( - user_groups.insert().values(user_id=developer_user.id, group_id=dev_group.id) - ) - await db.commit() - return developer_user - - -@pytest.fixture -async def dev_in_group_token(dev_in_group): - return create_access_token(data={"sub": str(dev_in_group.id), "role": dev_in_group.role}) - - -@pytest.fixture -async def default_workflow(db): - wf = WorkflowDefinition( - name="标准审批流", - description="DBA审核后Admin确认", - is_default=True, - is_active=True, - ) - db.add(wf) - await db.commit() - await db.refresh(wf) - - step1 = WorkflowStep( - workflow_id=wf.id, - step_number=1, - name="DBA审核", - step_type="serial", - approver_role="dba", - is_required=True, - timeout_hours=24, - ) - step2 = WorkflowStep( - workflow_id=wf.id, - step_number=2, - name="管理员确认", - step_type="serial", - approver_role="admin", - is_required=True, - timeout_hours=24, - ) - db.add_all([step1, step2]) - await db.commit() - return wf - - -# ── 1. 查询权限测试 ────────────────────────────────────────────────── - -class TestQueryPermission: - async def test_admin_can_query_grouped_instance(self, client, admin_token, grouped_instance, dev_group): - """admin 可以查询绑定权限组的实例""" - mock_conn = _make_mock_conn() - mock_aiomysql = _make_aiomysql_mock() - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.post( - "/api/v1/query", - json={"instance_id": grouped_instance.id, "database": "prod_db", "sql": "SELECT 1"}, - params={"save_history": "false"}, - headers=auth_header(admin_token), - ) - assert resp.status_code != 403 - - async def test_dev_in_group_can_query(self, client, dev_in_group_token, grouped_instance, dev_group): - """developer 在权限组内 → 可以查询""" - mock_conn = _make_mock_conn() - mock_aiomysql = _make_aiomysql_mock() - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.post( - "/api/v1/query", - json={"instance_id": grouped_instance.id, "database": "prod_db", "sql": "SELECT 1"}, - params={"save_history": "false"}, - headers=auth_header(dev_in_group_token), - ) - assert resp.status_code != 403 - - async def test_dev_not_in_group_blocked(self, client, developer_token, grouped_instance, dev_group): - """developer 不在权限组 → 403""" - resp = await client.post( - "/api/v1/query", - json={"instance_id": grouped_instance.id, "database": "prod_db", "sql": "SELECT 1"}, - params={"save_history": "false"}, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - assert "权限" in resp.json()["detail"] - - async def test_dev_can_query_public_instance(self, client, developer_token, public_instance): - """无权限组的公共实例 → 所有认证用户可查""" - resp = await client.post( - "/api/v1/query", - json={"instance_id": public_instance.id, "database": "test_db", "sql": "SELECT 1"}, - params={"save_history": "false"}, - headers=auth_header(developer_token), - ) - assert resp.status_code != 403 - - async def test_guest_still_blocked(self, client, guest_token, public_instance): - """guest 被禁止查询""" - resp = await client.post( - "/api/v1/query", - json={"instance_id": public_instance.id, "database": "test_db", "sql": "SELECT 1"}, - params={"save_history": "false"}, - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - async def test_dba_not_in_group_blocked(self, client, dba_token, grouped_instance, dev_group): - """dba 不在权限组也 → 403""" - resp = await client.post( - "/api/v1/query", - json={"instance_id": grouped_instance.id, "database": "prod_db", "sql": "SELECT 1"}, - params={"save_history": "false"}, - headers=auth_header(dba_token), - ) - assert resp.status_code == 403 - - -# ── 2. 工单权限测试 ────────────────────────────────────────────────── - -class TestTicketPermission: - async def test_admin_can_create_ticket_any(self, client, admin_token, grouped_instance, dev_group): - """admin 可以为任何实例创建工单""" - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "管理员修改表结构", - "db_type": "mysql", - "instance_id": grouped_instance.id, - "target_database": "prod_db", - "sql_content": "ALTER TABLE users ADD COLUMN age INT", - "reason": "业务需求,增加年龄字段", - "priority": "medium", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code != 403 - - async def test_dev_in_group_can_create_ticket(self, client, dev_in_group_token, grouped_instance, dev_group): - """developer 在权限组内 → 可以创建工单""" - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "开发修正错误数据", - "db_type": "mysql", - "instance_id": grouped_instance.id, - "target_database": "prod_db", - "sql_content": "UPDATE users SET age = 20 WHERE id = 1", - "reason": "修正用户年龄数据错误", - "priority": "low", - }, - headers=auth_header(dev_in_group_token), - ) - assert resp.status_code != 403 - - async def test_dev_not_in_group_cannot_create_ticket(self, client, developer_token, grouped_instance, dev_group): - """developer 不在权限组 → 403""" - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "未授权操作工单", - "db_type": "mysql", - "instance_id": grouped_instance.id, - "target_database": "prod_db", - "sql_content": "DELETE FROM users WHERE id = 1", - "reason": "测试未授权操作权限", - "priority": "low", - }, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - assert "权限" in resp.json()["detail"] - - -# ── 3. 审批流对接测试 ────────────────────────────────────────────────── - -class TestWorkflowIntegration: - async def test_no_workflow_status_pending(self, client, admin_token, test_instance): - """无默认审批流 → 状态为 pending""" - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "简单测试工单", - "db_type": "mysql", - "instance_id": test_instance.id, - "target_database": "test_db", - "sql_content": "UPDATE users SET name = 'test' WHERE id = 1", - "reason": "测试审批流功能", - "priority": "low", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - assert resp.json()["status"] == "pending" - - async def test_with_workflow_status_reviewing(self, client, admin_token, test_instance, default_workflow): - """有默认审批流 → 状态为 reviewing""" - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "审批流测试工单", - "db_type": "mysql", - "instance_id": test_instance.id, - "target_database": "test_db", - "sql_content": "UPDATE users SET name = 'test' WHERE id = 1", - "reason": "测试审批流功能", - "priority": "medium", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - assert data["status"] == "reviewing" - ticket_id = data["id"] - - resp2 = await client.get( - f"/api/v1/tickets/{ticket_id}/approvals", - headers=auth_header(admin_token), - ) - assert resp2.status_code == 200 - approvals = resp2.json() - assert len(approvals) == 2 - assert approvals[0]["step_number"] == 1 - assert approvals[0]["approver_role"] == "dba" - assert approvals[0]["action"] == "pending" - assert approvals[1]["step_number"] == 2 - assert approvals[1]["approver_role"] == "admin" - - async def test_multi_step_first_approve_still_reviewing(self, client, admin_token, dba_token, test_instance, default_workflow): - """多级审批: 第一步通过 → reviewing""" - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "多级审批测试工单", - "db_type": "mysql", - "instance_id": test_instance.id, - "target_database": "test_db", - "sql_content": "UPDATE users SET age = 25 WHERE id = 1", - "reason": "测试审批流功能", - "priority": "medium", - }, - headers=auth_header(admin_token), - ) - ticket_id = resp.json()["id"] - - resp2 = await client.post( - f"/api/v1/tickets/{ticket_id}/review", - json={"action": "approve", "comment": "DBA审核通过"}, - headers=auth_header(dba_token), - ) - assert resp2.status_code == 200 - assert resp2.json()["status"] == "reviewing" - - async def test_multi_step_all_approve_becomes_approved(self, client, admin_token, dba_token, test_instance, default_workflow): - """多级审批: 全部通过 → approved""" - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "完整审批测试工单", - "db_type": "mysql", - "instance_id": test_instance.id, - "target_database": "test_db", - "sql_content": "UPDATE users SET status = 'active' WHERE id = 1", - "reason": "测试审批流功能", - "priority": "medium", - }, - headers=auth_header(admin_token), - ) - ticket_id = resp.json()["id"] - - await client.post( - f"/api/v1/tickets/{ticket_id}/review", - json={"action": "approve", "comment": "DBA通过"}, - headers=auth_header(dba_token), - ) - - resp3 = await client.post( - f"/api/v1/tickets/{ticket_id}/review", - json={"action": "approve", "comment": "管理员确认通过"}, - headers=auth_header(admin_token), - ) - assert resp3.status_code == 200 - assert resp3.json()["status"] == "approved" - - async def test_reject_at_any_step(self, client, admin_token, dba_token, test_instance, default_workflow): - """多级审批: 拒绝 → rejected""" - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "被拒绝的测试工单", - "db_type": "mysql", - "instance_id": test_instance.id, - "target_database": "test_db", - "sql_content": "DELETE FROM logs WHERE id < 1000", - "reason": "清理过期日志数据", - "priority": "high", - }, - headers=auth_header(admin_token), - ) - ticket_id = resp.json()["id"] - - resp2 = await client.post( - f"/api/v1/tickets/{ticket_id}/review", - json={"action": "reject", "comment": "风险太高"}, - headers=auth_header(dba_token), - ) - assert resp2.status_code == 200 - assert resp2.json()["status"] == "rejected" - - async def test_no_workflow_single_step_approve(self, client, admin_token, dba_token, test_instance): - """无审批流: 传统单步审批直接 approved""" - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "单步审批测试工单", - "db_type": "mysql", - "instance_id": test_instance.id, - "target_database": "test_db", - "sql_content": "UPDATE users SET name = 'x' WHERE id = 1", - "reason": "测试审批流功能", - "priority": "low", - }, - headers=auth_header(admin_token), - ) - ticket_id = resp.json()["id"] - assert resp.json()["status"] == "pending" - - resp2 = await client.post( - f"/api/v1/tickets/{ticket_id}/review", - json={"action": "approve", "comment": "通过"}, - headers=auth_header(dba_token), - ) - assert resp2.status_code == 200 - assert resp2.json()["status"] == "approved" - - async def test_approvals_endpoint_empty(self, client, admin_token, test_instance): - """无审批流 → approvals 返回空列表""" - resp = await client.post( - "/api/v1/tickets", - json={ - "title": "无审批流测试工单", - "db_type": "mysql", - "instance_id": test_instance.id, - "target_database": "test_db", - "sql_content": "SELECT 1", - "reason": "测试审批流功能", - "priority": "low", - }, - headers=auth_header(admin_token), - ) - ticket_id = resp.json()["id"] - - resp2 = await client.get( - f"/api/v1/tickets/{ticket_id}/approvals", - headers=auth_header(admin_token), - ) - assert resp2.status_code == 200 - assert resp2.json() == [] diff --git a/backend/tests/test_phase1_features.py b/backend/tests/test_phase1_features.py deleted file mode 100644 index 7520576..0000000 --- a/backend/tests/test_phase1_features.py +++ /dev/null @@ -1,280 +0,0 @@ -"""风险等级评估 + 维护窗口 + 通知脱敏 + SQL items 测试。""" - -import json -import pytest -from unittest.mock import patch, MagicMock - -# ===== 风险等级评估测试 ===== - -class TestRiskEvaluator: - """风险等级自动计算测试。""" - - def test_select_is_low(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("SELECT * FROM users WHERE id = 1") == "low" - - def test_show_is_low(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("SHOW TABLES") == "low" - - def test_explain_is_low(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("EXPLAIN SELECT * FROM users") == "low" - - def test_create_table_is_medium(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("CREATE TABLE t1 (id INT)") == "medium" - - def test_alter_table_is_medium(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("ALTER TABLE users ADD COLUMN age INT") == "medium" - - def test_insert_with_where_is_medium(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level( - "INSERT INTO users (name) VALUES ('test')", - sql_type="DML", - ) == "medium" - - def test_update_without_where_is_high(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("UPDATE users SET status = 1") == "high" - - def test_delete_without_where_is_high(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("DELETE FROM users") == "high" - - def test_alter_drop_column_is_high(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("ALTER TABLE users DROP COLUMN age") == "high" - - def test_alter_modify_column_is_high(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("ALTER TABLE users MODIFY COLUMN age BIGINT") == "high" - - def test_drop_table_is_critical(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("DROP TABLE users") == "critical" - - def test_truncate_is_critical(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("TRUNCATE TABLE users") == "critical" - - def test_drop_database_is_critical(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("DROP DATABASE mydb") == "critical" - - def test_many_statements_is_high(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level( - "SELECT 1", statement_count=10 - ) == "high" - - def test_multiple_audit_errors_is_high(self): - from app.services.risk_evaluator import evaluate_risk_level - findings = [ - {"level": "error", "rule_id": "R1"}, - {"level": "error", "rule_id": "R2"}, - ] - assert evaluate_risk_level( - "UPDATE users SET x=1 WHERE id=1", - sql_type="DML", - audit_findings=findings, - ) == "high" - - def test_delete_with_where_no_limit_is_medium(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level( - "DELETE FROM users WHERE id > 100" - ) == "medium" - - def test_delete_with_where_and_limit_is_low(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level( - "DELETE FROM users WHERE id > 100 LIMIT 10" - ) == "low" - - # Redis 风险 - def test_redis_flushall_is_critical(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("FLUSHALL", db_type="redis") == "critical" - - def test_redis_flushdb_is_critical(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("FLUSHDB", db_type="redis") == "critical" - - def test_redis_set_is_medium(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("SET key value", db_type="redis") == "medium" - - def test_redis_get_is_low(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("GET key", db_type="redis") == "low" - - # MongoDB 风险 - def test_mongo_drop_database_is_critical(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("db.dropDatabase()", db_type="mongodb") == "critical" - - def test_mongo_drop_database_upper_is_critical(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("DROP DATABASE mydb", db_type="mongodb") == "critical" - - def test_mongo_insert_is_medium(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("db.users.insertOne({name: 'test'})", db_type="mongodb") == "medium" - - def test_mongo_find_is_low(self): - from app.services.risk_evaluator import evaluate_risk_level - assert evaluate_risk_level("db.users.find({})", db_type="mongodb") == "low" - - -# ===== 风险等级配置测试 ===== - -class TestRiskLevelConfig: - """风险等级配置验证。""" - - def test_all_levels_have_config(self): - from app.services.risk_evaluator import RISK_LEVEL_CONFIG - for level in ["low", "medium", "high", "critical"]: - assert level in RISK_LEVEL_CONFIG - cfg = RISK_LEVEL_CONFIG[level] - assert "label" in cfg - assert "color" in cfg - assert "min_approvers" in cfg - - def test_high_and_critical_require_2_approvers(self): - from app.services.risk_evaluator import RISK_LEVEL_CONFIG - assert RISK_LEVEL_CONFIG["high"]["min_approvers"] >= 2 - assert RISK_LEVEL_CONFIG["critical"]["min_approvers"] >= 2 - - -# ===== 通知脱敏测试 ===== - -class TestNotificationMasking: - """通知内容脱敏测试。""" - - def test_mask_password_in_sql_preview(self): - from app.services.notification_sender import NotificationSender - data = {"sql_preview": "SET password='secret123' WHERE id=1"} - masked = NotificationSender._mask_sensitive_data(data) - assert "secret123" not in masked["sql_preview"] - assert "***" in masked["sql_preview"] - - def test_mask_drop_table_in_sql_preview(self): - from app.services.notification_sender import NotificationSender - data = {"sql_preview": "DROP TABLE users"} - masked = NotificationSender._mask_sensitive_data(data) - assert "***" in masked["sql_preview"] - - def test_mask_error_field(self): - from app.services.notification_sender import NotificationSender - data = {"error": "Connection failed: password='mysecret'"} - masked = NotificationSender._mask_sensitive_data(data) - assert "mysecret" not in masked["error"] - - def test_truncate_long_sql_preview(self): - from app.services.notification_sender import NotificationSender - long_sql = "SELECT * FROM users WHERE " + "x=1 AND " * 50 - data = {"sql_preview": long_sql} - masked = NotificationSender._mask_sensitive_data(data) - assert len(masked["sql_preview"]) <= 110 # 100 + "..." - - def test_non_sensitive_data_unchanged(self): - from app.services.notification_sender import NotificationSender - data = {"title": "测试工单", "applicant": "张三"} - masked = NotificationSender._mask_sensitive_data(data) - assert masked["title"] == "测试工单" - assert masked["applicant"] == "张三" - - -# ===== 维护窗口测试 ===== - -class TestMaintenanceWindow: - """维护窗口逻辑测试。""" - - def test_no_config_allows_execution(self): - """无配置时允许执行。""" - from app.api.v1.endpoints.ticket import _check_maintenance_window - from unittest.mock import AsyncMock - import asyncio - - mock_db = AsyncMock() - mock_result = MagicMock() - mock_result.scalar_one_or_none.return_value = None - mock_db.execute.return_value = mock_result - - async def run(): - result = await _check_maintenance_window(mock_db) - assert result is None - - asyncio.get_event_loop().run_until_complete(run()) - - def test_disabled_window_allows_execution(self): - """disabled 的窗口允许执行。""" - from app.api.v1.endpoints.ticket import _check_maintenance_window - from app.models.system_setting import SystemSetting - from unittest.mock import AsyncMock - import asyncio - - setting = SystemSetting(key="maintenance_window", value='{"enabled": false}') - mock_db = AsyncMock() - mock_result = MagicMock() - mock_result.scalar_one_or_none.return_value = setting - mock_db.execute.return_value = mock_result - - async def run(): - result = await _check_maintenance_window(mock_db) - assert result is None - - asyncio.get_event_loop().run_until_complete(run()) - - -# ===== SQL 类型分类测试 ===== - -class TestClassifySqlType: - """SQL 类型分类辅助函数测试。""" - - def test_select_is_dql(self): - from app.api.v1.endpoints.ticket import _classify_sql_type - assert _classify_sql_type("SELECT * FROM t") == "DQL" - - def test_insert_is_dml(self): - from app.api.v1.endpoints.ticket import _classify_sql_type - assert _classify_sql_type("INSERT INTO t VALUES (1)") == "DML" - - def test_update_is_dml(self): - from app.api.v1.endpoints.ticket import _classify_sql_type - assert _classify_sql_type("UPDATE t SET x=1") == "DML" - - def test_delete_is_dml(self): - from app.api.v1.endpoints.ticket import _classify_sql_type - assert _classify_sql_type("DELETE FROM t") == "DML" - - def test_create_is_ddl(self): - from app.api.v1.endpoints.ticket import _classify_sql_type - assert _classify_sql_type("CREATE TABLE t (id INT)") == "DDL" - - def test_alter_is_ddl(self): - from app.api.v1.endpoints.ticket import _classify_sql_type - assert _classify_sql_type("ALTER TABLE t ADD COLUMN x INT") == "DDL" - - def test_drop_is_ddl(self): - from app.api.v1.endpoints.ticket import _classify_sql_type - assert _classify_sql_type("DROP TABLE t") == "DDL" - - def test_grant_is_dcl(self): - from app.api.v1.endpoints.ticket import _classify_sql_type - assert _classify_sql_type("GRANT SELECT ON t TO user") == "DCL" - - def test_redis_set(self): - from app.api.v1.endpoints.ticket import _classify_sql_type - assert _classify_sql_type("SET key value") == "SET" - - def test_redis_get(self): - from app.api.v1.endpoints.ticket import _classify_sql_type - assert _classify_sql_type("GET key") == "SET" - - def test_unknown(self): - from app.api.v1.endpoints.ticket import _classify_sql_type - assert _classify_sql_type("UNKNOWN COMMAND") == "OTHER" diff --git a/backend/tests/test_phase2_features.py b/backend/tests/test_phase2_features.py deleted file mode 100644 index 3423549..0000000 --- a/backend/tests/test_phase2_features.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Phase 2 影响分析测试 — EXPLAIN预检 + 部分失败策略 + 上下文感知规则。""" - -import pytest -from unittest.mock import MagicMock, AsyncMock, patch - - -# ===== EXPLAIN 预检: 表名提取 ===== - -class TestExtractTableNames: - """SQL 表名提取测试。""" - - def test_update_table(self): - from app.services.explain_preview import _extract_table_names - assert "users" in _extract_table_names("UPDATE users SET x=1") - - def test_delete_from_table(self): - from app.services.explain_preview import _extract_table_names - assert "users" in _extract_table_names("DELETE FROM users WHERE id=1") - - def test_alter_table(self): - from app.services.explain_preview import _extract_table_names - assert "orders" in _extract_table_names("ALTER TABLE orders ADD COLUMN price DECIMAL") - - def test_drop_table(self): - from app.services.explain_preview import _extract_table_names - assert "temp" in _extract_table_names("DROP TABLE temp") - - def test_drop_table_if_exists(self): - from app.services.explain_preview import _extract_table_names - assert "temp" in _extract_table_names("DROP TABLE IF EXISTS temp") - - def test_insert_into(self): - from app.services.explain_preview import _extract_table_names - assert "logs" in _extract_table_names("INSERT INTO logs (msg) VALUES ('hi')") - - def test_select_from(self): - from app.services.explain_preview import _extract_table_names - names = _extract_table_names("SELECT * FROM users WHERE id=1") - assert "users" in names - - def test_join_tables(self): - from app.services.explain_preview import _extract_table_names - names = _extract_table_names("SELECT * FROM users JOIN orders ON users.id = orders.user_id") - assert "users" in names - assert "orders" in names - - def test_create_table(self): - from app.services.explain_preview import _extract_table_names - assert "new_table" in _extract_table_names("CREATE TABLE new_table (id INT)") - - def test_create_table_if_not_exists(self): - from app.services.explain_preview import _extract_table_names - assert "new_table" in _extract_table_names("CREATE TABLE IF NOT EXISTS new_table (id INT)") - - def test_deduplication(self): - from app.services.explain_preview import _extract_table_names - names = _extract_table_names("SELECT * FROM users JOIN users AS u2 ON users.id = u2.id") - assert names.count("users") == 1 - - def test_empty_sql(self): - from app.services.explain_preview import _extract_table_names - assert _extract_table_names("") == [] - - -# ===== ExplainResult 测试 ===== - -class TestExplainResult: - """EXPLAIN 结果数据结构测试。""" - - def test_to_dict(self): - from app.services.explain_preview import ExplainResult - r = ExplainResult( - sql="SELECT * FROM users", - sql_type="DQL", - estimated_rows=1000, - full_table_scan=True, - warnings=["Using filesort"], - ) - d = r.to_dict() - assert d["sql_type"] == "DQL" - assert d["estimated_rows"] == 1000 - assert d["full_table_scan"] is True - assert "Using filesort" in d["warnings"] - - def test_error_field(self): - from app.services.explain_preview import ExplainResult - r = ExplainResult(error="Connection refused") - d = r.to_dict() - assert d["error"] == "Connection refused" - - -# ===== 执行策略测试 ===== - -class TestExecStrategy: - """部分失败策略测试。""" - - def test_schema_default_strategy(self): - """TicketCreate schema 默认策略是 stop_on_error。""" - from app.schemas.ticket import TicketCreate - data = TicketCreate( - title="测试工单12345", - db_type="mysql", - instance_id=1, - target_database="test_db", - sql_content="SELECT 1", - reason="测试原因abcde", - ) - assert data.exec_strategy == "stop_on_error" - - def test_schema_continue_strategy(self): - from app.schemas.ticket import TicketCreate - data = TicketCreate( - title="测试工单12345", - db_type="mysql", - instance_id=1, - target_database="test_db", - sql_content="SELECT 1", - reason="测试原因abcde", - exec_strategy="continue", - ) - assert data.exec_strategy == "continue" - - def test_schema_transaction_strategy(self): - from app.schemas.ticket import TicketCreate - data = TicketCreate( - title="测试工单12345", - db_type="mysql", - instance_id=1, - target_database="test_db", - sql_content="SELECT 1", - reason="测试原因abcde", - exec_strategy="transaction", - ) - assert data.exec_strategy == "transaction" - - -# ===== 上下文感知规则测试 ===== - -class TestRiskEvaluatorTableSizes: - """risk_evaluator 大表检测测试(合并自原 CTX-001)。""" - - def test_alter_large_table_upgraded_to_high(self): - """大表 ALTER 升级为 high。""" - from app.services.risk_evaluator import evaluate_risk_level - level = evaluate_risk_level( - "ALTER TABLE big_table ADD COLUMN status INT", - db_type="mysql", - table_sizes={"big_table": 500000}, - ) - assert level == "high" - - def test_alter_small_table_not_upgraded(self): - """小表 ALTER 保持 medium。""" - from app.services.risk_evaluator import evaluate_risk_level - level = evaluate_risk_level( - "ALTER TABLE users ADD COLUMN age INT", - db_type="mysql", - table_sizes={"users": 100}, - ) - assert level == "medium" - - def test_update_large_table_upgraded_to_high(self): - """大表 UPDATE 无 WHERE 升级为 high。""" - from app.services.risk_evaluator import evaluate_risk_level - level = evaluate_risk_level( - "UPDATE big_table SET status = 1", - db_type="mysql", - table_sizes={"big_table": 200000}, - ) - assert level == "high" - - def test_no_table_sizes_no_upgrade(self): - """无 table_sizes 时不升级。""" - from app.services.risk_evaluator import evaluate_risk_level - level = evaluate_risk_level( - "ALTER TABLE users ADD COLUMN age INT", - db_type="mysql", - ) - assert level == "medium" - - def test_select_large_table_stays_low(self): - """SELECT 大表不升级。""" - from app.services.risk_evaluator import evaluate_risk_level - level = evaluate_risk_level( - "SELECT * FROM big_table", - db_type="mysql", - table_sizes={"big_table": 500000}, - ) - assert level == "low" diff --git a/backend/tests/test_pool.py b/backend/tests/test_pool.py deleted file mode 100644 index 2c77055..0000000 --- a/backend/tests/test_pool.py +++ /dev/null @@ -1,329 +0,0 @@ -"""连接池管理端点完整测试 — pool.py - -覆盖端点: - GET /api/v1/pools/instances/{instance_id}/stats 连接池状态 - POST /api/v1/pools/instances/{instance_id}/reset 重置连接池 - GET /api/v1/pools/overview 所有连接池概览 -""" -import pytest -from unittest.mock import patch, AsyncMock, MagicMock -from httpx import AsyncClient - -from tests.conftest import auth_header - - -# ═══════════════════════════════════════════════════════════════ -# GET /pools/instances/{instance_id}/stats — 连接池状态 -# ═══════════════════════════════════════════════════════════════ - - -class TestPoolStats: - - async def test_stats_not_initialized( - self, client: AsyncClient, admin_token, test_instance - ): - """连接池未初始化时返回 not_initialized。""" - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - manager = MagicMock() - manager.get_pool_stats.return_value = None - mock_pm.return_value = manager - - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["status"] == "not_initialized" - assert body["instance_id"] == test_instance.id - - async def test_stats_with_active_pool( - self, client: AsyncClient, admin_token, test_instance - ): - """已初始化的连接池返回统计数据。""" - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - stats = MagicMock() - stats.to_dict.return_value = { - "instance_id": test_instance.id, - "active": 3, - "idle": 2, - "max_size": 10, - } - manager = MagicMock() - manager.get_pool_stats.return_value = stats - mock_pm.return_value = manager - - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["active"] == 3 - assert body["idle"] == 2 - - async def test_stats_instance_not_found( - self, client: AsyncClient, admin_token - ): - """数据源不存在返回 404。""" - resp = await client.get( - "/api/v1/pools/instances/9999/stats", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - async def test_stats_dba_allowed( - self, client: AsyncClient, dba_token, test_instance - ): - """DBA 角色可以查看连接池状态。""" - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - stats = MagicMock() - stats.to_dict.return_value = {"active": 0, "idle": 0} - manager = MagicMock() - manager.get_pool_stats.return_value = stats - mock_pm.return_value = manager - - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - async def test_stats_developer_forbidden( - self, client: AsyncClient, developer_token, test_instance - ): - """developer 角色无权查看连接池状态。""" - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - async def test_stats_guest_forbidden( - self, client: AsyncClient, guest_token, test_instance - ): - """guest 角色无权查看连接池状态。""" - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - async def test_stats_unauthenticated( - self, client: AsyncClient, test_instance - ): - """未认证请求返回 401。""" - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - ) - assert resp.status_code == 401 - - async def test_stats_returns_not_initialized_message( - self, client: AsyncClient, admin_token, test_instance - ): - """未初始化时返回友好提示信息。""" - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - manager = MagicMock() - manager.get_pool_stats.return_value = None - mock_pm.return_value = manager - - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - headers=auth_header(admin_token), - ) - body = resp.json() - assert "message" in body - assert "首次查询" in body["message"] or "初始化" in body["message"] - - -# ═══════════════════════════════════════════════════════════════ -# POST /pools/instances/{instance_id}/reset — 重置连接池 -# ═══════════════════════════════════════════════════════════════ - - -class TestPoolReset: - - async def test_reset_success( - self, client: AsyncClient, admin_token, test_instance - ): - """管理员重置连接池成功。""" - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - manager = MagicMock() - manager.reset_pool = AsyncMock() - mock_pm.return_value = manager - - resp = await client.post( - f"/api/v1/pools/instances/{test_instance.id}/reset", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert "已重置" in body["message"] - assert body["instance_id"] == test_instance.id - - async def test_reset_dba_allowed( - self, client: AsyncClient, dba_token, test_instance - ): - """DBA 角色可以重置连接池。""" - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - manager = MagicMock() - manager.reset_pool = AsyncMock() - mock_pm.return_value = manager - - resp = await client.post( - f"/api/v1/pools/instances/{test_instance.id}/reset", - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - async def test_reset_developer_forbidden( - self, client: AsyncClient, developer_token, test_instance - ): - """developer 角色无权重置连接池。""" - resp = await client.post( - f"/api/v1/pools/instances/{test_instance.id}/reset", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - async def test_reset_guest_forbidden( - self, client: AsyncClient, guest_token, test_instance - ): - """guest 角色无权重置连接池。""" - resp = await client.post( - f"/api/v1/pools/instances/{test_instance.id}/reset", - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - async def test_reset_unauthenticated( - self, client: AsyncClient, test_instance - ): - """未认证请求返回 401。""" - resp = await client.post( - f"/api/v1/pools/instances/{test_instance.id}/reset", - ) - assert resp.status_code == 401 - - async def test_reset_calls_manager_reset_pool( - self, client: AsyncClient, admin_token, test_instance - ): - """验证 reset_pool 被正确调用。""" - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - manager = MagicMock() - manager.reset_pool = AsyncMock() - mock_pm.return_value = manager - - await client.post( - f"/api/v1/pools/instances/{test_instance.id}/reset", - headers=auth_header(admin_token), - ) - manager.reset_pool.assert_awaited_once_with(test_instance.id) - - -# ═══════════════════════════════════════════════════════════════ -# GET /pools/overview — 所有连接池概览 -# ═══════════════════════════════════════════════════════════════ - - -class TestPoolsOverview: - - async def test_overview_empty(self, client: AsyncClient, admin_token): - """无活跃连接池时返回空概览。""" - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - manager = MagicMock() - manager.get_all_stats.return_value = {} - mock_pm.return_value = manager - - resp = await client.get( - "/api/v1/pools/overview", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["total_pools"] == 0 - assert body["pools"] == [] - - async def test_overview_with_pools(self, client: AsyncClient, admin_token): - """有连接池时返回正确概览。""" - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - stats1 = MagicMock() - stats1.to_dict.return_value = {"instance_id": 1, "active": 2} - stats2 = MagicMock() - stats2.to_dict.return_value = {"instance_id": 2, "active": 1} - - manager = MagicMock() - manager.get_all_stats.return_value = {1: stats1, 2: stats2} - mock_pm.return_value = manager - - resp = await client.get( - "/api/v1/pools/overview", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["total_pools"] == 2 - assert len(body["pools"]) == 2 - - async def test_overview_dba_allowed(self, client: AsyncClient, dba_token): - """DBA 角色可以查看连接池概览。""" - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - manager = MagicMock() - manager.get_all_stats.return_value = {} - mock_pm.return_value = manager - - resp = await client.get( - "/api/v1/pools/overview", - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - async def test_overview_developer_forbidden( - self, client: AsyncClient, developer_token - ): - """developer 角色无权查看连接池概览。""" - resp = await client.get( - "/api/v1/pools/overview", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - async def test_overview_guest_forbidden( - self, client: AsyncClient, guest_token - ): - """guest 角色无权查看连接池概览。""" - resp = await client.get( - "/api/v1/pools/overview", - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - async def test_overview_unauthenticated(self, client: AsyncClient): - """未认证请求返回 401。""" - resp = await client.get("/api/v1/pools/overview") - assert resp.status_code == 401 - - async def test_overview_single_pool_data( - self, client: AsyncClient, admin_token - ): - """单个连接池数据验证。""" - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - stats = MagicMock() - stats.to_dict.return_value = { - "instance_id": 42, - "active": 5, - "idle": 3, - "max_size": 10, - } - manager = MagicMock() - manager.get_all_stats.return_value = {42: stats} - mock_pm.return_value = manager - - resp = await client.get( - "/api/v1/pools/overview", - headers=auth_header(admin_token), - ) - body = resp.json() - assert body["total_pools"] == 1 - assert body["pools"][0]["instance_id"] == 42 - assert body["pools"][0]["active"] == 5 diff --git a/backend/tests/test_pool_endpoint.py b/backend/tests/test_pool_endpoint.py deleted file mode 100644 index a674631..0000000 --- a/backend/tests/test_pool_endpoint.py +++ /dev/null @@ -1,186 +0,0 @@ -"""连接池管理 API 端点测试。""" -import pytest -from unittest.mock import patch, MagicMock, AsyncMock -from httpx import AsyncClient - -from app.models.user import User -from app.models.instance import Instance -from app.core.security import hash_password, create_access_token - - -def auth_header(token: str) -> dict: - return {"Authorization": f"Bearer {token}"} - - -@pytest.mark.asyncio -class TestPoolStats: - """GET /api/v1/pools/instances/{instance_id}/stats""" - - async def test_pool_stats_initialized( - self, client: AsyncClient, admin_token: str, test_instance - ): - mock_stats = MagicMock() - mock_stats.to_dict.return_value = { - "instance_id": test_instance.id, - "status": "active", - "total_connections": 5, - "active_connections": 2, - "idle_connections": 3, - } - with patch( - "app.api.v1.endpoints.pool.get_pool_manager" - ) as mock_mgr: - mock_mgr.return_value.get_pool_stats.return_value = mock_stats - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["status"] == "active" - assert data["total_connections"] == 5 - - async def test_pool_stats_not_initialized( - self, client: AsyncClient, admin_token: str, test_instance - ): - with patch( - "app.api.v1.endpoints.pool.get_pool_manager" - ) as mock_mgr: - mock_mgr.return_value.get_pool_stats.return_value = None - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["status"] == "not_initialized" - - async def test_pool_stats_instance_not_found( - self, client: AsyncClient, admin_token: str - ): - resp = await client.get( - "/api/v1/pools/instances/9999/stats", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - async def test_pool_stats_forbidden_developer( - self, client: AsyncClient, developer_token: str, test_instance - ): - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - async def test_pool_stats_dba_allowed( - self, client: AsyncClient, dba_token: str, test_instance - ): - with patch( - "app.api.v1.endpoints.pool.get_pool_manager" - ) as mock_mgr: - mock_mgr.return_value.get_pool_stats.return_value = None - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - -@pytest.mark.asyncio -class TestResetPool: - """POST /api/v1/pools/instances/{instance_id}/reset""" - - async def test_reset_pool_success( - self, client: AsyncClient, admin_token: str, test_instance - ): - with patch( - "app.api.v1.endpoints.pool.get_pool_manager" - ) as mock_mgr: - mock_mgr.return_value.reset_pool = AsyncMock() - resp = await client.post( - f"/api/v1/pools/instances/{test_instance.id}/reset", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert "已重置" in data["message"] - assert data["instance_id"] == test_instance.id - - async def test_reset_pool_dba_allowed( - self, client: AsyncClient, dba_token: str, test_instance - ): - with patch( - "app.api.v1.endpoints.pool.get_pool_manager" - ) as mock_mgr: - mock_mgr.return_value.reset_pool = AsyncMock() - resp = await client.post( - f"/api/v1/pools/instances/{test_instance.id}/reset", - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - async def test_reset_pool_forbidden_developer( - self, client: AsyncClient, developer_token: str, test_instance - ): - resp = await client.post( - f"/api/v1/pools/instances/{test_instance.id}/reset", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -class TestPoolsOverview: - """GET /api/v1/pools/overview""" - - async def test_pools_overview_empty( - self, client: AsyncClient, admin_token: str - ): - with patch( - "app.api.v1.endpoints.pool.get_pool_manager" - ) as mock_mgr: - mock_mgr.return_value.get_all_stats.return_value = {} - resp = await client.get( - "/api/v1/pools/overview", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["total_pools"] == 0 - assert data["pools"] == [] - - async def test_pools_overview_with_pools( - self, client: AsyncClient, admin_token: str - ): - mock_stats = MagicMock() - mock_stats.to_dict.return_value = { - "instance_id": 1, - "status": "active", - "total_connections": 10, - } - with patch( - "app.api.v1.endpoints.pool.get_pool_manager" - ) as mock_mgr: - mock_mgr.return_value.get_all_stats.return_value = {1: mock_stats} - resp = await client.get( - "/api/v1/pools/overview", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["total_pools"] == 1 - assert len(data["pools"]) == 1 - - async def test_pools_overview_unauthorized(self, client: AsyncClient): - resp = await client.get("/api/v1/pools/overview") - assert resp.status_code == 401 - - async def test_pools_overview_forbidden_guest( - self, client: AsyncClient, guest_token: str - ): - resp = await client.get( - "/api/v1/pools/overview", - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 diff --git a/backend/tests/test_query.py b/backend/tests/test_query.py deleted file mode 100644 index 10c281d..0000000 --- a/backend/tests/test_query.py +++ /dev/null @@ -1,532 +0,0 @@ -import sys -from contextlib import asynccontextmanager -from unittest.mock import patch, AsyncMock, MagicMock - -import pytest -from httpx import AsyncClient - -from app.core.crypto import encrypt_password -from app.models.instance_group import InstanceGroup, user_groups -from tests.conftest import auth_header - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _make_aiomysql_mock(): - aiomysql = MagicMock() - aiomysql.connect = AsyncMock() - aiomysql.DictCursor = MagicMock() - return aiomysql - - -def _build_mock_query_result(): - """Return a mock UnifiedQueryResult for SELECT queries.""" - from app.schemas.query import UnifiedQueryResult - return UnifiedQueryResult( - columns=["id", "name"], - rows=[{"id": 1, "name": "test"}], - total=1, - elapsed_ms=10, - truncated=False, - db_type="mysql", - query_type="SELECT", - ) - - -async def _create_grouped_instance(db, group_id, **overrides): - """Create an instance bound to the given group.""" - from app.models.instance import Instance - defaults = dict( - name="GroupedMySQL", - db_type="mysql", - host="127.0.0.1", - port=3306, - username="root", - password=encrypt_password("test123"), - database="test_db", - env="development", - status="disconnected", - group_id=group_id, - ) - defaults.update(overrides) - inst = Instance(**defaults) - db.add(inst) - await db.commit() - await db.refresh(inst) - return inst - - -# =================================================================== -# 1. POST /query — execute_query -# =================================================================== - -class TestExecuteQuery: - """Tests for POST /api/v1/query""" - - # ---- happy path ------------------------------------------------ - - async def test_select_query_success( - self, client: AsyncClient, test_instance, admin_token - ): - mock_result = _build_mock_query_result() - - with patch( - "app.api.v1.endpoints.query.QueryService.execute_query", - new_callable=AsyncMock, - return_value=mock_result, - ): - resp = await client.post( - "/api/v1/query", - headers=auth_header(admin_token), - json={ - "instance_id": test_instance.id, - "database": "test_db", - "sql": "SELECT * FROM users LIMIT 10", - }, - ) - assert resp.status_code == 200 - data = resp.json() - assert "columns" in data - assert "rows" in data - assert data["total"] == 1 - assert data["query_type"] == "SELECT" - - # ---- forbidden SQL (DDL / DML) --------------------------------- - - async def test_drop_rejected( - self, client: AsyncClient, test_instance, admin_token - ): - """DDL like DROP should be rejected by the query service (ValueError → 400).""" - with patch( - "app.api.v1.endpoints.query.QueryService.execute_query", - new_callable=AsyncMock, - side_effect=ValueError("不允许执行非SELECT语句"), - ): - resp = await client.post( - "/api/v1/query", - headers=auth_header(admin_token), - json={ - "instance_id": test_instance.id, - "database": "test_db", - "sql": "DROP TABLE users", - }, - ) - assert resp.status_code == 400 - - async def test_delete_rejected( - self, client: AsyncClient, test_instance, admin_token - ): - with patch( - "app.api.v1.endpoints.query.QueryService.execute_query", - new_callable=AsyncMock, - side_effect=ValueError("不允许执行非SELECT语句"), - ): - resp = await client.post( - "/api/v1/query", - headers=auth_header(admin_token), - json={ - "instance_id": test_instance.id, - "database": "test_db", - "sql": "DELETE FROM users WHERE 1=1", - }, - ) - assert resp.status_code == 400 - - # ---- guest blocked --------------------------------------------- - - async def test_guest_cannot_query( - self, client: AsyncClient, test_instance, guest_token - ): - resp = await client.post( - "/api/v1/query", - headers=auth_header(guest_token), - json={ - "instance_id": test_instance.id, - "database": "test_db", - "sql": "SELECT 1", - }, - ) - assert resp.status_code == 403 - - # ---- instance not found ---------------------------------------- - - async def test_nonexistent_instance(self, client: AsyncClient, admin_token): - resp = await client.post( - "/api/v1/query", - headers=auth_header(admin_token), - json={ - "instance_id": 99999, - "database": "test_db", - "sql": "SELECT 1", - }, - ) - assert resp.status_code == 404 - - # ---- missing sql parameter (validation) ------------------------ - - async def test_missing_sql_parameter( - self, client: AsyncClient, test_instance, admin_token - ): - resp = await client.post( - "/api/v1/query", - headers=auth_header(admin_token), - json={ - "instance_id": test_instance.id, - "database": "test_db", - # no "sql" field - }, - ) - assert resp.status_code == 422 # Pydantic validation error - - # ---- empty sql string ------------------------------------------ - - async def test_empty_sql_string( - self, client: AsyncClient, test_instance, admin_token - ): - resp = await client.post( - "/api/v1/query", - headers=auth_header(admin_token), - json={ - "instance_id": test_instance.id, - "database": "test_db", - "sql": "", - }, - ) - assert resp.status_code == 422 - - # ---- internal server error from query service ------------------ - - async def test_query_service_exception( - self, client: AsyncClient, test_instance, admin_token - ): - with patch( - "app.api.v1.endpoints.query.QueryService.execute_query", - new_callable=AsyncMock, - side_effect=RuntimeError("connection lost"), - ): - resp = await client.post( - "/api/v1/query", - headers=auth_header(admin_token), - json={ - "instance_id": test_instance.id, - "database": "test_db", - "sql": "SELECT 1", - }, - ) - assert resp.status_code == 500 - - # ---- mask=false query param ------------------------------------ - - async def test_select_query_no_mask( - self, client: AsyncClient, test_instance, admin_token - ): - mock_result = _build_mock_query_result() - - with patch( - "app.api.v1.endpoints.query.QueryService.execute_query", - new_callable=AsyncMock, - return_value=mock_result, - ): - resp = await client.post( - "/api/v1/query?mask=false", - headers=auth_header(admin_token), - json={ - "instance_id": test_instance.id, - "database": "test_db", - "sql": "SELECT * FROM users LIMIT 10", - }, - ) - assert resp.status_code == 200 - - # ---- save_history=false query param ---------------------------- - - async def test_select_query_no_history( - self, client: AsyncClient, test_instance, admin_token - ): - mock_result = _build_mock_query_result() - - with patch( - "app.api.v1.endpoints.query.QueryService.execute_query", - new_callable=AsyncMock, - return_value=mock_result, - ), patch( - "app.api.v1.endpoints.query.QueryHistoryService.record_query", - new_callable=AsyncMock, - ) as mock_hist: - resp = await client.post( - "/api/v1/query?save_history=false", - headers=auth_header(admin_token), - json={ - "instance_id": test_instance.id, - "database": "test_db", - "sql": "SELECT 1", - }, - ) - assert resp.status_code == 200 - # history should NOT be recorded - mock_hist.assert_not_called() - - # ---- unauthenticated request ----------------------------------- - - async def test_unauthenticated_request(self, client: AsyncClient, test_instance): - resp = await client.post( - "/api/v1/query", - json={ - "instance_id": test_instance.id, - "database": "test_db", - "sql": "SELECT 1", - }, - ) - assert resp.status_code == 401 - - # ---- developer can query public instance ----------------------- - - async def test_developer_can_query_public_instance( - self, client: AsyncClient, test_instance, developer_token - ): - """Public instance (no group_id) is accessible to developer.""" - mock_result = _build_mock_query_result() - - with patch( - "app.api.v1.endpoints.query.QueryService.execute_query", - new_callable=AsyncMock, - return_value=mock_result, - ): - resp = await client.post( - "/api/v1/query", - headers=auth_header(developer_token), - json={ - "instance_id": test_instance.id, - "database": "test_db", - "sql": "SELECT 1", - }, - ) - assert resp.status_code == 200 - - # ---- developer blocked from grouped instance without membership -- - - async def test_developer_blocked_from_grouped_instance( - self, client: AsyncClient, db, developer_token - ): - """Instance in a group where developer is not a member → 403.""" - group = InstanceGroup(name="private-group", db_type="mysql") - db.add(group) - await db.commit() - await db.refresh(group) - - inst = await _create_grouped_instance(db, group.id) - - resp = await client.post( - "/api/v1/query", - headers=auth_header(developer_token), - json={ - "instance_id": inst.id, - "database": "test_db", - "sql": "SELECT 1", - }, - ) - assert resp.status_code == 403 - - # ---- developer with group membership can query ----------------- - - async def test_developer_with_group_membership_can_query( - self, client: AsyncClient, db, developer_user, developer_token - ): - """Developer belongs to the instance group → 200.""" - group = InstanceGroup( - name="dev-group", - db_type="mysql", - permissions='["query:select"]', - ) - db.add(group) - await db.commit() - await db.refresh(group) - - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, - group_id=group.id, - permissions='["query:select"]', - ) - ) - await db.commit() - - inst = await _create_grouped_instance(db, group.id) - - mock_result = _build_mock_query_result() - with patch( - "app.api.v1.endpoints.query.QueryService.execute_query", - new_callable=AsyncMock, - return_value=mock_result, - ): - resp = await client.post( - "/api/v1/query", - headers=auth_header(developer_token), - json={ - "instance_id": inst.id, - "database": "test_db", - "sql": "SELECT 1", - }, - ) - assert resp.status_code == 200 - - # ---- permission check: missing query:select → 403 -------------- - - async def test_developer_without_query_select_permission( - self, client: AsyncClient, db, developer_user, developer_token - ): - """Developer in group but without query:select permission → 403.""" - group = InstanceGroup( - name="no-query-group", - db_type="mysql", - permissions='["slowlog:view"]', # no query:select - ) - db.add(group) - await db.commit() - await db.refresh(group) - - await db.execute( - user_groups.insert().values( - user_id=developer_user.id, - group_id=group.id, - permissions='["slowlog:view"]', - ) - ) - await db.commit() - - inst = await _create_grouped_instance(db, group.id) - - resp = await client.post( - "/api/v1/query", - headers=auth_header(developer_token), - json={ - "instance_id": inst.id, - "database": "test_db", - "sql": "SELECT 1", - }, - ) - assert resp.status_code == 403 - - # ---- DBA can always query (admin bypass) ----------------------- - - async def test_dba_can_query_any_instance( - self, client: AsyncClient, db, dba_token - ): - """DBA is not admin, but can query a public instance.""" - from app.models.instance import Instance - - inst = Instance( - name="dba-test", - db_type="mysql", - host="127.0.0.1", - port=3306, - username="root", - password=encrypt_password("pw"), - database="test_db", - env="development", - status="disconnected", - ) - db.add(inst) - await db.commit() - await db.refresh(inst) - - mock_result = _build_mock_query_result() - with patch( - "app.api.v1.endpoints.query.QueryService.execute_query", - new_callable=AsyncMock, - return_value=mock_result, - ): - resp = await client.post( - "/api/v1/query", - headers=auth_header(dba_token), - json={ - "instance_id": inst.id, - "database": "test_db", - "sql": "SELECT 1", - }, - ) - assert resp.status_code == 200 - - -# =================================================================== -# 2. GET /query/supported-types -# =================================================================== - -class TestSupportedTypes: - """Tests for GET /api/v1/query/supported-types""" - - async def test_supported_types_returns_200( - self, client: AsyncClient, admin_token - ): - resp = await client.get( - "/api/v1/query/supported-types", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert "types" in data - types = data["types"] - assert len(types) >= 4 - type_names = [t["type"] for t in types] - assert "mysql" in type_names - assert "postgres" in type_names - assert "redis" in type_names - assert "mongo" in type_names - - async def test_supported_types_each_entry_has_required_fields( - self, client: AsyncClient, admin_token - ): - resp = await client.get( - "/api/v1/query/supported-types", - headers=auth_header(admin_token), - ) - data = resp.json() - for entry in data["types"]: - assert "type" in entry - assert "name" in entry - assert "query_format" in entry - assert "example" in entry - assert "security" in entry - assert "features" in entry - - async def test_supported_types_unauthenticated( - self, client: AsyncClient - ): - resp = await client.get("/api/v1/query/supported-types") - assert resp.status_code == 401 - - async def test_supported_types_developer( - self, client: AsyncClient, developer_token - ): - """Non-admin authenticated user can also view supported types.""" - resp = await client.get( - "/api/v1/query/supported-types", - headers=auth_header(developer_token), - ) - assert resp.status_code == 200 - - async def test_supported_types_redis_has_readonly_commands( - self, client: AsyncClient, admin_token - ): - resp = await client.get( - "/api/v1/query/supported-types", - headers=auth_header(admin_token), - ) - data = resp.json() - redis_entry = next(t for t in data["types"] if t["type"] == "redis") - assert "readonly_commands" in redis_entry - assert "GET" in redis_entry["readonly_commands"] - - async def test_supported_types_mongo_has_readonly_operations( - self, client: AsyncClient, admin_token - ): - resp = await client.get( - "/api/v1/query/supported-types", - headers=auth_header(admin_token), - ) - data = resp.json() - mongo_entry = next(t for t in data["types"] if t["type"] == "mongo") - assert "readonly_operations" in mongo_entry - assert "find" in mongo_entry["readonly_operations"] diff --git a/backend/tests/test_query_history_service.py b/backend/tests/test_query_history_service.py deleted file mode 100644 index 408f5f3..0000000 --- a/backend/tests/test_query_history_service.py +++ /dev/null @@ -1,768 +0,0 @@ -"""查询历史和快捷查询 持久化测试 — 验证 SQLite 存储。 - -测试范围: -- 查询历史记录和查看(CRUD + 分页 + 过滤) -- 收藏切换 -- 快捷查询 CRUD -- 查询统计 -- 权限控制 -- 预置模板 -""" -import pytest -import pytest_asyncio -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.security import create_access_token, hash_password -from app.models.user import User -from app.models.query_history import QueryHistory, QuickQueryModel -from app.services.query_history_service import QueryHistoryService - - -@pytest_asyncio.fixture -async def admin_headers(admin_token: str) -> dict: - return {"Authorization": f"Bearer {admin_token}"} - - -@pytest_asyncio.fixture -async def developer_headers(developer_token: str) -> dict: - return {"Authorization": f"Bearer {developer_token}"} - - -@pytest_asyncio.fixture -async def guest_headers(guest_token: str) -> dict: - return {"Authorization": f"Bearer {guest_token}"} - - -# ── 查询历史记录 ── - - -class TestQueryHistoryRecord: - """查询历史记录功能测试。""" - - @pytest.mark.asyncio - async def test_record_query(self, db: AsyncSession): - """测试记录一条查询。""" - entry = await QueryHistoryService.record_query( - db=db, - user_id=1, - username="admin", - instance_id=1, - instance_name="测试MySQL", - db_type="mysql", - database_name="test_db", - query_content="SELECT * FROM users LIMIT 10", - query_type="SELECT", - execution_time_ms=50, - result_rows=10, - ) - await db.commit() - assert entry.id > 0 - assert entry.user_id == 1 - assert entry.db_type == "mysql" - assert entry.query_type == "SELECT" - assert entry.execution_time_ms == 50 - assert entry.result_rows == 10 - assert entry.is_success is True - assert entry.error_message is None - - @pytest.mark.asyncio - async def test_record_failed_query(self, db: AsyncSession): - """测试记录失败的查询。""" - entry = await QueryHistoryService.record_query( - db=db, - user_id=1, - username="admin", - instance_id=1, - instance_name="测试MySQL", - db_type="mysql", - database_name="test_db", - query_content="SELECT * FROM nonexistent", - query_type="SELECT", - execution_time_ms=10, - result_rows=0, - is_success=False, - error_message="Table doesn't exist", - ) - await db.commit() - assert entry.is_success is False - assert entry.error_message == "Table doesn't exist" - - @pytest.mark.asyncio - async def test_record_multiple_db_types(self, db: AsyncSession): - """测试记录不同数据库类型的查询。""" - for db_type, query, q_type in [ - ("mysql", "SELECT 1", "SELECT"), - ("postgres", "SELECT version()", "SELECT"), - ("redis", "GET mykey", "GET"), - ("mongo", '{"collection": "users", "operation": "find"}', "find"), - ]: - entry = await QueryHistoryService.record_query( - db=db, - user_id=1, - username="admin", - instance_id=1, - instance_name="测试", - db_type=db_type, - database_name="test", - query_content=query, - query_type=q_type, - execution_time_ms=10, - result_rows=1, - ) - await db.commit() - assert entry.db_type == db_type - assert entry.query_type == q_type - - @pytest.mark.asyncio - async def test_record_query_long_content_truncated(self, db: AsyncSession): - """测试超长查询内容被截断。""" - long_sql = "SELECT * FROM t WHERE id = " + "1" * 3000 - entry = await QueryHistoryService.record_query( - db=db, - user_id=1, - username="admin", - instance_id=1, - instance_name="测试", - db_type="mysql", - database_name="test", - query_content=long_sql, - query_type="SELECT", - execution_time_ms=10, - result_rows=0, - ) - await db.commit() - assert len(entry.query_content) == 2000 - - @pytest.mark.asyncio - async def test_record_query_max_history_per_user(self, db: AsyncSession): - """测试每个用户最多保留500条记录。""" - # 创建超过限制的记录 - for i in range(510): - await QueryHistoryService.record_query( - db=db, - user_id=99, - username="test_user", - instance_id=1, - instance_name="测试", - db_type="mysql", - database_name="test", - query_content=f"SELECT {i}", - query_type="SELECT", - execution_time_ms=i, - result_rows=0, - ) - await db.commit() - - from sqlalchemy import select, func - count_stmt = select(func.count()).select_from(QueryHistory).where( - QueryHistory.user_id == 99 - ) - total = (await db.execute(count_stmt)).scalar() - assert total <= 500 - - -# ── 查询历史查看 ── - - -class TestQueryHistoryView: - """查询历史查看功能测试。""" - - async def _setup_history(self, db: AsyncSession): - """准备测试数据。""" - entries = [ - ("SELECT * FROM users", "mysql", 1, 50, 10), - ("SELECT * FROM orders", "mysql", 1, 30, 20), - ("GET mykey", "redis", 2, 5, 1), - ("SELECT version()", "postgres", 3, 15, 1), - ] - for sql, db_type, inst_id, time_ms, rows in entries: - await QueryHistoryService.record_query( - db=db, - user_id=1, - username="admin", - instance_id=inst_id, - instance_name=f"测试{db_type}", - db_type=db_type, - database_name="test", - query_content=sql, - query_type="SELECT" if db_type != "redis" else "GET", - execution_time_ms=time_ms, - result_rows=rows, - ) - await db.commit() - - @pytest.mark.asyncio - async def test_get_history_default(self, db: AsyncSession): - """测试默认查看历史。""" - await self._setup_history(db) - result = await QueryHistoryService.get_history(db=db, user_id=1) - assert result["total"] == 4 - assert result["page"] == 1 - assert result["page_size"] == 20 - assert len(result["items"]) == 4 - - @pytest.mark.asyncio - async def test_get_history_filter_by_db_type(self, db: AsyncSession): - """测试按数据库类型筛选。""" - await self._setup_history(db) - result = await QueryHistoryService.get_history(db=db, user_id=1, db_type="mysql") - assert result["total"] == 2 - for item in result["items"]: - assert item["db_type"] == "mysql" - - @pytest.mark.asyncio - async def test_get_history_filter_by_instance(self, db: AsyncSession): - """测试按实例ID筛选。""" - await self._setup_history(db) - result = await QueryHistoryService.get_history(db=db, user_id=1, instance_id=2) - assert result["total"] == 1 - assert result["items"][0]["db_type"] == "redis" - - @pytest.mark.asyncio - async def test_get_history_filter_by_keyword(self, db: AsyncSession): - """测试按关键词筛选。""" - await self._setup_history(db) - result = await QueryHistoryService.get_history(db=db, user_id=1, keyword="users") - assert result["total"] == 1 - assert "users" in result["items"][0]["query_content"].lower() - - @pytest.mark.asyncio - async def test_get_history_pagination(self, db: AsyncSession): - """测试分页。""" - await self._setup_history(db) - result = await QueryHistoryService.get_history(db=db, user_id=1, page=1, page_size=2) - assert result["total"] == 4 - assert len(result["items"]) == 2 - - result2 = await QueryHistoryService.get_history(db=db, user_id=1, page=2, page_size=2) - assert result2["total"] == 4 - assert len(result2["items"]) == 2 - - @pytest.mark.asyncio - async def test_get_history_other_user_empty(self, db: AsyncSession): - """测试查看其他用户的历史为空。""" - await self._setup_history(db) - result = await QueryHistoryService.get_history(db=db, user_id=999) - assert result["total"] == 0 - assert result["items"] == [] - - @pytest.mark.asyncio - async def test_history_sorted_by_time_desc(self, db: AsyncSession): - """测试历史按时间倒序排列。""" - await self._setup_history(db) - result = await QueryHistoryService.get_history(db=db, user_id=1) - times = [item["created_at"] for item in result["items"]] - assert times == sorted(times, reverse=True) - - -# ── 收藏功能 ── - - -class TestQueryHistoryFavorite: - """查询收藏功能测试。""" - - async def _setup_for_favorite(self, db: AsyncSession): - entry = await QueryHistoryService.record_query( - db=db, - user_id=1, username="admin", instance_id=1, - instance_name="测试", db_type="mysql", database_name="test", - query_content="SELECT 1", query_type="SELECT", - execution_time_ms=10, result_rows=1, - ) - await db.commit() - return entry.id - - @pytest.mark.asyncio - async def test_toggle_favorite_add(self, db: AsyncSession): - """测试添加收藏。""" - entry_id = await self._setup_for_favorite(db) - is_fav = await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id) - await db.commit() - assert is_fav is True - - @pytest.mark.asyncio - async def test_toggle_favorite_remove(self, db: AsyncSession): - """测试取消收藏。""" - entry_id = await self._setup_for_favorite(db) - await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id) - await db.commit() - is_fav = await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id) - await db.commit() - assert is_fav is False - - @pytest.mark.asyncio - async def test_get_favorites(self, db: AsyncSession): - """测试获取收藏列表。""" - entry_id = await self._setup_for_favorite(db) - await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id) - await db.commit() - - result = await QueryHistoryService.get_favorites(db=db, user_id=1) - assert result["total"] == 1 - assert result["items"][0]["id"] == entry_id - - @pytest.mark.asyncio - async def test_get_favorites_empty(self, db: AsyncSession): - """测试无收藏时返回空列表。""" - result = await QueryHistoryService.get_favorites(db=db, user_id=1) - assert result["total"] == 0 - assert result["items"] == [] - - @pytest.mark.asyncio - async def test_favorites_per_user(self, db: AsyncSession): - """测试收藏是用户隔离的。""" - entry_id = await self._setup_for_favorite(db) - await QueryHistoryService.toggle_favorite(db=db, user_id=1, history_id=entry_id) - await db.commit() - - result = await QueryHistoryService.get_favorites(db=db, user_id=999) - assert result["total"] == 0 - - -# ── 快捷查询 ── - - -class TestQuickQueries: - """快捷查询功能测试。""" - - @pytest.mark.asyncio - async def test_create_quick_query(self, db: AsyncSession): - """测试创建快捷查询。""" - qq = await QueryHistoryService.create_quick_query( - db=db, - name="查看活跃用户", - description="查看最近活跃的用户列表", - query_content="SELECT * FROM users WHERE last_login > DATE_SUB(NOW(), INTERVAL 7 DAY)", - db_type="mysql", - database_pattern="*", - is_shared=False, - created_by=1, - created_by_name="admin", - ) - await db.commit() - assert qq.id > 0 - assert qq.name == "查看活跃用户" - assert qq.is_shared is False - assert qq.created_by == 1 - - @pytest.mark.asyncio - async def test_get_quick_queries_personal(self, db: AsyncSession): - """测试获取个人快捷查询。""" - await QueryHistoryService.create_quick_query( - db=db, name="个人查询", description="", query_content="SELECT 1", - db_type="mysql", database_pattern="*", - is_shared=False, created_by=1, created_by_name="admin", - ) - await QueryHistoryService.create_quick_query( - db=db, name="他人查询", description="", query_content="SELECT 2", - db_type="mysql", database_pattern="*", - is_shared=False, created_by=2, created_by_name="dba", - ) - await db.commit() - - # 用户1只能看到自己的 + 共享的 - items = await QueryHistoryService.get_quick_queries(db=db, user_id=1) - assert len(items) == 1 - assert items[0]["name"] == "个人查询" - - @pytest.mark.asyncio - async def test_get_quick_queries_shared(self, db: AsyncSession): - """测试获取共享快捷查询。""" - await QueryHistoryService.create_quick_query( - db=db, name="共享查询", description="", query_content="SELECT 1", - db_type="mysql", database_pattern="*", - is_shared=True, created_by=2, created_by_name="dba", - ) - await db.commit() - - items = await QueryHistoryService.get_quick_queries(db=db, user_id=1) - assert len(items) == 1 - assert items[0]["is_shared"] is True - - @pytest.mark.asyncio - async def test_get_quick_queries_filter_db_type(self, db: AsyncSession): - """测试按数据库类型筛选快捷查询。""" - await QueryHistoryService.create_quick_query( - db=db, name="MySQL查询", description="", query_content="SELECT 1", - db_type="mysql", database_pattern="*", - is_shared=True, created_by=1, created_by_name="admin", - ) - await QueryHistoryService.create_quick_query( - db=db, name="Redis查询", description="", query_content="GET key", - db_type="redis", database_pattern="*", - is_shared=True, created_by=1, created_by_name="admin", - ) - await db.commit() - - items = await QueryHistoryService.get_quick_queries(db=db, user_id=1, db_type="redis") - assert len(items) == 1 - assert items[0]["db_type"] == "redis" - - @pytest.mark.asyncio - async def test_get_quick_queries_filter_keyword(self, db: AsyncSession): - """测试按关键词筛选快捷查询。""" - await QueryHistoryService.create_quick_query( - db=db, name="查看活跃用户", description="", query_content="SELECT 1", - db_type="mysql", database_pattern="*", - is_shared=True, created_by=1, created_by_name="admin", - ) - await QueryHistoryService.create_quick_query( - db=db, name="查看慢查询", description="", query_content="SELECT 2", - db_type="mysql", database_pattern="*", - is_shared=True, created_by=1, created_by_name="admin", - ) - await db.commit() - - items = await QueryHistoryService.get_quick_queries(db=db, user_id=1, keyword="活跃") - assert len(items) == 1 - assert "活跃" in items[0]["name"] - - @pytest.mark.asyncio - async def test_use_quick_query(self, db: AsyncSession): - """测试使用快捷查询。""" - qq = await QueryHistoryService.create_quick_query( - db=db, name="测试", description="", query_content="SELECT 1", - db_type="mysql", database_pattern="*", - is_shared=True, created_by=1, created_by_name="admin", - ) - await db.commit() - - content = await QueryHistoryService.use_quick_query(db, qq.id) - await db.commit() - assert content == "SELECT 1" - - @pytest.mark.asyncio - async def test_use_quick_query_increments_count(self, db: AsyncSession): - """测试使用快捷查询增加使用次数。""" - qq = await QueryHistoryService.create_quick_query( - db=db, name="测试", description="", query_content="SELECT 1", - db_type="mysql", database_pattern="*", - is_shared=True, created_by=1, created_by_name="admin", - ) - await db.commit() - assert qq.usage_count == 0 - - await QueryHistoryService.use_quick_query(db, qq.id) - await db.commit() - assert qq.usage_count == 1 - - await QueryHistoryService.use_quick_query(db, qq.id) - await db.commit() - assert qq.usage_count == 2 - - @pytest.mark.asyncio - async def test_use_quick_query_not_found(self, db: AsyncSession): - """测试使用不存在的快捷查询。""" - content = await QueryHistoryService.use_quick_query(db, 99999) - assert content is None - - -# ── 预置模板 ── - - -class TestDefaultQuickQueries: - """预置快捷查询模板测试。""" - - @pytest.mark.asyncio - async def test_default_templates_exist(self, db: AsyncSession): - """测试预置模板存在。""" - await QueryHistoryService.seed_default_quick_queries(db) - await db.commit() - - items = await QueryHistoryService.get_quick_queries(db=db, user_id=1) - # 预置了10个模板 - assert len(items) == 10 - - @pytest.mark.asyncio - async def test_default_templates_cover_db_types(self, db: AsyncSession): - """测试预置模板覆盖所有数据库类型。""" - await QueryHistoryService.seed_default_quick_queries(db) - await db.commit() - - items = await QueryHistoryService.get_quick_queries(db=db, user_id=1) - db_types = {item["db_type"] for item in items} - assert "mysql" in db_types - assert "redis" in db_types - assert "postgres" in db_types - assert "mongo" in db_types - - @pytest.mark.asyncio - async def test_default_templates_all_shared(self, db: AsyncSession): - """测试预置模板都是共享的。""" - await QueryHistoryService.seed_default_quick_queries(db) - await db.commit() - - items = await QueryHistoryService.get_quick_queries(db=db, user_id=1) - for item in items: - assert item["is_shared"] is True - - @pytest.mark.asyncio - async def test_default_templates_system_created(self, db: AsyncSession): - """测试预置模板由系统创建。""" - await QueryHistoryService.seed_default_quick_queries(db) - await db.commit() - - items = await QueryHistoryService.get_quick_queries(db=db, user_id=1) - for item in items: - assert item["created_by_name"] == "系统" - - @pytest.mark.asyncio - async def test_seed_idempotent(self, db: AsyncSession): - """测试 seed 是幂等的(多次调用不会重复插入)。""" - await QueryHistoryService.seed_default_quick_queries(db) - await db.commit() - await QueryHistoryService.seed_default_quick_queries(db) - await db.commit() - - items = await QueryHistoryService.get_quick_queries(db=db, user_id=1) - assert len(items) == 10 - - -# ── 查询统计 ── - - -class TestQueryStatistics: - """查询统计功能测试。""" - - async def _setup_stats(self, db: AsyncSession): - for i in range(5): - await QueryHistoryService.record_query( - db=db, user_id=1, username="admin", instance_id=1, - instance_name="测试", db_type="mysql", database_name="test", - query_content=f"SELECT * FROM table_{i} WHERE id = 1", - query_type="SELECT", - execution_time_ms=10 + i * 10, - result_rows=i + 1, - ) - for i in range(3): - await QueryHistoryService.record_query( - db=db, user_id=1, username="admin", instance_id=2, - instance_name="测试Redis", db_type="redis", database_name="db0", - query_content="GET mykey", - query_type="GET", - execution_time_ms=5, - result_rows=1, - ) - await db.commit() - - @pytest.mark.asyncio - async def test_statistics_basic(self, db: AsyncSession): - """测试基本统计。""" - await self._setup_stats(db) - stats = await QueryHistoryService.get_query_statistics(db=db, user_id=1) - - assert stats["total_queries"] == 8 - assert stats["avg_execution_time_ms"] > 0 - assert stats["success_rate"] == 100.0 - - @pytest.mark.asyncio - async def test_statistics_db_type_distribution(self, db: AsyncSession): - """测试数据库类型分布统计。""" - await self._setup_stats(db) - stats = await QueryHistoryService.get_query_statistics(db=db, user_id=1) - - assert "mysql" in stats["db_type_distribution"] - assert "redis" in stats["db_type_distribution"] - assert stats["db_type_distribution"]["mysql"] == 5 - assert stats["db_type_distribution"]["redis"] == 3 - - @pytest.mark.asyncio - async def test_statistics_daily_trend(self, db: AsyncSession): - """测试每日趋势。""" - await self._setup_stats(db) - stats = await QueryHistoryService.get_query_statistics(db=db, user_id=1) - - assert "daily_trend" in stats - assert len(stats["daily_trend"]) == 7 - - @pytest.mark.asyncio - async def test_statistics_empty(self, db: AsyncSession): - """测试无记录时返回提示。""" - stats = await QueryHistoryService.get_query_statistics(db=db, user_id=999) - assert "message" in stats - assert stats["message"] == "暂无查询记录" - - @pytest.mark.asyncio - async def test_statistics_top_tables(self, db: AsyncSession): - """测试高频表统计。""" - await self._setup_stats(db) - stats = await QueryHistoryService.get_query_statistics(db=db, user_id=1) - - assert "top_tables" in stats - # 应该能从SQL中提取表名 - assert len(stats["top_tables"]) > 0 - - -# ── API端点集成测试 ── - - -class TestQueryHistoryAPI: - """查询历史 API 端点集成测试。""" - - @pytest.mark.asyncio - async def test_my_history_unauthorized(self, client): - """测试未认证访问历史。""" - resp = await client.get("/api/v1/query-history/my") - assert resp.status_code == 401 - - @pytest.mark.asyncio - async def test_my_history_empty(self, client, admin_headers, db): - """测试空历史记录。""" - resp = await client.get("/api/v1/query-history/my", headers=admin_headers) - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 0 - assert data["items"] == [] - - @pytest.mark.asyncio - async def test_my_history_with_data(self, client, admin_headers, db, admin_user): - """测试有数据的历史记录。""" - await QueryHistoryService.record_query( - db=db, user_id=admin_user.id, username="admin", instance_id=1, - instance_name="测试", db_type="mysql", database_name="test", - query_content="SELECT 1", query_type="SELECT", - execution_time_ms=10, result_rows=1, - ) - await db.commit() - - resp = await client.get("/api/v1/query-history/my", headers=admin_headers) - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 1 - - @pytest.mark.asyncio - async def test_my_history_filter_db_type(self, client, admin_headers, db, admin_user): - """测试按类型筛选。""" - await QueryHistoryService.record_query( - db=db, user_id=admin_user.id, username="admin", instance_id=1, - instance_name="测试", db_type="mysql", database_name="test", - query_content="SELECT 1", query_type="SELECT", - execution_time_ms=10, result_rows=1, - ) - await QueryHistoryService.record_query( - db=db, user_id=admin_user.id, username="admin", instance_id=2, - instance_name="测试Redis", db_type="redis", database_name="db0", - query_content="GET key", query_type="GET", - execution_time_ms=5, result_rows=1, - ) - await db.commit() - - resp = await client.get( - "/api/v1/query-history/my?db_type=mysql", - headers=admin_headers, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 1 - assert data["items"][0]["db_type"] == "mysql" - - @pytest.mark.asyncio - async def test_toggle_favorite_api(self, client, admin_headers, db, admin_user): - """测试收藏API。""" - entry = await QueryHistoryService.record_query( - db=db, user_id=admin_user.id, username="admin", instance_id=1, - instance_name="测试", db_type="mysql", database_name="test", - query_content="SELECT 1", query_type="SELECT", - execution_time_ms=10, result_rows=1, - ) - await db.commit() - - resp = await client.post( - f"/api/v1/query-history/{entry.id}/favorite", - headers=admin_headers, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["is_favorite"] is True - - @pytest.mark.asyncio - async def test_favorites_api(self, client, admin_headers, db): - """测试收藏列表API。""" - resp = await client.get("/api/v1/query-history/favorites", headers=admin_headers) - assert resp.status_code == 200 - data = resp.json() - assert "total" in data - assert "items" in data - - @pytest.mark.asyncio - async def test_statistics_api(self, client, admin_headers, db): - """测试统计API。""" - resp = await client.get("/api/v1/query-history/statistics", headers=admin_headers) - assert resp.status_code == 200 - data = resp.json() - assert "message" in data or "total_queries" in data - - @pytest.mark.asyncio - async def test_quick_queries_list_api(self, client, admin_headers, db): - """测试快捷查询列表API。""" - await QueryHistoryService.seed_default_quick_queries(db) - await db.commit() - - resp = await client.get("/api/v1/query-history/quick-queries", headers=admin_headers) - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 10 - assert len(data["items"]) == 10 - - @pytest.mark.asyncio - async def test_quick_queries_create_api(self, client, admin_headers, db): - """测试创建快捷查询API。""" - resp = await client.post( - "/api/v1/query-history/quick-queries", - params={ - "name": "我的查询", - "query_content": "SELECT * FROM users", - "db_type": "mysql", - "description": "测试快捷查询", - "is_shared": False, - }, - headers=admin_headers, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["name"] == "我的查询" - assert "id" in data - - @pytest.mark.asyncio - async def test_quick_queries_filter_api(self, client, admin_headers, db): - """测试快捷查询筛选API。""" - await QueryHistoryService.seed_default_quick_queries(db) - await db.commit() - - resp = await client.get( - "/api/v1/query-history/quick-queries?db_type=redis", - headers=admin_headers, - ) - assert resp.status_code == 200 - data = resp.json() - for item in data["items"]: - assert item["db_type"] == "redis" - - @pytest.mark.asyncio - async def test_guest_can_view_own_history(self, client, guest_headers, db): - """测试访客可以查看自己的历史(但无权执行查询)。""" - resp = await client.get("/api/v1/query-history/my", headers=guest_headers) - assert resp.status_code == 200 - - @pytest.mark.asyncio - async def test_pagination_parameters(self, client, admin_headers, db, admin_user): - """测试分页参数。""" - for i in range(15): - await QueryHistoryService.record_query( - db=db, user_id=admin_user.id, username="admin", instance_id=1, - instance_name="测试", db_type="mysql", database_name="test", - query_content=f"SELECT {i}", query_type="SELECT", - execution_time_ms=10, result_rows=1, - ) - await db.commit() - - resp = await client.get( - "/api/v1/query-history/my?page=1&page_size=5", - headers=admin_headers, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["total"] == 15 - assert len(data["items"]) == 5 - assert data["page"] == 1 - assert data["page_size"] == 5 diff --git a/backend/tests/test_query_service.py b/backend/tests/test_query_service.py deleted file mode 100644 index ae35623..0000000 --- a/backend/tests/test_query_service.py +++ /dev/null @@ -1,584 +0,0 @@ -"""多数据库查询服务测试 — 验证 QueryService 统一查询路由和各处理器。 - -覆盖: -1. MySQL 查询处理器: 只读验证、自动 LIMIT、结果格式 -2. PostgreSQL 查询处理器: 只读验证、自动 LIMIT -3. Redis 查询处理器: 白名单验证、子命令验证、结果格式化 -4. MongoDB 查询处理器: JSON 解析、只读操作白名单、aggregate 管道安全 -5. QueryService 路由: 按 db_type 分发、不支持的类型拒绝 -""" - -import json -import pytest -from unittest.mock import MagicMock - -from app.services.query_service import ( - QueryService, - MySQLQueryHandler, - PostgresQueryHandler, - RedisQueryHandler, - MongoQueryHandler, - REDIS_READONLY_COMMANDS, - REDIS_SUBCOMMAND_ALLOWLIST, - MONGODB_READONLY_OPS, - MONGODB_WRITE_OPS, -) -from app.schemas.query import UnifiedQueryResult - - -# ===== MySQL 查询处理器 ===== - - -class TestMySQLQueryHandler: - """MySQL 查询处理器单元测试。""" - - def setup_method(self): - self.handler = MySQLQueryHandler() - - def test_validate_readonly_select_pass(self): - """SELECT 语句应通过只读验证。""" - is_safe, msg = self.handler.validate_readonly("SELECT * FROM users LIMIT 10") - assert is_safe is True - assert msg == "" - - def test_validate_readonly_drop_rejected(self): - """DROP 语句应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("DROP TABLE users") - assert is_safe is False - assert "DROP" in msg.upper() or "只读" in msg or "禁止" in msg - - def test_validate_readonly_insert_rejected(self): - """INSERT 语句应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("INSERT INTO users (name) VALUES ('test')") - assert is_safe is False - - def test_validate_readonly_update_rejected(self): - """UPDATE 语句应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("UPDATE users SET name='x' WHERE id=1") - assert is_safe is False - - def test_validate_readonly_delete_rejected(self): - """DELETE 语句应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("DELETE FROM users WHERE id=1") - assert is_safe is False - - def test_validate_readonly_alter_rejected(self): - """ALTER 语句应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("ALTER TABLE users ADD COLUMN age INT") - assert is_safe is False - - def test_auto_add_limit_no_existing(self): - """没有 LIMIT 的 SQL 应自动添加 LIMIT。""" - sql = "SELECT * FROM users" - result = self.handler._auto_add_limit(sql) - assert "LIMIT 10000" in result.upper() - - def test_auto_add_limit_already_has(self): - """已有 LIMIT 的 SQL 不应重复添加。""" - sql = "SELECT * FROM users LIMIT 5" - result = self.handler._auto_add_limit(sql) - assert "10000" not in result - assert "LIMIT 5" in result.upper() - - def test_auto_add_limit_show_no_limit(self): - """SHOW 语句不应添加 LIMIT。""" - sql = "SHOW CREATE TABLE users" - result = self.handler._auto_add_limit(sql) - assert "LIMIT" not in result.upper() - assert result == sql - - def test_auto_add_limit_desc_no_limit(self): - """DESC 语句不应添加 LIMIT。""" - sql = "DESC users" - result = self.handler._auto_add_limit(sql) - assert "LIMIT" not in result.upper() - - @pytest.mark.asyncio - async def test_execute_non_readonly_raises(self): - """非只读查询应抛出 ValueError。""" - instance = MagicMock() - instance.db_type = "mysql" - with pytest.raises(ValueError): - await self.handler.execute(instance, "test_db", "DROP TABLE users", 1) - - -# ===== PostgreSQL 查询处理器 ===== - - -class TestPostgresQueryHandler: - """PostgreSQL 查询处理器单元测试。""" - - def setup_method(self): - self.handler = PostgresQueryHandler() - - def test_validate_readonly_select_pass(self): - """SELECT 语句应通过只读验证。""" - is_safe, msg = self.handler.validate_readonly("SELECT * FROM users LIMIT 10") - assert is_safe is True - - def test_validate_readonly_update_rejected(self): - """UPDATE 语句应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("UPDATE users SET name='x' WHERE id=1") - assert is_safe is False - - def test_validate_readonly_create_rejected(self): - """CREATE 语句应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("CREATE TABLE test (id INT)") - assert is_safe is False - - def test_auto_add_limit(self): - """应自动添加 LIMIT。""" - sql = "SELECT * FROM users" - result = self.handler._auto_add_limit(sql) - assert "LIMIT 10000" in result.upper() - - @pytest.mark.asyncio - async def test_execute_non_readonly_raises(self): - """非只读查询应抛出 ValueError。""" - instance = MagicMock() - instance.db_type = "postgres" - with pytest.raises(ValueError): - await self.handler.execute(instance, "test_db", "DELETE FROM users", 1) - - -# ===== Redis 查询处理器 ===== - - -class TestRedisQueryHandler: - """Redis 查询处理器单元测试。""" - - def setup_method(self): - self.handler = RedisQueryHandler() - - # --- 白名单验证 --- - - def test_validate_get_pass(self): - """GET 命令应在白名单中。""" - is_safe, msg = self.handler.validate_readonly("GET mykey") - assert is_safe is True - - def test_validate_hgetall_pass(self): - """HGETALL 命令应在白名单中。""" - is_safe, msg = self.handler.validate_readonly("HGETALL myhash") - assert is_safe is True - - def test_validate_keys_pass(self): - """KEYS 命令应在白名单中。""" - is_safe, msg = self.handler.validate_readonly("KEYS user:*") - assert is_safe is True - - def test_validate_info_pass(self): - """INFO 命令应在白名单中。""" - is_safe, msg = self.handler.validate_readonly("INFO") - assert is_safe is True - - def test_validate_scan_pass(self): - """SCAN 命令应在白名单中。""" - is_safe, msg = self.handler.validate_readonly("SCAN 0 MATCH user:* COUNT 100") - assert is_safe is True - - def test_validate_ttl_pass(self): - """TTL 命令应在白名单中。""" - is_safe, msg = self.handler.validate_readonly("TTL mykey") - assert is_safe is True - - def test_validate_exists_pass(self): - """EXISTS 命令应在白名单中。""" - is_safe, msg = self.handler.validate_readonly("EXISTS mykey") - assert is_safe is True - - def test_validate_type_pass(self): - """TYPE 命令应在白名单中。""" - is_safe, msg = self.handler.validate_readonly("TYPE mykey") - assert is_safe is True - - def test_validate_config_get_pass(self): - """CONFIG GET 子命令应在白名单中。""" - is_safe, msg = self.handler.validate_readonly("CONFIG GET maxmemory") - assert is_safe is True - - def test_validate_memory_usage_pass(self): - """MEMORY USAGE 子命令应在白名单中。""" - is_safe, msg = self.handler.validate_readonly("MEMORY USAGE mykey") - assert is_safe is True - - def test_validate_object_encoding_pass(self): - """OBJECT ENCODING 子命令应在白名单中。""" - is_safe, msg = self.handler.validate_readonly("OBJECT ENCODING mykey") - assert is_safe is True - - # --- 写命令拒绝 --- - - def test_validate_set_rejected(self): - """SET 命令应被拒绝(写操作)。""" - is_safe, msg = self.handler.validate_readonly("SET mykey value") - assert is_safe is False - assert "SET" in msg - - def test_validate_del_rejected(self): - """DEL 命令应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("DEL mykey") - assert is_safe is False - - def test_validate_flushdb_rejected(self): - """FLUSHDB 命令应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("FLUSHDB") - assert is_safe is False - - def test_validate_flushall_rejected(self): - """FLUSHALL 命令应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("FLUSHALL") - assert is_safe is False - - def test_validate_hset_rejected(self): - """HSET 命令应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("HSET myhash field value") - assert is_safe is False - - def test_validate_lpush_rejected(self): - """LPUSH 命令应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("LPUSH mylist value") - assert is_safe is False - - def test_validate_sadd_rejected(self): - """SADD 命令应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("SADD myset member") - assert is_safe is False - - def test_validate_zadd_rejected(self): - """ZADD 命令应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("ZADD myzset 1 member") - assert is_safe is False - - def test_validate_config_set_rejected(self): - """CONFIG SET 子命令应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("CONFIG SET maxmemory 100mb") - assert is_safe is False - - def test_validate_empty_rejected(self): - """空命令应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("") - assert is_safe is False - assert "空" in msg - - def test_validate_unknown_rejected(self): - """未知命令应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("UNKNOWNCMD arg1") - assert is_safe is False - - # --- 结果格式化 --- - - def test_format_result_nil(self): - """(nil) 结果应返回单行。""" - cols, rows = self.handler._format_result("GET", "(nil)") - assert cols == ["result"] - assert rows == [{"result": "(nil)"}] - - def test_format_result_simple_string(self): - """简单字符串结果应返回单行。""" - cols, rows = self.handler._format_result("GET", "hello") - assert cols == ["result"] - assert rows[0]["result"] == "hello" - - def test_format_result_info_keyvalue(self): - """INFO 结果应解析为 key-value 表格。""" - info_output = "# Server\nredis_version:7.0.0\ntcp_port:6379\nuptime_in_days:10" - cols, rows = self.handler._format_result("INFO", info_output) - assert cols == ["key", "value"] - assert len(rows) == 3 - assert rows[0]["key"] == "redis_version" - assert rows[0]["value"] == "7.0.0" - - def test_format_result_hgetall(self): - """HGETALL 结果应解析为 field-value 表格。""" - hgetall_output = "name\n" + "张三\n" + "age\n" + "25\n" + "email\n" + "test@example.com" - cols, rows = self.handler._format_result("HGETALL", hgetall_output) - assert cols == ["field", "value"] - assert len(rows) == 3 - assert rows[0]["field"] == "name" - assert rows[0]["value"] == "张三" - - def test_format_result_keys_list(self): - """KEYS 结果应解析为 value 列表。""" - keys_output = "user:1\nuser:2\nuser:3" - cols, rows = self.handler._format_result("KEYS", keys_output) - assert cols == ["value"] - assert len(rows) == 3 - - def test_format_result_empty(self): - """空结果应返回 (nil)。""" - cols, rows = self.handler._format_result("GET", "") - assert cols == ["result"] - assert rows == [{"result": "(nil)"}] - - @pytest.mark.asyncio - async def test_execute_non_readonly_raises(self): - """非只读命令应抛出 ValueError。""" - instance = MagicMock() - with pytest.raises(ValueError): - await self.handler.execute(instance, "0", "SET mykey value", 1) - - -# ===== MongoDB 查询处理器 ===== - - -class TestMongoQueryHandler: - """MongoDB 查询处理器单元测试。""" - - def setup_method(self): - self.handler = MongoQueryHandler() - - # --- JSON 解析 --- - - def test_validate_invalid_json_rejected(self): - """无效 JSON 应被拒绝。""" - is_safe, msg = self.handler.validate_readonly("not json at all") - assert is_safe is False - assert "JSON" in msg - - def test_validate_missing_operation_rejected(self): - """缺少 operation 字段应被拒绝。""" - is_safe, msg = self.handler.validate_readonly(json.dumps({"collection": "users"})) - assert is_safe is False - assert "operation" in msg - - # --- 只读操作白名单 --- - - def test_validate_find_pass(self): - """find 操作应通过验证。""" - query = json.dumps({"collection": "users", "operation": "find", "filter": {}}) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is True - - def test_validate_findone_pass(self): - """findOne 操作应通过验证。""" - query = json.dumps({"collection": "users", "operation": "findOne", "filter": {"_id": 1}}) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is True - - def test_validate_countdocuments_pass(self): - """countDocuments 操作应通过验证。""" - query = json.dumps({"collection": "users", "operation": "countDocuments", "filter": {}}) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is True - - def test_validate_estimatedcount_pass(self): - """estimatedDocumentCount 操作应通过验证。""" - query = json.dumps({"collection": "users", "operation": "estimatedDocumentCount"}) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is True - - def test_validate_distinct_pass(self): - """distinct 操作应通过验证。""" - query = json.dumps({"collection": "users", "operation": "distinct", "field": "status"}) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is True - - def test_validate_aggregate_pass(self): - """安全的 aggregate 管道应通过验证。""" - query = json.dumps({ - "collection": "users", - "operation": "aggregate", - "pipeline": [{"$match": {"status": "active"}}, {"$group": {"_id": "$city", "count": {"$sum": 1}}}] - }) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is True - - # --- 写操作拒绝 --- - - def test_validate_insertone_rejected(self): - """insertOne 操作应被拒绝。""" - query = json.dumps({"collection": "users", "operation": "insertOne", "document": {"name": "test"}}) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is False - assert "写操作" in msg or "禁止" in msg - - def test_validate_updatemany_rejected(self): - """updateMany 操作应被拒绝。""" - query = json.dumps({"collection": "users", "operation": "updateMany", "filter": {}, "update": {"$set": {"x": 1}}}) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is False - - def test_validate_deleteone_rejected(self): - """deleteOne 操作应被拒绝。""" - query = json.dumps({"collection": "users", "operation": "deleteOne", "filter": {"_id": 1}}) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is False - - def test_validate_deletemany_rejected(self): - """deleteMany 操作应被拒绝。""" - query = json.dumps({"collection": "users", "operation": "deleteMany", "filter": {}}) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is False - - def test_validate_drop_rejected(self): - """drop 操作应被拒绝。""" - query = json.dumps({"collection": "users", "operation": "drop"}) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is False - - # --- aggregate 管道安全检查 --- - - def test_validate_aggregate_with_out_rejected(self): - """aggregate 包含 $out 阶段应被拒绝。""" - query = json.dumps({ - "collection": "users", - "operation": "aggregate", - "pipeline": [{"$match": {}}, {"$out": "backup_users"}] - }) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is False - assert "$out" in msg or "写操作" in msg - - def test_validate_aggregate_with_merge_rejected(self): - """aggregate 包含 $merge 阶段应被拒绝。""" - query = json.dumps({ - "collection": "users", - "operation": "aggregate", - "pipeline": [{"$match": {}}, {"$merge": {"into": "backup_users"}}] - }) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is False - assert "$merge" in msg or "写操作" in msg - - def test_validate_aggregate_pipeline_not_list_rejected(self): - """aggregate 的 pipeline 不是数组应被拒绝。""" - query = json.dumps({ - "collection": "users", - "operation": "aggregate", - "pipeline": "not a list" - }) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is False - assert "数组" in msg - - def test_validate_unknown_operation_rejected(self): - """未知操作应被拒绝。""" - query = json.dumps({"collection": "users", "operation": "mapReduce"}) - is_safe, msg = self.handler.validate_readonly(query) - assert is_safe is False - assert "允许" in msg or "不在" in msg - - @pytest.mark.asyncio - async def test_execute_non_readonly_raises(self): - """写操作应抛出 ValueError。""" - instance = MagicMock() - query = json.dumps({"collection": "users", "operation": "insertOne", "document": {"name": "test"}}) - with pytest.raises(ValueError): - await self.handler.execute(instance, "test_db", query, 1) - - @pytest.mark.asyncio - async def test_execute_invalid_json_raises(self): - """无效 JSON 应抛出 ValueError。""" - instance = MagicMock() - with pytest.raises(ValueError): - await self.handler.execute(instance, "test_db", "not json", 1) - - -# ===== QueryService 路由 ===== - - -class TestQueryServiceRouter: - """QueryService 统一路由测试。""" - - def test_handlers_include_all_db_types(self): - """应支持所有 4 种数据库类型。""" - handlers = QueryService._handlers - assert "mysql" in handlers - assert "postgres" in handlers - assert "postgresql" in handlers - assert "redis" in handlers - assert "mongo" in handlers - - def test_postgres_and_postgresql_same_type(self): - """postgres 和 postgresql 应使用同一类型的处理器。""" - handlers = QueryService._handlers - assert isinstance(handlers["postgres"], PostgresQueryHandler) - assert isinstance(handlers["postgresql"], PostgresQueryHandler) - - @pytest.mark.asyncio - async def test_unsupported_db_type_raises(self): - """不支持的数据库类型应抛出 ValueError。""" - instance = MagicMock() - instance.db_type = "oracle" - with pytest.raises(ValueError, match="暂不支持"): - await QueryService.execute_query(instance, "test_db", "SELECT 1", 1) - - @pytest.mark.asyncio - async def test_unsupported_db_type_sqlite(self): - """SQLite 不应被支持(通过统一查询)。""" - instance = MagicMock() - instance.db_type = "sqlite" - with pytest.raises(ValueError): - await QueryService.execute_query(instance, "test_db", "SELECT 1", 1) - - -# ===== Redis 白名单完整性检查 ===== - - -class TestRedisWhitelistCompleteness: - """检查 Redis 只读白名单的完整性。""" - - def test_common_readonly_commands_present(self): - """常用只读命令应在白名单中。""" - required = { - "GET", "MGET", "HGET", "HGETALL", "HKEYS", "HVALS", "HLEN", - "LRANGE", "LLEN", "LINDEX", - "SMEMBERS", "SCARD", "SISMEMBER", - "ZRANGE", "ZCARD", "ZSCORE", "ZRANK", - "EXISTS", "TTL", "PTTL", "TYPE", "DBSIZE", - "SCAN", "HSCAN", "SSCAN", "ZSCAN", "KEYS", - "INFO", "PING", "ECHO", "TIME", - } - missing = required - REDIS_READONLY_COMMANDS - assert not missing, "缺少只读命令: " + str(missing) - - def test_write_commands_not_in_readonly(self): - """写命令不应出现在只读白名单中。""" - write_cmds = { - "SET", "SETEX", "SETNX", "MSET", "DEL", - "HSET", "HMSET", "HDEL", "HINCRBY", - "LPUSH", "RPUSH", "LPOP", "RPOP", "LREM", "LSET", - "SADD", "SREM", "SPOP", - "ZADD", "ZREM", "ZINCRBY", - "INCR", "INCRBY", "DECR", "DECRBY", - "EXPIRE", "EXPIREAT", "PERSIST", "RENAME", - "FLUSHDB", "FLUSHALL", "SAVE", "BGSAVE", - } - leaked = write_cmds & REDIS_READONLY_COMMANDS - assert not leaked, "写命令不应在只读白名单中: " + str(leaked) - - def test_subcommand_allowlist_has_config_get(self): - """CONFIG GET 子命令应在白名单中。""" - assert ("CONFIG", "GET") in REDIS_SUBCOMMAND_ALLOWLIST - - def test_subcommand_allowlist_has_memory_usage(self): - """MEMORY USAGE 子命令应在白名单中。""" - assert ("MEMORY", "USAGE") in REDIS_SUBCOMMAND_ALLOWLIST - - -# ===== MongoDB 白名单完整性检查 ===== - - -class TestMongoWhitelistCompleteness: - """检查 MongoDB 只读操作白名单的完整性。""" - - def test_readonly_ops_present(self): - """所有只读操作应在白名单中。""" - required = {"find", "findOne", "countDocuments", "estimatedDocumentCount", "distinct", "aggregate"} - assert required == MONGODB_READONLY_OPS - - def test_write_ops_present(self): - """所有写操作应在 MONGODB_WRITE_OPS 中。""" - required = { - "insertOne", "insertMany", "updateOne", "updateMany", - "deleteOne", "deleteMany", "drop", "createIndex", "dropIndex", - "replaceOne", "renameCollection", "findOneAndUpdate", - "findOneAndReplace", "findOneAndDelete", "bulkWrite", - } - assert required == MONGODB_WRITE_OPS - - def test_readonly_and_write_no_overlap(self): - """只读和写操作不应有重叠。""" - overlap = MONGODB_READONLY_OPS & MONGODB_WRITE_OPS - assert not overlap, "只读和写操作不应重叠: " + str(overlap) diff --git a/backend/tests/test_redis.py b/backend/tests/test_redis.py deleted file mode 100644 index 843f148..0000000 --- a/backend/tests/test_redis.py +++ /dev/null @@ -1,296 +0,0 @@ -"""Tests for Redis management API endpoints — all methods mocked.""" -import pytest -from unittest.mock import AsyncMock, patch - -from tests.conftest import auth_header - - -# ── Helpers ────────────────────────────────────────────────────── - -REDIS_INSTANCE_DATA = { - "name": "Test Redis", - "db_type": "redis", - "host": "127.0.0.1", - "port": 6379, - "username": "default", - "password": "test123", - "database": "0", - "env": "development", - "description": "Test Redis instance", -} - -MOCK_INFO = { - "redis_version": "7.2.3", - "uptime_in_days": 30, - "connected_clients": 42, - "db0": {"keys": 1000, "expires": 500}, - "used_memory_human": "256M", - "keyspace_hits": 10000, - "keyspace_misses": 100, -} - - -async def _create_redis_instance(client, admin_token): - """Create a Redis instance and return its ID.""" - resp = await client.post( - "/api/v1/instances", - json=REDIS_INSTANCE_DATA, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - return resp.json()["id"] - - -# ── Info endpoint ──────────────────────────────────────────────── - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector") -async def test_get_redis_info(mock_cls, client, admin_token): - mock_cls.get_info = AsyncMock(return_value=MOCK_INFO) - iid = await _create_redis_instance(client, admin_token) - - resp = await client.get( - "/api/v1/redis/" + str(iid) + "/info", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["redis_version"] == "7.2.3" - - -# ── DBSIZE endpoint ────────────────────────────────────────────── - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector") -async def test_get_redis_dbsize(mock_cls, client, admin_token): - mock_cls.get_dbsize = AsyncMock(return_value=1000) - iid = await _create_redis_instance(client, admin_token) - - resp = await client.get( - "/api/v1/redis/" + str(iid) + "/dbsize?db=0", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["size"] == 1000 - - -# ── Keys list ──────────────────────────────────────────────────── - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector") -async def test_get_redis_keys(mock_cls, client, admin_token): - mock_keys = [ - {"key": "user:1", "type": "string", "ttl": 3600}, - {"key": "session:abc", "type": "hash", "ttl": -1}, - ] - mock_cls.scan_keys = AsyncMock(return_value=(0, mock_keys)) - iid = await _create_redis_instance(client, admin_token) - - resp = await client.get( - "/api/v1/redis/" + str(iid) + "/keys?db=0&pattern=*&count=50", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert len(data["keys"]) == 2 - assert data["keys"][0]["key"] == "user:1" - - -# ── Key detail ─────────────────────────────────────────────────── - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector") -async def test_get_redis_key_detail(mock_cls, client, admin_token): - mock_cls.get_key_value = AsyncMock(return_value={ - "key": "user:1", "type": "string", "value": "hello", "ttl": 3600, - }) - iid = await _create_redis_instance(client, admin_token) - - resp = await client.get( - "/api/v1/redis/" + str(iid) + "/keys/user:1?db=0", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["value"] == "hello" - - -# ── Key CRUD ───────────────────────────────────────────────────── - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector") -async def test_create_redis_key(mock_cls, client, admin_token): - mock_cls.set_key_value = AsyncMock(return_value="OK") - iid = await _create_redis_instance(client, admin_token) - - resp = await client.post( - "/api/v1/redis/" + str(iid) + "/keys", - json={"db": 0, "key": "test_key", "value": "test_value", "type": "string", "ttl": 60}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["message"] == "操作成功" - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector") -async def test_delete_redis_key(mock_cls, client, admin_token): - mock_cls.del_key = AsyncMock(return_value=1) - iid = await _create_redis_instance(client, admin_token) - - resp = await client.delete( - "/api/v1/redis/" + str(iid) + "/keys/test_key?db=0", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["deleted"] == 1 - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector") -async def test_set_expire(mock_cls, client, admin_token): - mock_cls.set_ttl = AsyncMock(return_value=True) - iid = await _create_redis_instance(client, admin_token) - - resp = await client.put( - "/api/v1/redis/" + str(iid) + "/keys/test_key/expire", - json={"db": 0, "seconds": 300}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - -# ── Command execution ──────────────────────────────────────────── - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector") -async def test_execute_command(mock_cls, client, admin_token): - mock_cls.execute_command = AsyncMock(return_value="PONG") - iid = await _create_redis_instance(client, admin_token) - - resp = await client.post( - "/api/v1/redis/" + str(iid) + "/command", - json={"db": 0, "command": "PING"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["output"] == "PONG" - - -# ── Slowlog & Clients ──────────────────────────────────────────── - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector") -async def test_get_slowlog(mock_cls, client, admin_token): - mock_cls.get_slowlog = AsyncMock(return_value=[ - {"id": 1, "timestamp": 1700000000, "duration_us": 5000, "command": "KEYS *"}, - ]) - iid = await _create_redis_instance(client, admin_token) - - resp = await client.get( - "/api/v1/redis/" + str(iid) + "/slowlog?count=10", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert len(resp.json()) == 1 - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector") -async def test_get_clients(mock_cls, client, admin_token): - mock_cls.get_clients = AsyncMock(return_value=[ - {"id": "1", "addr": "127.0.0.1:54321", "cmd": "ping"}, - ]) - iid = await _create_redis_instance(client, admin_token) - - resp = await client.get( - "/api/v1/redis/" + str(iid) + "/clients", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert len(resp.json()) == 1 - - -# ── Permission checks ──────────────────────────────────────────── - -@pytest.mark.asyncio -async def test_create_key_requires_admin_or_dba(client, developer_token, admin_token): - """Non-admin/dba cannot create keys.""" - # Need a redis instance first - iid_data = dict(REDIS_INSTANCE_DATA) - resp = await client.post( - "/api/v1/instances", - json=iid_data, - headers=auth_header(admin_token), - ) - iid = resp.json()["id"] - - resp = await client.post( - "/api/v1/redis/" + str(iid) + "/keys", - json={"db": 0, "key": "k", "value": "v", "type": "string"}, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_flushdb_requires_admin(client, dba_token, admin_token): - """Non-admin cannot flush DB.""" - iid_data = dict(REDIS_INSTANCE_DATA) - resp = await client.post( - "/api/v1/instances", - json=iid_data, - headers=auth_header(admin_token), - ) - iid = resp.json()["id"] - - resp = await client.delete( - "/api/v1/redis/" + str(iid) + "/flushdb?db=0", - headers=auth_header(dba_token), - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -@patch("app.api.v1.endpoints.redis.redis_connector.RedisConnector") -async def test_flushdb_admin_can(mock_cls, client, admin_token): - """Admin can flush DB.""" - mock_cls.flush_db = AsyncMock(return_value="OK") - iid = await _create_redis_instance(client, admin_token) - - resp = await client.delete( - "/api/v1/redis/" + str(iid) + "/flushdb?db=0", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - -# ── Non-Redis instance rejection ────────────────────────────────── - -@pytest.mark.asyncio -async def test_non_redis_instance_rejected(client, admin_token): - """A MySQL instance should be rejected on Redis endpoints.""" - mysql_data = { - "name": "Test MySQL", - "db_type": "mysql", - "host": "127.0.0.1", - "port": 3306, - "username": "root", - "password": "test123", - "database": "test_db", - "env": "development", - } - resp = await client.post( - "/api/v1/instances", - json=mysql_data, - headers=auth_header(admin_token), - ) - iid = resp.json()["id"] - - resp = await client.get( - "/api/v1/redis/" + str(iid) + "/info", - headers=auth_header(admin_token), - ) - assert resp.status_code == 400 - assert "Redis" in resp.json()["detail"] diff --git a/backend/tests/test_rollback.py b/backend/tests/test_rollback.py deleted file mode 100644 index 40cd6c9..0000000 --- a/backend/tests/test_rollback.py +++ /dev/null @@ -1,182 +0,0 @@ -"""回滚 SQL API 测试 — 覆盖 generate-rollback 和 preview 端点。""" -import pytest -from unittest.mock import patch, AsyncMock, MagicMock -from httpx import AsyncClient - -from tests.conftest import auth_header - - -async def _create_ticket(client: AsyncClient, admin_token: str, instance_id: int) -> dict: - """Helper: create a ticket and return the response body.""" - payload = { - "title": "测试工单-回滚验证", - "instance_id": instance_id, - "target_database": "test_db", - "sql_content": "UPDATE users SET name='test' WHERE id=1", - "reason": "执行回滚测试用例", - "priority": "medium", - "db_type": "mysql", - } - resp = await client.post( - "/api/v1/tickets", json=payload, headers=auth_header(admin_token) - ) - return resp - - -# ────────────────────────── Generate Rollback Tests ────────────────────────── - - -class TestGenerateRollback: - - @pytest.mark.asyncio - async def test_generate_success(self, client, admin_token, test_instance): - """生成回滚 SQL — mock RollbackGenerator 以避免真实连接。""" - ticket_resp = await _create_ticket(client, admin_token, test_instance.id) - assert ticket_resp.status_code == 201 - ticket_id = ticket_resp.json()["id"] - - with patch("app.api.v1.endpoints.rollback.RollbackGenerator") as MockGen: - instance_mock = MagicMock() - instance_mock.generate = AsyncMock(return_value="SELECT * FROM users WHERE id=1") - MockGen.return_value = instance_mock - - resp = await client.post( - f"/api/v1/tickets/{ticket_id}/generate-rollback", - json={"auto_save": False}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["ticket_id"] == ticket_id - assert "UPDATE" in body["original_sql"] - assert body["rollback_sql"] == "SELECT * FROM users WHERE id=1" - assert body["saved"] is False - - @pytest.mark.asyncio - async def test_generate_with_auto_save(self, client, admin_token, test_instance): - """auto_save=True 应保存回滚 SQL 到工单。""" - ticket_resp = await _create_ticket(client, admin_token, test_instance.id) - ticket_id = ticket_resp.json()["id"] - - with patch("app.api.v1.endpoints.rollback.RollbackGenerator") as MockGen: - instance_mock = MagicMock() - instance_mock.generate = AsyncMock(return_value="DELETE FROM users WHERE id=1 AND name='test'") - MockGen.return_value = instance_mock - - resp = await client.post( - f"/api/v1/tickets/{ticket_id}/generate-rollback", - json={"auto_save": True}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["saved"] is True - - @pytest.mark.asyncio - async def test_generate_ticket_not_found(self, client, admin_token): - resp = await client.post( - "/api/v1/tickets/9999/generate-rollback", - json={"auto_save": False}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_generate_developer_forbidden(self, client, developer_token, test_instance): - resp = await client.post( - "/api/v1/tickets/1/generate-rollback", - json={"auto_save": False}, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_generate_guest_forbidden(self, client, guest_token): - resp = await client.post( - "/api/v1/tickets/1/generate-rollback", - json={"auto_save": False}, - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - -# ────────────────────────── Preview Rollback Tests ────────────────────────── - - -class TestPreviewRollback: - - @pytest.mark.asyncio - async def test_preview_success(self, client, admin_token, test_instance): - with patch("app.api.v1.endpoints.rollback.RollbackGenerator") as MockGen: - instance_mock = MagicMock() - instance_mock.preview = AsyncMock(return_value="SELECT * FROM users WHERE id=1") - MockGen.return_value = instance_mock - - resp = await client.post( - "/api/v1/rollback/preview", - json={ - "instance_id": test_instance.id, - "database": "test_db", - "sql": "UPDATE users SET name='x' WHERE id=1", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert "UPDATE" in body["original_sql"] - assert body["rollback_sql"] == "SELECT * FROM users WHERE id=1" - - @pytest.mark.asyncio - async def test_preview_instance_not_found(self, client, admin_token): - with patch("app.api.v1.endpoints.rollback.RollbackGenerator") as MockGen: - MockGen.return_value = MagicMock() - resp = await client.post( - "/api/v1/rollback/preview", - json={ - "instance_id": 9999, - "database": "test_db", - "sql": "DELETE FROM t WHERE id=1", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_preview_any_authenticated_user(self, client, developer_token, test_instance): - """preview 允许所有认证用户 (get_current_user)。""" - with patch("app.api.v1.endpoints.rollback.RollbackGenerator") as MockGen: - instance_mock = MagicMock() - instance_mock.preview = AsyncMock(return_value="-- 回滚SQL预览") - MockGen.return_value = instance_mock - - resp = await client.post( - "/api/v1/rollback/preview", - json={ - "instance_id": test_instance.id, - "database": "test_db", - "sql": "UPDATE t SET a=1 WHERE id=1", - }, - headers=auth_header(developer_token), - ) - assert resp.status_code == 200 - - @pytest.mark.asyncio - async def test_preview_no_auth_401(self, client): - resp = await client.post( - "/api/v1/rollback/preview", - json={ - "instance_id": 1, - "database": "test_db", - "sql": "UPDATE t SET a=1", - }, - ) - assert resp.status_code == 401 - - @pytest.mark.asyncio - async def test_preview_validation_error(self, client, admin_token): - """缺少必填字段 → 422。""" - resp = await client.post( - "/api/v1/rollback/preview", - json={"instance_id": 1}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 422 diff --git a/backend/tests/test_schema_drift.py b/backend/tests/test_schema_drift.py deleted file mode 100644 index d912604..0000000 --- a/backend/tests/test_schema_drift.py +++ /dev/null @@ -1,413 +0,0 @@ -"""Schema 漂移检测端点完整测试 — schema_drift.py - -覆盖端点: - POST /api/v1/schema-drift/instances/{instance_id}/capture 捕获快照 - POST /api/v1/schema-drift/instances/{instance_id}/check 检测漂移 - GET /api/v1/schema-drift/instances/{instance_id}/history 变更历史 -""" -import pytest -from unittest.mock import patch, AsyncMock, MagicMock -from httpx import AsyncClient - -from tests.conftest import auth_header - - -# ═══════════════════════════════════════════════════════════════ -# POST /schema-drift/instances/{instance_id}/capture — 捕获快照 -# ═══════════════════════════════════════════════════════════════ - - -class TestSchemaDriftCapture: - - async def test_capture_success( - self, client: AsyncClient, admin_token, test_instance - ): - """管理员成功捕获 Schema 快照。""" - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDet, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="decrypted_pw", - ): - detector_mock = MagicMock() - detector_mock.capture_snapshot = AsyncMock( - return_value=[{"table": "users", "column": "id"}] - ) - detector_mock.save_baseline = AsyncMock() - MockDet.return_value = detector_mock - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["message"] == "Schema 快照已捕获" - assert body["instance_id"] == test_instance.id - assert body["total_columns"] == 1 - - async def test_capture_with_explicit_database( - self, client: AsyncClient, admin_token, test_instance - ): - """通过 query param 指定 database。""" - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDet, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="decrypted_pw", - ): - detector_mock = MagicMock() - detector_mock.capture_snapshot = AsyncMock(return_value=[]) - detector_mock.save_baseline = AsyncMock() - MockDet.return_value = detector_mock - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture?database=mydb", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["database"] == "mydb" - - async def test_capture_uses_instance_database_as_default( - self, client: AsyncClient, admin_token, test_instance - ): - """不传 database 时使用 instance 的 database 字段。""" - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDet, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="decrypted_pw", - ): - detector_mock = MagicMock() - detector_mock.capture_snapshot = AsyncMock(return_value=[]) - detector_mock.save_baseline = AsyncMock() - MockDet.return_value = detector_mock - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - # test_instance.database == "test_db" - assert resp.json()["database"] == "test_db" - - async def test_capture_instance_not_found( - self, client: AsyncClient, admin_token - ): - """数据源不存在返回 404。""" - resp = await client.post( - "/api/v1/schema-drift/instances/9999/capture", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - assert "不存在" in resp.json()["detail"] - - async def test_capture_developer_forbidden( - self, client: AsyncClient, developer_token, test_instance - ): - """developer 角色无权捕获快照。""" - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - async def test_capture_guest_forbidden( - self, client: AsyncClient, guest_token, test_instance - ): - """guest 角色无权捕获快照。""" - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture", - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - async def test_capture_dba_allowed( - self, client: AsyncClient, dba_token, test_instance - ): - """DBA 角色可以捕获快照。""" - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDet, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="decrypted_pw", - ): - detector_mock = MagicMock() - detector_mock.capture_snapshot = AsyncMock(return_value=[]) - detector_mock.save_baseline = AsyncMock() - MockDet.return_value = detector_mock - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture", - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - async def test_capture_unauthenticated( - self, client: AsyncClient, test_instance - ): - """未认证请求返回 401。""" - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture", - ) - assert resp.status_code == 401 - - async def test_capture_multiple_columns( - self, client: AsyncClient, admin_token, test_instance - ): - """快照包含多个列。""" - snapshot = [ - {"table": "users", "column": "id"}, - {"table": "users", "column": "name"}, - {"table": "orders", "column": "id"}, - ] - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDet, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="decrypted_pw", - ): - detector_mock = MagicMock() - detector_mock.capture_snapshot = AsyncMock(return_value=snapshot) - detector_mock.save_baseline = AsyncMock() - MockDet.return_value = detector_mock - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["total_columns"] == 3 - - -# ═══════════════════════════════════════════════════════════════ -# POST /schema-drift/instances/{instance_id}/check — 检测漂移 -# ═══════════════════════════════════════════════════════════════ - - -class TestSchemaDriftCheck: - - async def test_check_no_drift( - self, client: AsyncClient, admin_token, test_instance - ): - """无漂移时返回正确报告。""" - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDet, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="decrypted_pw", - ): - report = MagicMock() - report.to_dict.return_value = { - "drifted": False, - "changes": [], - "instance_id": test_instance.id, - } - detector_mock = MagicMock() - detector_mock.detect_drift = AsyncMock(return_value=report) - MockDet.return_value = detector_mock - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/check", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["drifted"] is False - assert body["changes"] == [] - - async def test_check_with_drift( - self, client: AsyncClient, admin_token, test_instance - ): - """检测到漂移时返回变更列表。""" - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDet, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="decrypted_pw", - ): - report = MagicMock() - report.to_dict.return_value = { - "drifted": True, - "changes": [ - {"type": "column_added", "table": "users", "column": "age"}, - ], - } - detector_mock = MagicMock() - detector_mock.detect_drift = AsyncMock(return_value=report) - MockDet.return_value = detector_mock - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/check", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["drifted"] is True - assert len(body["changes"]) == 1 - - async def test_check_instance_not_found( - self, client: AsyncClient, admin_token - ): - """数据源不存在返回 404。""" - resp = await client.post( - "/api/v1/schema-drift/instances/9999/check", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - async def test_check_dba_allowed( - self, client: AsyncClient, dba_token, test_instance - ): - """DBA 角色可以检测漂移。""" - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDet, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="decrypted_pw", - ): - report = MagicMock() - report.to_dict.return_value = {"drifted": False, "changes": []} - detector_mock = MagicMock() - detector_mock.detect_drift = AsyncMock(return_value=report) - MockDet.return_value = detector_mock - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/check", - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - async def test_check_developer_forbidden( - self, client: AsyncClient, developer_token, test_instance - ): - """developer 角色无权检测漂移。""" - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/check", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - async def test_check_guest_forbidden( - self, client: AsyncClient, guest_token, test_instance - ): - """guest 角色无权检测漂移。""" - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/check", - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - async def test_check_unauthenticated( - self, client: AsyncClient, test_instance - ): - """未认证请求返回 401。""" - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/check", - ) - assert resp.status_code == 401 - - async def test_check_with_explicit_database( - self, client: AsyncClient, admin_token, test_instance - ): - """通过 query param 指定 database 检测漂移。""" - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDet, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="decrypted_pw", - ): - report = MagicMock() - report.to_dict.return_value = {"drifted": False} - detector_mock = MagicMock() - detector_mock.detect_drift = AsyncMock(return_value=report) - MockDet.return_value = detector_mock - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/check?database=other_db", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - -# ═══════════════════════════════════════════════════════════════ -# GET /schema-drift/instances/{instance_id}/history — 变更历史 -# ═══════════════════════════════════════════════════════════════ - - -class TestSchemaHistory: - - async def test_history_returns_data( - self, client: AsyncClient, admin_token, test_instance - ): - """管理员可以获取 Schema 变更历史。""" - resp = await client.get( - f"/api/v1/schema-drift/instances/{test_instance.id}/history", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["instance_id"] == test_instance.id - assert "history" in body - - async def test_history_is_list( - self, client: AsyncClient, admin_token, test_instance - ): - """history 字段为列表。""" - resp = await client.get( - f"/api/v1/schema-drift/instances/{test_instance.id}/history", - headers=auth_header(admin_token), - ) - body = resp.json() - assert isinstance(body["history"], list) - - async def test_history_dba_allowed( - self, client: AsyncClient, dba_token, test_instance - ): - """DBA 角色可以查看变更历史。""" - resp = await client.get( - f"/api/v1/schema-drift/instances/{test_instance.id}/history", - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - async def test_history_developer_forbidden( - self, client: AsyncClient, developer_token, test_instance - ): - """developer 角色无权查看变更历史。""" - resp = await client.get( - f"/api/v1/schema-drift/instances/{test_instance.id}/history", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - async def test_history_guest_forbidden( - self, client: AsyncClient, guest_token, test_instance - ): - """guest 角色无权查看变更历史。""" - resp = await client.get( - f"/api/v1/schema-drift/instances/{test_instance.id}/history", - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - async def test_history_unauthenticated( - self, client: AsyncClient, test_instance - ): - """未认证请求返回 401。""" - resp = await client.get( - f"/api/v1/schema-drift/instances/{test_instance.id}/history", - ) - assert resp.status_code == 401 - - async def test_history_contains_message( - self, client: AsyncClient, admin_token, test_instance - ): - """响应包含 message 字段。""" - resp = await client.get( - f"/api/v1/schema-drift/instances/{test_instance.id}/history", - headers=auth_header(admin_token), - ) - body = resp.json() - assert "message" in body diff --git a/backend/tests/test_schema_drift_endpoint.py b/backend/tests/test_schema_drift_endpoint.py deleted file mode 100644 index 5f777d1..0000000 --- a/backend/tests/test_schema_drift_endpoint.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Schema 漂移检测 API 端点测试。""" -import pytest -from unittest.mock import patch, MagicMock, AsyncMock -from httpx import AsyncClient - -from app.models.user import User -from app.models.instance import Instance - - -def auth_header(token: str) -> dict: - return {"Authorization": f"Bearer {token}"} - - -@pytest.mark.asyncio -class TestCaptureSchema: - """POST /api/v1/schema-drift/instances/{instance_id}/capture""" - - async def test_capture_schema_success( - self, client: AsyncClient, admin_token: str, test_instance - ): - mock_snapshot = [ - {"table": "users", "column": "id", "type": "int"}, - {"table": "users", "column": "name", "type": "varchar"}, - ] - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDetector, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="decrypted_pw", - ): - detector = MockDetector.return_value - detector.capture_snapshot = AsyncMock(return_value=mock_snapshot) - detector.save_baseline = AsyncMock() - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert "已捕获" in data["message"] - assert data["total_columns"] == 2 - assert data["instance_id"] == test_instance.id - - async def test_capture_schema_with_database_param( - self, client: AsyncClient, admin_token: str, test_instance - ): - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDetector, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="decrypted_pw", - ): - detector = MockDetector.return_value - detector.capture_snapshot = AsyncMock(return_value=[]) - detector.save_baseline = AsyncMock() - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture?database=mydb", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["database"] == "mydb" - - async def test_capture_schema_instance_not_found( - self, client: AsyncClient, admin_token: str - ): - resp = await client.post( - "/api/v1/schema-drift/instances/9999/capture", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - async def test_capture_schema_forbidden_developer( - self, client: AsyncClient, developer_token: str, test_instance - ): - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - async def test_capture_schema_dba_allowed( - self, client: AsyncClient, dba_token: str, test_instance - ): - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDetector, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="pw", - ): - detector = MockDetector.return_value - detector.capture_snapshot = AsyncMock(return_value=[]) - detector.save_baseline = AsyncMock() - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture", - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - -@pytest.mark.asyncio -class TestCheckDrift: - """POST /api/v1/schema-drift/instances/{instance_id}/check""" - - async def test_check_drift_no_changes( - self, client: AsyncClient, admin_token: str, test_instance - ): - mock_report = MagicMock() - mock_report.to_dict.return_value = { - "has_drift": False, - "changes": [], - "instance_id": test_instance.id, - } - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDetector, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="pw", - ): - detector = MockDetector.return_value - detector.detect_drift = AsyncMock(return_value=mock_report) - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/check", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["has_drift"] is False - - async def test_check_drift_with_changes( - self, client: AsyncClient, admin_token: str, test_instance - ): - mock_report = MagicMock() - mock_report.to_dict.return_value = { - "has_drift": True, - "changes": [ - {"type": "column_added", "table": "users", "column": "email"}, - ], - "instance_id": test_instance.id, - } - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDetector, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="pw", - ): - detector = MockDetector.return_value - detector.detect_drift = AsyncMock(return_value=mock_report) - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/check", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["has_drift"] is True - assert len(data["changes"]) == 1 - - async def test_check_drift_instance_not_found( - self, client: AsyncClient, admin_token: str - ): - resp = await client.post( - "/api/v1/schema-drift/instances/9999/check", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - async def test_check_drift_dict_report( - self, client: AsyncClient, admin_token: str, test_instance - ): - """Detector 返回普通 dict 而非对象时的序列化。""" - plain_dict = {"has_drift": False, "instance_id": test_instance.id} - with patch( - "app.api.v1.endpoints.schema_drift.SchemaDriftDetector" - ) as MockDetector, patch( - "app.api.v1.endpoints.schema_drift.decrypt_password", - return_value="pw", - ): - detector = MockDetector.return_value - detector.detect_drift = AsyncMock(return_value=plain_dict) - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/check", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - -@pytest.mark.asyncio -class TestSchemaHistory: - """GET /api/v1/schema-drift/instances/{instance_id}/history""" - - async def test_schema_history( - self, client: AsyncClient, admin_token: str, test_instance - ): - resp = await client.get( - f"/api/v1/schema-drift/instances/{test_instance.id}/history", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["instance_id"] == test_instance.id - assert "history" in data - - async def test_schema_history_dba_allowed( - self, client: AsyncClient, dba_token: str, test_instance - ): - resp = await client.get( - f"/api/v1/schema-drift/instances/{test_instance.id}/history", - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - async def test_schema_history_forbidden_guest( - self, client: AsyncClient, guest_token: str, test_instance - ): - resp = await client.get( - f"/api/v1/schema-drift/instances/{test_instance.id}/history", - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 - - async def test_schema_history_unauthorized(self, client: AsyncClient, test_instance): - resp = await client.get( - f"/api/v1/schema-drift/instances/{test_instance.id}/history", - ) - assert resp.status_code == 401 diff --git a/backend/tests/test_schema_drift_pool.py b/backend/tests/test_schema_drift_pool.py deleted file mode 100644 index 55f7be2..0000000 --- a/backend/tests/test_schema_drift_pool.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Schema 漂移检测 + 连接池管理 API 测试。""" -import pytest -from unittest.mock import patch, AsyncMock, MagicMock -from httpx import AsyncClient - -from tests.conftest import auth_header - - -# ════════════════════════════════════════════════════════════════════ -# Schema Drift Tests -# ════════════════════════════════════════════════════════════════════ - - -class TestSchemaDriftCapture: - """捕获 Schema 快照。""" - - @pytest.mark.asyncio - async def test_capture_success(self, client, admin_token, test_instance): - with patch("app.api.v1.endpoints.schema_drift.SchemaDriftDetector") as MockDet: - instance_mock = MagicMock() - instance_mock.capture_snapshot = AsyncMock(return_value=[{"col": "id"}]) - instance_mock.save_baseline = AsyncMock() - MockDet.return_value = instance_mock - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["message"] == "Schema 快照已捕获" - assert body["total_columns"] == 1 - - @pytest.mark.asyncio - async def test_capture_instance_not_found(self, client, admin_token): - resp = await client.post( - "/api/v1/schema-drift/instances/9999/capture", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_capture_developer_forbidden(self, client, developer_token, test_instance): - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_capture_dba_allowed(self, client, dba_token, test_instance): - with patch("app.api.v1.endpoints.schema_drift.SchemaDriftDetector") as MockDet: - instance_mock = MagicMock() - instance_mock.capture_snapshot = AsyncMock(return_value=[]) - instance_mock.save_baseline = AsyncMock() - MockDet.return_value = instance_mock - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/capture", - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - -class TestSchemaDriftCheck: - """检测 Schema 漂移。""" - - @pytest.mark.asyncio - async def test_check_success(self, client, admin_token, test_instance): - with patch("app.api.v1.endpoints.schema_drift.SchemaDriftDetector") as MockDet: - report = MagicMock() - report.to_dict.return_value = {"drifted": False, "changes": []} - instance_mock = MagicMock() - instance_mock.detect_drift = AsyncMock(return_value=report) - MockDet.return_value = instance_mock - - resp = await client.post( - f"/api/v1/schema-drift/instances/{test_instance.id}/check", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["drifted"] is False - - @pytest.mark.asyncio - async def test_check_instance_not_found(self, client, admin_token): - resp = await client.post( - "/api/v1/schema-drift/instances/9999/check", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - -class TestSchemaHistory: - - @pytest.mark.asyncio - async def test_history_returns_data(self, client, admin_token, test_instance): - resp = await client.get( - f"/api/v1/schema-drift/instances/{test_instance.id}/history", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["instance_id"] == test_instance.id - assert "history" in body - - -# ════════════════════════════════════════════════════════════════════ -# Connection Pool Tests -# ════════════════════════════════════════════════════════════════════ - - -class TestPoolStats: - - @pytest.mark.asyncio - async def test_stats_not_initialized(self, client, admin_token, test_instance): - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - manager = MagicMock() - manager.get_pool_stats.return_value = None - mock_pm.return_value = manager - - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["status"] == "not_initialized" - - @pytest.mark.asyncio - async def test_stats_with_data(self, client, admin_token, test_instance): - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - stats = MagicMock() - stats.to_dict.return_value = {"active": 2, "idle": 3} - manager = MagicMock() - manager.get_pool_stats.return_value = stats - mock_pm.return_value = manager - - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["active"] == 2 - - @pytest.mark.asyncio - async def test_stats_instance_not_found(self, client, admin_token): - resp = await client.get( - "/api/v1/pools/instances/9999/stats", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_stats_developer_forbidden(self, client, developer_token, test_instance): - resp = await client.get( - f"/api/v1/pools/instances/{test_instance.id}/stats", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -class TestPoolReset: - - @pytest.mark.asyncio - async def test_reset_success(self, client, admin_token, test_instance): - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - manager = MagicMock() - manager.reset_pool = AsyncMock() - mock_pm.return_value = manager - - resp = await client.post( - f"/api/v1/pools/instances/{test_instance.id}/reset", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert "已重置" in resp.json()["message"] - - @pytest.mark.asyncio - async def test_reset_dba_allowed(self, client, dba_token, test_instance): - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - manager = MagicMock() - manager.reset_pool = AsyncMock() - mock_pm.return_value = manager - - resp = await client.post( - f"/api/v1/pools/instances/{test_instance.id}/reset", - headers=auth_header(dba_token), - ) - assert resp.status_code == 200 - - @pytest.mark.asyncio - async def test_reset_developer_forbidden(self, client, developer_token, test_instance): - resp = await client.post( - f"/api/v1/pools/instances/{test_instance.id}/reset", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -class TestPoolsOverview: - - @pytest.mark.asyncio - async def test_overview_empty(self, client, admin_token): - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - manager = MagicMock() - manager.get_all_stats.return_value = {} - mock_pm.return_value = manager - - resp = await client.get( - "/api/v1/pools/overview", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["total_pools"] == 0 - - @pytest.mark.asyncio - async def test_overview_with_pools(self, client, admin_token): - with patch("app.api.v1.endpoints.pool.get_pool_manager") as mock_pm: - stats = MagicMock() - stats.to_dict.return_value = {"instance_id": 1, "active": 1} - manager = MagicMock() - manager.get_all_stats.return_value = {1: stats} - mock_pm.return_value = manager - - resp = await client.get( - "/api/v1/pools/overview", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - body = resp.json() - assert body["total_pools"] == 1 - assert len(body["pools"]) == 1 - - @pytest.mark.asyncio - async def test_overview_guest_forbidden(self, client, guest_token): - resp = await client.get( - "/api/v1/pools/overview", - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py deleted file mode 100644 index 4764215..0000000 --- a/backend/tests/test_settings.py +++ /dev/null @@ -1,381 +0,0 @@ -"""Tests for Settings modules: Announcement, NotificationChannel, LDAP Config.""" -import json - -import pytest -from unittest.mock import AsyncMock, patch -from httpx import AsyncClient - -from tests.conftest import auth_header - - -# ─── Announcement Tests ──────────────────────────────────────────── - -@pytest.mark.asyncio -async def test_create_announcement(client: AsyncClient, admin_token: str): - resp = await client.post( - "/api/v1/announcements", - json={"title": "系统公告", "content": "# Hello\n这是公告内容"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - assert data["title"] == "系统公告" - assert data["content"] == "# Hello\n这是公告内容" - assert data["is_published"] is False - - -@pytest.mark.asyncio -async def test_list_announcements(client: AsyncClient, admin_token: str, developer_token: str): - # Create two announcements - for i in range(2): - await client.post( - "/api/v1/announcements", - json={"title": f"公告{i}", "content": f"内容{i}"}, - headers=auth_header(admin_token), - ) - - # Admin can list - resp = await client.get("/api/v1/announcements", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert len(resp.json()) == 2 - - # Developer can also list - resp = await client.get("/api/v1/announcements", headers=auth_header(developer_token)) - assert resp.status_code == 200 - assert len(resp.json()) == 2 - - -@pytest.mark.asyncio -async def test_update_announcement(client: AsyncClient, admin_token: str): - create = await client.post( - "/api/v1/announcements", - json={"title": "原始标题", "content": "原始内容"}, - headers=auth_header(admin_token), - ) - aid = create.json()["id"] - - resp = await client.put( - f"/api/v1/announcements/{aid}", - json={"title": "更新标题", "is_published": True}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["title"] == "更新标题" - assert data["is_published"] is True - assert data["published_at"] is not None - - -@pytest.mark.asyncio -async def test_delete_announcement(client: AsyncClient, admin_token: str): - create = await client.post( - "/api/v1/announcements", - json={"title": "待删除", "content": "内容"}, - headers=auth_header(admin_token), - ) - aid = create.json()["id"] - - resp = await client.delete( - f"/api/v1/announcements/{aid}", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - # Verify deleted - resp = await client.get("/api/v1/announcements", headers=auth_header(admin_token)) - assert all(a["id"] != aid for a in resp.json()) - - -@pytest.mark.asyncio -async def test_announcement_non_admin_forbidden(client: AsyncClient, developer_token: str): - # Create forbidden - resp = await client.post( - "/api/v1/announcements", - json={"title": "test", "content": "test"}, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - # Update forbidden - resp = await client.put( - "/api/v1/announcements/1", - json={"title": "test"}, - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - # Delete forbidden - resp = await client.delete( - "/api/v1/announcements/1", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -# ─── NotificationChannel Tests ───────────────────────────────────── - -@pytest.mark.asyncio -async def test_create_notification_channel(client: AsyncClient, admin_token: str): - resp = await client.post( - "/api/v1/notifications", - json={ - "name": "钉钉告警", - "channel_type": "dingtalk", - "config": {"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=xxx", "secret": "mysecret"}, - "is_enabled": True, - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - assert data["name"] == "钉钉告警" - assert data["channel_type"] == "dingtalk" - # Password/secret should be masked - assert data["config"]["secret"] == "******" - - -@pytest.mark.asyncio -async def test_list_notification_channels(client: AsyncClient, admin_token: str): - resp = await client.get( - "/api/v1/notifications", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert isinstance(resp.json(), list) - - -@pytest.mark.asyncio -async def test_update_notification_channel(client: AsyncClient, admin_token: str): - create = await client.post( - "/api/v1/notifications", - json={ - "name": "飞书通知", - "channel_type": "webhook_feishu", - "config": {"webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"}, - }, - headers=auth_header(admin_token), - ) - cid = create.json()["id"] - - resp = await client.put( - f"/api/v1/notifications/{cid}", - json={"name": "飞书通知-更新", "is_enabled": False}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "飞书通知-更新" - assert resp.json()["is_enabled"] is False - - -@pytest.mark.asyncio -async def test_delete_notification_channel(client: AsyncClient, admin_token: str): - create = await client.post( - "/api/v1/notifications", - json={ - "name": "待删除", - "channel_type": "email", - "config": { - "smtp_host": "smtp.example.com", - "smtp_port": 465, - "smtp_user": "user@example.com", - "smtp_password": "pass123", - "from_addr": "user@example.com", - "use_tls": True, - }, - }, - headers=auth_header(admin_token), - ) - cid = create.json()["id"] - - resp = await client.delete( - f"/api/v1/notifications/{cid}", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - -@pytest.mark.asyncio -@patch("app.services.notification_sender.NotificationSender.send", new_callable=AsyncMock) -async def test_notification_test_endpoint(mock_send, client: AsyncClient, admin_token: str): - mock_send.return_value = {"success": True, "message": "钉钉测试消息发送成功"} - create = await client.post( - "/api/v1/notifications", - json={ - "name": "钉钉测试", - "channel_type": "dingtalk", - "config": {"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=***"}, - "is_enabled": True, - }, - headers=auth_header(admin_token), - ) - cid = create.json()["id"] - - resp = await client.post( - f"/api/v1/notifications/{cid}/test", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["success"] is True - - -@pytest.mark.asyncio -async def test_notification_non_admin_forbidden(client: AsyncClient, developer_token: str): - resp = await client.get( - "/api/v1/notifications", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -async def test_email_config_mask_password(client: AsyncClient, admin_token: str): - create = await client.post( - "/api/v1/notifications", - json={ - "name": "邮件通道", - "channel_type": "email", - "config": { - "smtp_host": "smtp.example.com", - "smtp_port": 587, - "smtp_user": "admin@example.com", - "smtp_password": "super_secret_pass", - "from_addr": "admin@example.com", - "use_tls": True, - }, - }, - headers=auth_header(admin_token), - ) - data = create.json() - assert data["config"]["smtp_password"] == "******" - - -# ─── LDAP Config Tests ───────────────────────────────────────────── - -@pytest.mark.asyncio -async def test_create_ldap_config(client: AsyncClient, admin_token: str): - resp = await client.put( - "/api/v1/ldap-config", - json={ - "ldap_host": "ldap.example.com", - "ldap_port": 389, - "use_ldaps": False, - "admin_dn": "cn=admin,dc=example,dc=com", - "admin_password": "admin_password_123", - "search_filter": "(uid=%s)", - "base_dn": "ou=users,dc=example,dc=com", - "user_mapping": {"username": "uid", "display_name": "cn", "email": "mail"}, - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["ldap_host"] == "ldap.example.com" - assert data["ldap_port"] == 389 - # Password should be masked - assert data["admin_password"] == "******" - assert data["user_mapping"]["username"] == "uid" - - -@pytest.mark.asyncio -async def test_get_ldap_config(client: AsyncClient, admin_token: str): - # First create - await client.put( - "/api/v1/ldap-config", - json={ - "ldap_host": "ldap.test.com", - "ldap_port": 636, - "use_ldaps": True, - "admin_dn": "cn=admin,dc=test,dc=com", - "admin_password": "testpass", - "base_dn": "dc=test,dc=com", - }, - headers=auth_header(admin_token), - ) - - resp = await client.get( - "/api/v1/ldap-config", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["ldap_host"] == "ldap.test.com" - assert data["use_ldaps"] is True - assert data["admin_password"] == "******" - - -@pytest.mark.asyncio -async def test_update_ldap_config(client: AsyncClient, admin_token: str): - # Create - await client.put( - "/api/v1/ldap-config", - json={ - "ldap_host": "ldap.old.com", - "ldap_port": 389, - "admin_dn": "cn=admin,dc=old,dc=com", - "admin_password": "oldpass", - "base_dn": "dc=old,dc=com", - }, - headers=auth_header(admin_token), - ) - - # Update - resp = await client.put( - "/api/v1/ldap-config", - json={ - "ldap_host": "ldap.new.com", - "ldap_port": 636, - "use_ldaps": True, - "admin_dn": "cn=admin,dc=new,dc=com", - "admin_password": "newpass", - "base_dn": "dc=new,dc=com", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["ldap_host"] == "ldap.new.com" - assert resp.json()["use_ldaps"] is True - - -@pytest.mark.asyncio -async def test_ldap_config_not_found(client: AsyncClient, admin_token: str): - resp = await client.get( - "/api/v1/ldap-config", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - -@pytest.mark.asyncio -async def test_ldap_test_connection(client: AsyncClient, admin_token: str): - # Create config first - await client.put( - "/api/v1/ldap-config", - json={ - "ldap_host": "ldap.example.com", - "ldap_port": 389, - "admin_dn": "cn=admin,dc=example,dc=com", - "admin_password": "admin123", - "base_dn": "dc=example,dc=com", - }, - headers=auth_header(admin_token), - ) - - resp = await client.post( - "/api/v1/ldap-config/test", - json={"test_user": "testuser", "test_password": "testpass"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - # 测试环境无真实 LDAP 服务器,应返回失败 - assert data["success"] is False - - -@pytest.mark.asyncio -async def test_ldap_non_admin_forbidden(client: AsyncClient, developer_token: str): - resp = await client.get( - "/api/v1/ldap-config", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 diff --git a/backend/tests/test_sql_analyzer.py b/backend/tests/test_sql_analyzer.py deleted file mode 100644 index ec33faa..0000000 --- a/backend/tests/test_sql_analyzer.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Tests for SQL AST Analyzer (Phase 4.1).""" -import pytest -from app.services.sql_analyzer import SQLAnalyzer, SQLType, RiskLevel - - -analyzer = SQLAnalyzer() - - -class TestSQLTypeDetection: - def test_select(self): - result = analyzer.analyze("SELECT id, name FROM users WHERE id = 1;") - assert result.sql_type == SQLType.SELECT - - def test_insert(self): - result = analyzer.analyze("INSERT INTO users (name) VALUES ('test');") - assert result.sql_type == SQLType.INSERT - - def test_update(self): - result = analyzer.analyze("UPDATE users SET name = 'test' WHERE id = 1;") - assert result.sql_type == SQLType.UPDATE - - def test_delete(self): - result = analyzer.analyze("DELETE FROM users WHERE id = 1;") - assert result.sql_type == SQLType.DELETE - - def test_drop_table(self): - result = analyzer.analyze("DROP TABLE users;") - assert result.sql_type == SQLType.DROP - - def test_truncate(self): - result = analyzer.analyze("TRUNCATE TABLE logs;") - assert result.sql_type == SQLType.TRUNCATE - - def test_alter_table(self): - result = analyzer.analyze("ALTER TABLE users ADD COLUMN age INT;") - assert result.sql_type == SQLType.ALTER - - def test_create_table(self): - result = analyzer.analyze("CREATE TABLE test (id INT PRIMARY KEY);") - assert result.sql_type == SQLType.CREATE - - def test_cte_select(self): - result = analyzer.analyze("WITH cte AS (SELECT 1) SELECT * FROM cte;") - assert result.sql_type == SQLType.SELECT - - def test_explain(self): - result = analyzer.analyze("EXPLAIN SELECT * FROM users;") - assert result.sql_type == SQLType.SELECT - - -class TestReadonlyValidation: - def test_select_is_safe(self): - safe, msg = analyzer.validate_readonly("SELECT id FROM users LIMIT 10;") - assert safe is True - - def test_insert_not_safe(self): - safe, msg = analyzer.validate_readonly("INSERT INTO users (name) VALUES ('test');") - assert safe is False - assert "INSERT" in msg - - def test_update_not_safe(self): - safe, msg = analyzer.validate_readonly("UPDATE users SET name = 'test' WHERE id = 1;") - assert safe is False - - def test_delete_not_safe(self): - safe, msg = analyzer.validate_readonly("DELETE FROM users WHERE id = 1;") - assert safe is False - - def test_drop_not_safe(self): - safe, msg = analyzer.validate_readonly("DROP TABLE users;") - assert safe is False - - def test_truncate_not_safe(self): - safe, msg = analyzer.validate_readonly("TRUNCATE TABLE logs;") - assert safe is False - - def test_alter_not_safe(self): - safe, msg = analyzer.validate_readonly("ALTER TABLE users ADD COLUMN age INT;") - assert safe is False - - def test_create_not_safe(self): - safe, msg = analyzer.validate_readonly("CREATE TABLE test (id INT);") - assert safe is False - - -class TestRiskLevels: - def test_select_is_safe_risk(self): - result = analyzer.analyze("SELECT * FROM users;") - assert result.risk_level == RiskLevel.SAFE - - def test_dml_with_where_is_medium(self): - result = analyzer.analyze("UPDATE users SET name = 'a' WHERE id = 1;") - assert result.risk_level == RiskLevel.MEDIUM - - def test_dml_without_where_is_high(self): - result = analyzer.analyze("UPDATE users SET name = 'a';") - assert result.risk_level == RiskLevel.HIGH - - def test_ddl_is_dangerous(self): - result = analyzer.analyze("DROP TABLE users;") - assert result.risk_level == RiskLevel.DANGEROUS - - -class TestSecurityChecks: - def test_multi_statement_rejected(self): - safe, msg = analyzer.validate_readonly("SELECT 1; DROP TABLE users;") - assert safe is False - assert "多条" in msg or "分号" in msg - - def test_into_outfile_rejected(self): - safe, msg = analyzer.validate_readonly("SELECT * INTO OUTFILE '/tmp/data.txt' FROM users;") - assert safe is False - assert "OUTFILE" in msg - - def test_subquery_dml_rejected(self): - safe, msg = analyzer.validate_readonly("SELECT * FROM (DELETE FROM users) AS t;") - assert safe is False - - def test_normal_subquery_ok(self): - safe, msg = analyzer.validate_readonly("SELECT * FROM (SELECT id FROM users) AS t;") - assert safe is True - - def test_semicolon_in_string_ok(self): - safe, msg = analyzer.validate_readonly("SELECT 'hello;world' FROM users;") - assert safe is True - - -class TestAnalysis: - def test_has_where(self): - result = analyzer.analyze("SELECT * FROM users WHERE id = 1;") - assert result.has_where is True - - def test_no_where(self): - result = analyzer.analyze("SELECT * FROM users;") - assert result.has_where is False - - def test_to_dict(self): - result = analyzer.analyze("SELECT id FROM users;") - d = result.to_dict() - assert "sql_type" in d - assert "risk_level" in d - assert "is_readonly" in d - assert d["sql_type"] == "SELECT" - assert d["is_readonly"] is True - - def test_complex_select(self): - result = analyzer.analyze(""" - SELECT u.id, u.name, o.total - FROM users u - JOIN orders o ON u.id = o.user_id - WHERE u.status = 'active' - ORDER BY o.total DESC - LIMIT 50; - """) - assert result.sql_type == SQLType.SELECT - assert result.is_readonly is True - assert result.has_where is True diff --git a/backend/tests/test_sql_assistant.py b/backend/tests/test_sql_assistant.py deleted file mode 100644 index c7aff07..0000000 --- a/backend/tests/test_sql_assistant.py +++ /dev/null @@ -1,326 +0,0 @@ -"""SQL 助手服务测试 — 验证格式化、高亮、风险检测、EXPLAIN 解析和查询建议。""" - -import pytest -from app.services.sql_assistant import SQLAssistant - - -# ===== SQL 格式化 ===== - - -class TestSQLFormat: - """SQL 格式化测试。""" - - def test_format_simple_select(self): - """应格式化简单 SELECT。""" - result = SQLAssistant.format_sql("select * from users where id = 1") - assert result.is_valid is True - assert "SELECT" in result.formatted - assert "FROM" in result.formatted - - def test_format_empty_sql(self): - """空 SQL 应返回无效。""" - result = SQLAssistant.format_sql("") - assert result.is_valid is False - assert result.error is not None - - def test_format_none_sql(self): - """None 应返回无效。""" - result = SQLAssistant.format_sql(None) - assert result.is_valid is False - - def test_format_whitespace_sql(self): - """纯空格 SQL 应返回无效。""" - result = SQLAssistant.format_sql(" ") - assert result.is_valid is False - - def test_format_preserves_content(self): - """格式化不应丢失 SQL 内容。""" - sql = "SELECT id, name FROM users WHERE status = 'active' ORDER BY id LIMIT 10" - result = SQLAssistant.format_sql(sql) - assert result.is_valid is True - assert "users" in result.formatted - assert "status" in result.formatted - - def test_format_complex_join(self): - """应格式化复杂 JOIN 查询。""" - sql = "select u.id, u.name, o.total from users u join orders o on u.id = o.user_id where o.total > 100" - result = SQLAssistant.format_sql(sql) - assert result.is_valid is True - assert "JOIN" in result.formatted.upper() or "join" in result.formatted - - -# ===== SQL 高亮 ===== - - -class TestSQLHighlight: - """SQL 语法高亮和风险分析测试。""" - - def test_highlight_select(self): - """应标记 SELECT 关键字。""" - result = SQLAssistant.highlight_sql("SELECT * FROM users") - assert "SELECT" in result.keywords - - def test_highlight_extract_table(self): - """应从 FROM 子句提取表名。""" - result = SQLAssistant.highlight_sql("SELECT * FROM users WHERE id = 1") - assert "users" in result.tables - - def test_highlight_extract_join_table(self): - """应从 JOIN 子句提取表名。""" - result = SQLAssistant.highlight_sql("SELECT * FROM users u JOIN orders o ON u.id = o.user_id") - assert "users" in result.tables - assert "orders" in result.tables - - def test_highlight_delete_risk(self): - """DELETE 应被标记为高风险。""" - result = SQLAssistant.highlight_sql("DELETE FROM users WHERE id = 1") - assert len(result.risks) > 0 - risk_ops = [r["operation"] for r in result.risks] - assert "DELETE" in risk_ops - - def test_highlight_update_risk(self): - """UPDATE 应被标记为中等风险。""" - result = SQLAssistant.highlight_sql("UPDATE users SET name='x' WHERE id=1") - assert len(result.risks) > 0 - risk_ops = [r["operation"] for r in result.risks] - assert "UPDATE" in risk_ops - - def test_highlight_drop_critical_risk(self): - """DROP 应被标记为关键风险。""" - result = SQLAssistant.highlight_sql("DROP TABLE users") - assert len(result.risks) > 0 - critical = [r for r in result.risks if r["level"] == "critical"] - assert len(critical) > 0 - - def test_highlight_update_no_where_critical(self): - """缺少 WHERE 的 UPDATE 应被标记为关键风险。""" - result = SQLAssistant.highlight_sql("UPDATE users SET name='x'") - assert len(result.risks) > 0 - critical = [r for r in result.risks if r["level"] == "critical"] - assert len(critical) > 0 - - def test_highlight_delete_no_where_critical(self): - """缺少 WHERE 的 DELETE 应被标记为关键风险。""" - result = SQLAssistant.highlight_sql("DELETE FROM users") - assert len(result.risks) > 0 - critical = [r for r in result.risks if r["level"] == "critical"] - assert len(critical) > 0 - - def test_highlight_select_no_risk(self): - """SELECT 语句不应有风险标记。""" - result = SQLAssistant.highlight_sql("SELECT id, name FROM users LIMIT 10") - assert len(result.risks) == 0 - - def test_highlight_produces_html(self): - """高亮结果应包含 HTML 标签。""" - result = SQLAssistant.highlight_sql("SELECT * FROM users") - assert "sql-keyword" in result.highlighted - - -# ===== EXPLAIN 解析 ===== - - -class TestExplainParse: - """MySQL EXPLAIN 结果解析测试。""" - - def test_parse_empty_explain(self): - """空 EXPLAIN 应返回警告。""" - result = SQLAssistant.parse_explain([]) - assert result.is_simple is True - assert len(result.warnings) > 0 - - def test_parse_simple_index_scan(self): - """索引扫描的 EXPLAIN 应被正确解析。""" - rows = [{ - "table": "users", - "type": "ref", - "key": "idx_status", - "rows": 100, - "Extra": "", - }] - result = SQLAssistant.parse_explain(rows) - assert result.has_index is True - assert result.has_full_scan is False - assert result.estimated_rows == 100 - - def test_parse_full_table_scan(self): - """全表扫描的 EXPLAIN 应标记警告。""" - rows = [{ - "table": "orders", - "type": "ALL", - "key": None, - "rows": 500000, - "Extra": "", - }] - result = SQLAssistant.parse_explain(rows) - assert result.has_full_scan is True - assert result.has_index is False - assert result.estimated_rows == 500000 - assert len(result.warnings) > 0 - - def test_parse_filesort_warning(self): - """Using filesort 应产生警告。""" - rows = [{ - "table": "users", - "type": "ref", - "key": "idx_status", - "rows": 100, - "Extra": "Using filesort", - }] - result = SQLAssistant.parse_explain(rows) - filesort_warnings = [w for w in result.warnings if "filesort" in w.lower() or "排序" in w] - assert len(filesort_warnings) > 0 - - def test_parse_temporary_table_warning(self): - """Using temporary 应产生警告。""" - rows = [{ - "table": "users", - "type": "ref", - "key": "idx_status", - "rows": 100, - "Extra": "Using temporary", - }] - result = SQLAssistant.parse_explain(rows) - temp_warnings = [w for w in result.warnings if "临时表" in w or "temporary" in w.lower()] - assert len(temp_warnings) > 0 - - def test_parse_large_rows_warning(self): - """预估行数过大应产生警告。""" - rows = [{ - "table": "big_table", - "type": "ALL", - "key": None, - "rows": 500000, - "Extra": "", - }] - result = SQLAssistant.parse_explain(rows) - large_warnings = [w for w in result.warnings if "较大" in w or "较慢" in w] - assert len(large_warnings) > 0 - - def test_parse_multi_table_join(self): - """多表 JOIN 应返回非简单查询。""" - rows = [ - {"table": "users", "type": "ref", "key": "PRIMARY", "rows": 1, "Extra": ""}, - {"table": "orders", "type": "ref", "key": "idx_user", "rows": 10, "Extra": ""}, - ] - result = SQLAssistant.parse_explain(rows) - assert result.is_simple is False - assert result.has_index is True - assert result.estimated_rows == 11 - - def test_parse_produces_human_readable(self): - """应生成人类可读的执行计划描述。""" - rows = [{ - "table": "users", - "type": "ref", - "key": "idx_name", - "rows": 50, - "Extra": "", - }] - result = SQLAssistant.parse_explain(rows) - assert result.human_readable != "" - assert "users" in result.human_readable - - def test_parse_case_insensitive_keys(self): - """应处理大写的 EXPLAIN 键名。""" - rows = [{ - "TABLE": "users", - "TYPE": "ALL", - "KEY": None, - "ROWS": 1000, - "EXTRA": "", - }] - result = SQLAssistant.parse_explain(rows) - assert result.estimated_rows == 1000 - - -# ===== 查询建议 ===== - - -class TestQuerySuggestions: - """SQL 查询建议测试。""" - - def test_select_star_suggestion(self): - """SELECT * 应触发优化建议。""" - suggestions = SQLAssistant.get_query_suggestions("SELECT * FROM users") - types = [s["type"] for s in suggestions] - assert "optimization" in types - - def test_no_limit_suggestion(self): - """缺少 LIMIT 应触发安全建议。""" - suggestions = SQLAssistant.get_query_suggestions("SELECT id, name FROM users WHERE status = 'active'") - msgs = [s["message"] for s in suggestions] - assert any("LIMIT" in m for m in msgs) - - def test_order_by_rand_suggestion(self): - """ORDER BY RAND() 应触发优化建议。""" - suggestions = SQLAssistant.get_query_suggestions("SELECT * FROM users ORDER BY RAND() LIMIT 5") - msgs = [s["message"] for s in suggestions] - assert any("RAND" in m for m in msgs) - - def test_update_no_where_suggestion(self): - """缺少 WHERE 的 UPDATE 应触发危险建议。""" - suggestions = SQLAssistant.get_query_suggestions("UPDATE users SET name='x'") - danger = [s for s in suggestions if s["type"] == "danger"] - assert len(danger) > 0 - - def test_delete_no_where_suggestion(self): - """缺少 WHERE 的 DELETE 应触发危险建议。""" - suggestions = SQLAssistant.get_query_suggestions("DELETE FROM users") - danger = [s for s in suggestions if s["type"] == "danger"] - assert len(danger) > 0 - - def test_complex_query_comment_suggestion(self): - """无注释的复杂查询应触发风格建议。""" - long_sql = "SELECT * FROM users WHERE " + " AND ".join(["column_name_" + str(i) + " = 'value_number_" + str(i) + "'" for i in range(30)]) - suggestions = SQLAssistant.get_query_suggestions(long_sql) - style = [s for s in suggestions if s["type"] == "style"] - assert len(style) > 0 - - def test_many_joins_suggestion(self): - """多个 JOIN 应触发优化建议。""" - sql = "SELECT * FROM a JOIN b ON a.id = b.aid JOIN c ON b.id = c.bid JOIN d ON c.id = d.cid JOIN e ON d.id = e.did" - suggestions = SQLAssistant.get_query_suggestions(sql) - join_suggestions = [s for s in suggestions if "JOIN" in s["message"]] - assert len(join_suggestions) > 0 - - def test_clean_select_no_suggestions(self): - """干净的 SELECT 查询不应有危险建议。""" - suggestions = SQLAssistant.get_query_suggestions("SELECT id, name FROM users WHERE status = 'active' LIMIT 10") - danger = [s for s in suggestions if s["type"] == "danger"] - assert len(danger) == 0 - - -# ===== 统一分析入口 ===== - - -class TestAnalyze: - """SQLAssistant.analyze 统一入口测试。""" - - def test_analyze_returns_all_fields(self): - """应返回所有分析字段。""" - result = SQLAssistant.analyze("SELECT * FROM users LIMIT 10") - assert "formatted" in result - assert "is_valid" in result - assert "highlighted" in result - assert "keywords" in result - assert "tables" in result - assert "risks" in result - assert "suggestions" in result - assert "risk_count" in result - assert "suggestion_count" in result - - def test_analyze_select_star(self): - """SELECT * 分析应有优化建议。""" - result = SQLAssistant.analyze("SELECT * FROM users") - assert result["suggestion_count"] > 0 - - def test_analyze_dangerous_update(self): - """危险 UPDATE 分析应有风险标记和建议。""" - result = SQLAssistant.analyze("UPDATE users SET name='x'") - assert result["risk_count"] > 0 - - def test_analyze_with_db_type(self): - """应支持指定数据库类型。""" - result = SQLAssistant.analyze("SELECT * FROM users", db_type="postgres") - assert result["is_valid"] is True diff --git a/backend/tests/test_system_settings.py b/backend/tests/test_system_settings.py deleted file mode 100644 index 9ce7eed..0000000 --- a/backend/tests/test_system_settings.py +++ /dev/null @@ -1,228 +0,0 @@ -"""系统设置 API 测试 — GET/PUT 设置 + 公共查询设置 + 查询集成。""" - -import pytest -from httpx import AsyncClient, ASGITransport -from app.main import app - -from tests.conftest import auth_header - - -async def _ensure_user(client: AsyncClient, admin_token: str, username: str, role: str = "developer") -> str: - """确保用户存在并返回 token。""" - from app.core.security import hash_password, create_access_token - from app.models.user import User - from sqlalchemy import select - from app.core.database import get_db - - # 通过 API 创建用户 - resp = await client.post("/api/v1/users", headers=auth_header(admin_token), json={ - "username": username, "password": "123456", - "display_name": username, "role": role - }) - if resp.status_code in (200, 201): - data = resp.json() - user_id = data.get("id") - return create_access_token(data={"sub": str(user_id), "role": role}) - # 如果已存在,尝试登录 - resp = await client.post("/api/v1/auth/login", json={"username": username, "password": "123456"}) - if resp.status_code == 200: - return resp.json()["access_token"] - # Fallback: 直接生成 token - raise RuntimeError(f"无法创建/获取用户 {username}: {resp.status_code} {resp.text}") - - -# ── 1. GET /settings ── - -@pytest.mark.asyncio -async def test_get_settings(client, admin_token): - """管理员可以获取所有系统设置。""" - token = admin_token - resp = await client.get("/api/v1/settings", headers=auth_header(token)) - assert resp.status_code == 200 - data = resp.json() - assert "query" in data - assert "security" in data - # 查询控制至少 3 项 (max_limit, timeout, auto_limit) - assert len(data["query"]) >= 3 - - -@pytest.mark.asyncio -async def test_get_settings_non_admin(client, admin_token): - """非管理员不能获取设置。""" - user_token = await _ensure_user(client, admin_token, "testuser_settings") - - resp = await client.get("/api/v1/settings", headers=auth_header(user_token)) - # require_role("admin") 应该返回 403 - assert resp.status_code == 403 - - -# ── 2. PUT /settings ── - -@pytest.mark.asyncio -async def test_update_settings_boolean(client, admin_token): - """更新布尔设置项。""" - token = admin_token - resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[ - {"key": "query_auto_limit", "value": "false"} - ]) - assert resp.status_code == 200 - assert "query_auto_limit" in resp.json()["updated"] - - # 验证 - resp = await client.get("/api/v1/settings", headers=auth_header(token)) - items = resp.json()["query"] - auto_limit = next(i for i in items if i["key"] == "query_auto_limit") - assert auto_limit["value"] == "false" - - # 恢复 - await client.put("/api/v1/settings", headers=auth_header(token), json=[ - {"key": "query_auto_limit", "value": "true"} - ]) - - -@pytest.mark.asyncio -async def test_update_settings_integer(client, admin_token): - """更新整数设置项。""" - token = admin_token - resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[ - {"key": "query_max_limit", "value": "5000"} - ]) - assert resp.status_code == 200 - - # 恢复 - await client.put("/api/v1/settings", headers=auth_header(token), json=[ - {"key": "query_max_limit", "value": "10000"} - ]) - - -@pytest.mark.asyncio -async def test_update_settings_validation(client, admin_token): - """设置值校验。""" - token = admin_token - - # 布尔值错误 - resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[ - {"key": "query_auto_limit", "value": "yes"} - ]) - assert resp.status_code == 400 - - # 整数范围错误 (query_max_limit min=100) - resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[ - {"key": "query_max_limit", "value": "10"} - ]) - assert resp.status_code == 400 - - # 超时范围错误 (query_timeout min=5) - resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[ - {"key": "query_timeout", "value": "1"} - ]) - assert resp.status_code == 400 - - -# ── 3. GET /settings/public/query ── - -@pytest.mark.asyncio -async def test_public_query_settings(client): - """公共查询设置无需鉴权。""" - resp = await client.get("/api/v1/settings/public/query") - assert resp.status_code == 200 - data = resp.json() - assert "query_max_limit" in data - assert "query_timeout" in data - assert isinstance(data["query_max_limit"], int) - - -# ── 4. 查询集成 — 审批开关 ── - -@pytest.mark.asyncio -async def test_query_require_approval(client, admin_token): - """开启查询审批后普通用户无法直接查询。""" - user_token = await _ensure_user(client, admin_token, "testuser_approval") - - # 开启查询审批 - await client.put("/api/v1/settings", headers=auth_header(admin_token), json=[ - {"key": "query_require_approval", "value": "true"} - ]) - - # 普通用户查询应被拦截 - resp = await client.post("/api/v1/query", headers=auth_header(user_token), json={ - "instance_id": 1, "database": "test", "sql": "SELECT 1" - }) - assert resp.status_code == 403 - assert "审批" in resp.json()["detail"] - - # 管理员仍然可以查询(不被审批限制拦截,可能因连接报其他错) - resp = await client.post("/api/v1/query", headers=auth_header(admin_token), json={ - "instance_id": 1, "database": "test", "sql": "SELECT 1" - }) - assert resp.status_code != 403, "管理员不应被审批限制拦截" - - # 恢复 - await client.put("/api/v1/settings", headers=auth_header(admin_token), json=[ - {"key": "query_require_approval", "value": "false"} - ]) - - -# ── 5. 查询集成 — 语句长度检查 ── - -@pytest.mark.asyncio -async def test_query_max_length(client, admin_token): - """超长查询语句被拦截。""" - # 设置较短限制(在合法范围 500~50000 内) - await client.put("/api/v1/settings", headers=auth_header(admin_token), json=[ - {"key": "query_max_length", "value": "500"} - ]) - - # 超长 SQL(> 500 字符) - long_sql = "SELECT * FROM users WHERE id = " + "1" * 600 - resp = await client.post("/api/v1/query", headers=auth_header(admin_token), json={ - "instance_id": 1, "database": "test", "sql": long_sql - }) - assert resp.status_code == 400, f"Expected 400, got {resp.status_code}: {resp.text}" - assert "过长" in resp.json()["detail"] - - # 正常长度 SQL 不被拦截(可能因连接失败但不应是400长度错误) - short_sql = "SELECT 1" - resp = await client.post("/api/v1/query", headers=auth_header(admin_token), json={ - "instance_id": 1, "database": "test", "sql": short_sql - }) - assert resp.status_code != 400 or "过长" not in resp.text - - # 恢复 - await client.put("/api/v1/settings", headers=auth_header(admin_token), json=[ - {"key": "query_max_length", "value": "50000"} - ]) - - -# ── 6. 批量更新 ── - -@pytest.mark.asyncio -async def test_bulk_update(client, admin_token): - """批量更新多个设置。""" - token = admin_token - resp = await client.put("/api/v1/settings", headers=auth_header(token), json=[ - {"key": "query_timeout", "value": "60"}, - {"key": "query_max_limit", "value": "2000"}, - ]) - assert resp.status_code == 200 - assert len(resp.json()["updated"]) == 2 - - # 恢复 - await client.put("/api/v1/settings", headers=auth_header(token), json=[ - {"key": "query_timeout", "value": "30"}, - {"key": "query_max_limit", "value": "10000"}, - ]) - - -# ── 7. 设置种子数据自动初始化 ── - -@pytest.mark.asyncio -async def test_settings_auto_seed(client, admin_token): - """设置接口自动初始化种子数据。""" - token = admin_token - resp = await client.get("/api/v1/settings", headers=auth_header(token)) - assert resp.status_code == 200 - data = resp.json() - # 确认至少 9 个设置项 (query:3 + security:2 + cleanup:3 + executor:1) - total = sum(len(v) for v in data.values()) - assert total >= 9 diff --git a/backend/tests/test_ticket_executor.py b/backend/tests/test_ticket_executor.py deleted file mode 100644 index c81b144..0000000 --- a/backend/tests/test_ticket_executor.py +++ /dev/null @@ -1,601 +0,0 @@ -"""TicketExecutor 多数据库工单执行引擎测试。 - -覆盖: -1. ExecutionReport 序列化/反序列化 -2. MySQL 语法预校验 -3. PostgreSQL 语法预校验 -4. Redis 命令解析和校验 -5. MongoDB 操作解析和校验 -6. SQL 工具方法: 表名提取、WHERE 子句提取、语句分割 -""" - -import json -import pytest -from unittest.mock import MagicMock - -from app.services.ticket_executor import ( - TicketExecutor, - ExecutionReport, - SyntaxValidationError, - REDIS_WRITE_COMMANDS, - MONGO_SUPPORTED_OPERATIONS, -) -from app.models.instance import Instance -from app.models.ticket import Ticket - - -# ===== ExecutionReport 测试 ===== - - -class TestExecutionReport: - """ExecutionReport 数据类测试。""" - - def test_default_values(self): - """默认值应为 success。""" - report = ExecutionReport() - assert report.status == "success" - assert report.affected_rows == 0 - assert report.duration_ms == 0 - assert report.validation_passed is True - assert report.warnings == [] - assert report.errors == [] - - def test_to_json(self): - """应正确序列化为 JSON。""" - report = ExecutionReport( - status="success", - affected_rows=10, - duration_ms=150, - before_snapshot="10 rows", - after_snapshot="15 rows", - validation_passed=True, - warnings=["UPDATE 缺少 WHERE"], - errors=[], - raw_output="UPDATE 10 rows", - ) - j = report.to_json() - data = json.loads(j) - assert data["status"] == "success" - assert data["affected_rows"] == 10 - assert data["duration_ms"] == 150 - assert data["warnings"] == ["UPDATE 缺少 WHERE"] - - def test_from_exception(self): - """从异常创建失败的报告。""" - exc = RuntimeError("连接超时") - report = ExecutionReport.from_exception(exc, "执行") - assert report.status == "failed" - assert report.validation_passed is False - assert len(report.errors) == 1 - assert "连接超时" in report.errors[0] - - def test_to_json_chinese(self): - """JSON 序列化应保留中文字符。""" - report = ExecutionReport(raw_output="更新了3行数据") - j = report.to_json() - assert "更新了3行数据" in j - - -# ===== TicketExecutor 初始化 ===== - - -class TestTicketExecutorInit: - """TicketExecutor 初始化测试。""" - - def test_init_creates_analyzer(self): - """初始化应创建 SQLAnalyzer。""" - executor = TicketExecutor() - assert executor._analyzer is not None - - -# ===== MySQL 语法预校验 ===== - - -class TestMySQLSyntaxValidation: - """MySQL 语法预校验测试。""" - - def setup_method(self): - self.executor = TicketExecutor() - - def test_empty_sql_rejected(self): - """空 SQL 应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="不能为空"): - self.executor._validate_mysql_syntax("") - - def test_whitespace_sql_rejected(self): - """纯空格 SQL 应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="不能为空"): - self.executor._validate_mysql_syntax(" ") - - def test_insert_pass(self): - """INSERT 语句应通过校验。""" - self.executor._validate_mysql_syntax("INSERT INTO users (name) VALUES ('test')") - - def test_update_pass(self): - """UPDATE 语句应通过校验。""" - self.executor._validate_mysql_syntax("UPDATE users SET name='x' WHERE id=1") - - def test_delete_pass(self): - """DELETE 语句应通过校验。""" - self.executor._validate_mysql_syntax("DELETE FROM users WHERE id=1") - - def test_drop_pass(self): - """DROP 语句应通过校验(DDL 通过工单审批执行)。""" - self.executor._validate_mysql_syntax("DROP TABLE users") - - def test_create_pass(self): - """CREATE 语句应通过校验。""" - self.executor._validate_mysql_syntax("CREATE TABLE test (id INT)") - - def test_alter_pass(self): - """ALTER 语句应通过校验。""" - self.executor._validate_mysql_syntax("ALTER TABLE users ADD COLUMN age INT") - - def test_truncate_pass(self): - """TRUNCATE 语句应通过校验。""" - self.executor._validate_mysql_syntax("TRUNCATE TABLE users") - - -# ===== PostgreSQL 语法预校验 ===== - - -class TestPostgresSyntaxValidation: - """PostgreSQL 语法预校验测试。""" - - def setup_method(self): - self.executor = TicketExecutor() - - def test_insert_pass(self): - """INSERT 语句应通过校验。""" - self.executor._validate_postgres_syntax("INSERT INTO users (name) VALUES ('test')") - - def test_update_pass(self): - """UPDATE 语句应通过校验。""" - self.executor._validate_postgres_syntax("UPDATE users SET name='x' WHERE id=1") - - def test_delete_pass(self): - """DELETE 语句应通过校验。""" - self.executor._validate_postgres_syntax("DELETE FROM users WHERE id=1") - - def test_drop_pass(self): - """DROP 语句应通过校验(DDL 通过工单审批执行)。""" - self.executor._validate_postgres_syntax("DROP TABLE users") - - -# ===== Redis 命令解析 ===== - - -class TestRedisCommandParsing: - """Redis 命令解析测试。""" - - def setup_method(self): - self.executor = TicketExecutor() - - def test_parse_json_commands(self): - """应解析 JSON 格式的命令列表。""" - content = json.dumps({ - "commands": [ - {"command": "SET key1 value1", "db": 0}, - {"command": "SET key2 value2", "db": 1}, - ] - }) - commands = self.executor._parse_redis_commands(content) - assert len(commands) == 2 - assert commands[0]["command"] == "SET key1 value1" - assert commands[0]["db"] == 0 - assert commands[1]["db"] == 1 - - def test_parse_json_single_command(self): - """应解析单条 JSON 命令。""" - content = json.dumps({"command": "SET key1 value1", "db": 2}) - commands = self.executor._parse_redis_commands(content) - assert len(commands) == 1 - assert commands[0]["command"] == "SET key1 value1" - assert commands[0]["db"] == 2 - - def test_parse_text_commands(self): - """应解析文本格式命令(每行一个)。""" - content = "SET key1 value1\nSET key2 value2\n# this is a comment" - commands = self.executor._parse_redis_commands(content) - assert len(commands) == 2 - assert commands[0]["command"] == "SET key1 value1" - assert commands[1]["command"] == "SET key2 value2" - - def test_parse_empty_content(self): - """空内容应返回空列表。""" - commands = self.executor._parse_redis_commands("") - assert commands == [] - - def test_parse_invalid_json_as_text(self): - """无效 JSON 应作为文本解析。""" - content = "{not valid json" - commands = self.executor._parse_redis_commands(content) - # Should fall through to text parsing - assert len(commands) == 1 - assert commands[0]["command"] == content - - -# ===== Redis 命令校验 ===== - - -class TestRedisCommandValidation: - """Redis 命令格式校验测试。""" - - def setup_method(self): - self.executor = TicketExecutor() - - def test_valid_set_command(self): - """合法的 SET 命令应通过校验。""" - self.executor._validate_redis_commands([{"command": "SET key1 value1", "db": 0}]) - - def test_valid_hset_command(self): - """合法的 HSET 命令应通过校验。""" - self.executor._validate_redis_commands([{"command": "HSET myhash field value", "db": 0}]) - - def test_empty_command_rejected(self): - """空命令应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="不能为空"): - self.executor._validate_redis_commands([{"command": "", "db": 0}]) - - def test_no_args_command_with_ping(self): - """PING 等无参数命令应通过。""" - self.executor._validate_redis_commands([{"command": "PING", "db": 0}]) - - def test_no_args_command_without_special(self): - """需要参数的命令缺少参数应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="缺少参数"): - self.executor._validate_redis_commands([{"command": "GET", "db": 0}]) - - def test_invalid_command_name_rejected(self): - """非法命令名应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="非法"): - self.executor._validate_redis_commands([{"command": "123BAD key", "db": 0}]) - - -# ===== MongoDB 操作解析 ===== - - -class TestMongoOperationParsing: - """MongoDB 操作解析测试。""" - - def setup_method(self): - self.executor = TicketExecutor() - - def test_parse_valid_insert(self): - """应解析有效的 insertOne 操作。""" - content = json.dumps({ - "collection": "users", - "operation": "insertOne", - "document": {"name": "test", "age": 25}, - }) - result = self.executor._parse_mongo_operation(content) - assert result["collection"] == "users" - assert result["operation"] == "insertOne" - assert result["document"]["name"] == "test" - - def test_parse_valid_update(self): - """应解析有效的 updateMany 操作。""" - content = json.dumps({ - "collection": "users", - "operation": "updateMany", - "filter": {"status": "inactive"}, - "update": {"$set": {"status": "active"}}, - }) - result = self.executor._parse_mongo_operation(content) - assert result["operation"] == "updateMany" - - def test_parse_empty_content_rejected(self): - """空内容应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="不能为空"): - self.executor._parse_mongo_operation("") - - def test_parse_invalid_json_rejected(self): - """无效 JSON 应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="JSON"): - self.executor._parse_mongo_operation("not json") - - def test_parse_non_dict_rejected(self): - """非 JSON 对象应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="JSON 对象"): - self.executor._parse_mongo_operation("[1, 2, 3]") - - -# ===== MongoDB 操作校验 ===== - - -class TestMongoOperationValidation: - """MongoDB 操作格式校验测试。""" - - def setup_method(self): - self.executor = TicketExecutor() - - def test_insertone_valid(self): - """insertOne 应通过校验。""" - self.executor._validate_mongo_operation({ - "collection": "users", - "operation": "insertOne", - "document": {"name": "test"}, - }) - - def test_insertmany_valid(self): - """insertMany 应通过校验。""" - self.executor._validate_mongo_operation({ - "collection": "users", - "operation": "insertMany", - "documents": [{"name": "a"}, {"name": "b"}], - }) - - def test_updateone_valid(self): - """updateOne 应通过校验。""" - self.executor._validate_mongo_operation({ - "collection": "users", - "operation": "updateOne", - "filter": {"_id": 1}, - "update": {"$set": {"name": "x"}}, - }) - - def test_updatemany_valid(self): - """updateMany 应通过校验。""" - self.executor._validate_mongo_operation({ - "collection": "users", - "operation": "updateMany", - "filter": {"status": "old"}, - "update": {"$set": {"status": "new"}}, - }) - - def test_deleteone_valid(self): - """deleteOne 应通过校验。""" - self.executor._validate_mongo_operation({ - "collection": "users", - "operation": "deleteOne", - "filter": {"_id": 1}, - }) - - def test_deletemany_valid(self): - """deleteMany 应通过校验。""" - self.executor._validate_mongo_operation({ - "collection": "users", - "operation": "deleteMany", - "filter": {"status": "inactive"}, - }) - - def test_replaceone_valid(self): - """replaceOne 应通过校验。""" - self.executor._validate_mongo_operation({ - "collection": "users", - "operation": "replaceOne", - "filter": {"_id": 1}, - "document": {"name": "new"}, - }) - - def test_missing_collection_rejected(self): - """缺少 collection 字段应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="collection"): - self.executor._validate_mongo_operation({ - "operation": "insertOne", - "document": {"name": "test"}, - }) - - def test_missing_operation_rejected(self): - """缺少 operation 字段应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="操作类型"): - self.executor._validate_mongo_operation({ - "collection": "users", - }) - - def test_unsupported_operation_rejected(self): - """不支持的操作应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="不支持"): - self.executor._validate_mongo_operation({ - "collection": "users", - "operation": "createIndex", - "keys": {"name": 1}, - }) - - def test_insertone_missing_document_rejected(self): - """insertOne 缺少 document 应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="document"): - self.executor._validate_mongo_operation({ - "collection": "users", - "operation": "insertOne", - }) - - def test_insertmany_missing_documents_rejected(self): - """insertMany 缺少 documents 应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="documents"): - self.executor._validate_mongo_operation({ - "collection": "users", - "operation": "insertMany", - }) - - def test_updateone_missing_filter_rejected(self): - """updateOne 缺少 filter 应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="filter"): - self.executor._validate_mongo_operation({ - "collection": "users", - "operation": "updateOne", - "update": {"$set": {"x": 1}}, - }) - - def test_updateone_missing_update_rejected(self): - """updateOne 缺少 update 应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="update"): - self.executor._validate_mongo_operation({ - "collection": "users", - "operation": "updateOne", - "filter": {"_id": 1}, - }) - - def test_replaceone_missing_document_rejected(self): - """replaceOne 缺少 document 应被拒绝。""" - with pytest.raises(SyntaxValidationError, match="document"): - self.executor._validate_mongo_operation({ - "collection": "users", - "operation": "replaceOne", - "filter": {"_id": 1}, - }) - - -# ===== SQL 工具方法 ===== - - -class TestSQLUtilities: - """SQL 工具方法测试。""" - - def setup_method(self): - self.executor = TicketExecutor() - - def test_split_statements_basic(self): - """应按分号分割语句。""" - sql = "INSERT INTO t1 VALUES (1); INSERT INTO t2 VALUES (2);" - stmts = self.executor._split_statements(sql) - assert len(stmts) == 2 - - def test_split_statements_single(self): - """单条语句无分号应返回一条。""" - sql = "INSERT INTO t1 VALUES (1)" - stmts = self.executor._split_statements(sql) - assert len(stmts) == 1 - - def test_split_statements_empty(self): - """空内容应返回空列表。""" - stmts = self.executor._split_statements("") - assert stmts == [] - - def test_extract_table_name_insert(self): - """应从 INSERT 语句中提取表名。""" - from app.services.sql_analyzer import SQLType - name = self.executor._extract_table_name( - "INSERT INTO users (name) VALUES ('test')", SQLType.INSERT - ) - assert name == "USERS" - - def test_extract_table_name_update(self): - """应从 UPDATE 语句中提取表名。""" - from app.services.sql_analyzer import SQLType - name = self.executor._extract_table_name( - "UPDATE users SET name='x' WHERE id=1", SQLType.UPDATE - ) - assert name == "USERS" - - def test_extract_table_name_delete(self): - """应从 DELETE 语句中提取表名。""" - from app.services.sql_analyzer import SQLType - name = self.executor._extract_table_name( - "DELETE FROM users WHERE id=1", SQLType.DELETE - ) - assert name == "USERS" - - def test_extract_where_clause_found(self): - """应从带 WHERE 的 SQL 中提取条件。""" - where = self.executor._extract_where_clause_text( - "UPDATE users SET name='x' WHERE id=1 AND status='active'" - ) - assert where is not None - assert "id=1" in where - - def test_extract_where_clause_not_found(self): - """没有 WHERE 的 SQL 应返回 None。""" - where = self.executor._extract_where_clause_text( - "INSERT INTO users (name) VALUES ('test')" - ) - assert where is None - - def test_format_redis_result_nil(self): - """None 应格式化为 (nil)。""" - result = self.executor._format_redis_result(None) - assert result == "(nil)" - - def test_format_redis_result_bool(self): - """布尔值应格式化为 1/0。""" - assert self.executor._format_redis_result(True) == "1" - assert self.executor._format_redis_result(False) == "0" - - def test_format_redis_result_list(self): - """列表应格式化为方括号。""" - result = self.executor._format_redis_result([b"a", b"b", b"c"]) - assert "[" in result - assert "a" in result - - def test_format_redis_result_string(self): - """字符串应原样返回。""" - result = self.executor._format_redis_result("OK") - assert result == "OK" - - -# ===== 路由分发 ===== - - -class TestTicketExecutorRouting: - """TicketExecutor 路由分发测试。""" - - def setup_method(self): - self.executor = TicketExecutor() - - def _make_ticket(self, sql_content, db_type="mysql", database="test_db"): - """创建测试用 Ticket 对象。""" - ticket = MagicMock(spec=Ticket) - ticket.sql_content = sql_content - ticket.target_database = database - return ticket - - def _make_instance(self, db_type="mysql"): - """创建测试用 Instance 对象。""" - instance = MagicMock(spec=Instance) - instance.db_type = db_type - instance.host = "127.0.0.1" - instance.port = 3306 - instance.username = "root" - instance.password = "test123" - instance.database = "test_db" - return instance - - @pytest.mark.asyncio - async def test_unsupported_db_type_raises(self): - """不支持的数据库类型应抛出 ValueError。""" - ticket = self._make_ticket("SELECT 1") - instance = self._make_instance("oracle") - with pytest.raises(ValueError, match="不支持的执行类型"): - await self.executor.execute(ticket, instance) - - @pytest.mark.asyncio - async def test_mysql_drop_rejected(self): - """MySQL DROP 语句应被语法预校验拒绝。""" - ticket = self._make_ticket("DROP TABLE users") - instance = self._make_instance("mysql") - report = await self.executor.execute(ticket, instance) - assert report.status == "failed" - assert report.validation_passed is False - - @pytest.mark.asyncio - async def test_postgres_drop_rejected(self): - """PostgreSQL DROP 语句应被语法预校验拒绝。""" - ticket = self._make_ticket("DROP TABLE users") - instance = self._make_instance("postgres") - report = await self.executor.execute(ticket, instance) - assert report.status == "failed" - assert report.validation_passed is False - - @pytest.mark.asyncio - async def test_redis_empty_commands_rejected(self): - """Redis 空命令应被拒绝。""" - ticket = self._make_ticket("") - instance = self._make_instance("redis") - report = await self.executor.execute(ticket, instance) - assert report.status == "failed" - - @pytest.mark.asyncio - async def test_mongo_invalid_json_rejected(self): - """MongoDB 无效 JSON 应被拒绝。""" - ticket = self._make_ticket("not json") - instance = self._make_instance("mongo") - report = await self.executor.execute(ticket, instance) - assert report.status == "failed" - - @pytest.mark.asyncio - async def test_mongo_missing_collection_rejected(self): - """MongoDB 缺少 collection 应被拒绝。""" - ticket = self._make_ticket(json.dumps({"operation": "insertOne"})) - instance = self._make_instance("mongo") - report = await self.executor.execute(ticket, instance) - assert report.status == "failed" diff --git a/backend/tests/test_tickets.py b/backend/tests/test_tickets.py deleted file mode 100644 index cfe60d4..0000000 --- a/backend/tests/test_tickets.py +++ /dev/null @@ -1,176 +0,0 @@ -from httpx import AsyncClient - -from tests.conftest import auth_header - - -async def _create_ticket(client: AsyncClient, token: str, instance_id: int): - return await client.post("/api/v1/tickets", headers=auth_header(token), json={ - "title": "测试工单标题", - "db_type": "mysql", - "instance_id": instance_id, - "target_database": "test_db", - "sql_content": "SELECT * FROM users LIMIT 10;", - "rollback_sql": "", - "reason": "测试原因12345", - "priority": "medium", - }) - - -class TestListTickets: - async def test_developer_sees_own_tickets( - self, client: AsyncClient, developer_user, developer_token, test_instance - ): - await _create_ticket(client, developer_token, test_instance.id) - resp = await client.get("/api/v1/tickets", headers=auth_header(developer_token)) - assert resp.status_code == 200 - data = resp.json() - for item in data["items"]: - assert item["applicant_id"] == developer_user.id - - async def test_admin_sees_all_tickets( - self, client: AsyncClient, admin_token, developer_user, developer_token, - test_instance, admin_user, - ): - await _create_ticket(client, developer_token, test_instance.id) - resp = await client.get("/api/v1/tickets", headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert data["total"] >= 1 - - -class TestCreateTicket: - async def test_create_ticket_success( - self, client: AsyncClient, developer_token, test_instance - ): - resp = await _create_ticket(client, developer_token, test_instance.id) - assert resp.status_code == 201 - data = resp.json() - assert data["status"] == "pending" - assert data["title"] == "测试工单标题" - - async def test_create_ticket_instance_not_found(self, client: AsyncClient, developer_token): - resp = await _create_ticket(client, developer_token, instance_id=9999) - assert resp.status_code == 404 - - -class TestGetTicket: - async def test_get_ticket_detail( - self, client: AsyncClient, developer_token, test_instance - ): - create_resp = await _create_ticket(client, developer_token, test_instance.id) - ticket_id = create_resp.json()["id"] - resp = await client.get(f"/api/v1/tickets/{ticket_id}", headers=auth_header(developer_token)) - assert resp.status_code == 200 - assert resp.json()["id"] == ticket_id - - async def test_developer_cannot_see_others_ticket( - self, client: AsyncClient, admin_token, admin_user, - developer_token, developer_user, test_instance, - ): - create_resp = await _create_ticket(client, developer_token, test_instance.id) - ticket_id = create_resp.json()["id"] - # admin creates a separate developer to test access - resp = await client.get(f"/api/v1/tickets/{ticket_id}", headers=auth_header(admin_token)) - assert resp.status_code == 200 # admin can see all - - -class TestReviewTicket: - async def test_approve_ticket( - self, client: AsyncClient, developer_token, dba_token, - dba_user, test_instance, - ): - create_resp = await _create_ticket(client, developer_token, test_instance.id) - ticket_id = create_resp.json()["id"] - resp = await client.post( - f"/api/v1/tickets/{ticket_id}/review", - headers=auth_header(dba_token), - json={"action": "approve", "comment": "通过"}, - ) - assert resp.status_code == 200 - assert resp.json()["status"] == "approved" - - async def test_reject_ticket( - self, client: AsyncClient, developer_token, dba_token, test_instance, - ): - create_resp = await _create_ticket(client, developer_token, test_instance.id) - ticket_id = create_resp.json()["id"] - resp = await client.post( - f"/api/v1/tickets/{ticket_id}/review", - headers=auth_header(dba_token), - json={"action": "reject", "comment": "拒绝"}, - ) - assert resp.status_code == 200 - assert resp.json()["status"] == "rejected" - - async def test_non_dba_cannot_review( - self, client: AsyncClient, developer_token, test_instance, - ): - create_resp = await _create_ticket(client, developer_token, test_instance.id) - ticket_id = create_resp.json()["id"] - resp = await client.post( - f"/api/v1/tickets/{ticket_id}/review", - headers=auth_header(developer_token), - json={"action": "approve", "comment": "非法审批"}, - ) - assert resp.status_code == 403 - - async def test_review_already_executed_ticket( - self, client: AsyncClient, developer_token, dba_token, test_instance, - ): - create_resp = await _create_ticket(client, developer_token, test_instance.id) - ticket_id = create_resp.json()["id"] - # Approve first - await client.post( - f"/api/v1/tickets/{ticket_id}/review", - headers=auth_header(dba_token), - json={"action": "approve", "comment": "通过"}, - ) - # The status is "approved" which is not in ("pending", "reviewing") so re-review should fail - resp = await client.post( - f"/api/v1/tickets/{ticket_id}/review", - headers=auth_header(dba_token), - json={"action": "approve", "comment": "再次审批"}, - ) - assert resp.status_code == 400 - - -class TestExecuteTicket: - async def test_execute_unapproved_ticket( - self, client: AsyncClient, developer_token, dba_token, test_instance, - ): - create_resp = await _create_ticket(client, developer_token, test_instance.id) - ticket_id = create_resp.json()["id"] - resp = await client.post( - f"/api/v1/tickets/{ticket_id}/execute", - headers=auth_header(dba_token), - ) - assert resp.status_code == 400 - - -class TestTicketStatistics: - async def test_ticket_statistics( - self, client: AsyncClient, developer_token, test_instance, - ): - await _create_ticket(client, developer_token, test_instance.id) - resp = await client.get("/api/v1/tickets/statistics", headers=auth_header(developer_token)) - assert resp.status_code == 200 - data = resp.json() - assert "total" in data - assert "pending" in data - - -class TestPagination: - async def test_pagination( - self, client: AsyncClient, developer_token, test_instance, - ): - for i in range(3): - await _create_ticket(client, developer_token, test_instance.id) - resp = await client.get( - "/api/v1/tickets?page=1&page_size=2", - headers=auth_header(developer_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["page"] == 1 - assert data["page_size"] == 2 - assert len(data["items"]) <= 2 diff --git a/backend/tests/test_user.py b/backend/tests/test_user.py deleted file mode 100644 index 5bec14f..0000000 --- a/backend/tests/test_user.py +++ /dev/null @@ -1,458 +0,0 @@ -"""用户管理端点完整测试 — user.py - -覆盖端点: - GET /api/v1/users 列表(支持 role / is_active 筛选) - POST /api/v1/users 创建用户 - GET /api/v1/users/{user_id} 获取单个用户 - PUT /api/v1/users/{user_id} 更新用户 - DELETE /api/v1/users/{user_id} 删除用户 -""" -import pytest -from httpx import AsyncClient - -from tests.conftest import auth_header - - -# ═══════════════════════════════════════════════════════════════ -# GET /users — 列表 -# ═══════════════════════════════════════════════════════════════ - - -class TestListUsers: - - async def test_list_all_users(self, client: AsyncClient, admin_user, admin_token): - """管理员可以获取全部用户列表。""" - resp = await client.get("/api/v1/users", headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - items = data.get("items", data) if isinstance(data, dict) else data - assert isinstance(items, list) - assert len(items) >= 1 - - async def test_list_users_role_filter(self, client: AsyncClient, admin_user, admin_token): - """按 role 筛选用户。""" - resp = await client.get( - "/api/v1/users?role=admin", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - items = data.get("items", data) if isinstance(data, dict) else data - for u in items: - assert u["role"] == "admin" - - async def test_list_users_is_active_filter( - self, client: AsyncClient, admin_user, disabled_user, admin_token - ): - """按 is_active 筛选用户。""" - resp = await client.get( - "/api/v1/users?is_active=false", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - items = data.get("items", data) if isinstance(data, dict) else data - for u in items: - assert u["is_active"] is False - - async def test_list_users_combined_filters( - self, client: AsyncClient, admin_user, disabled_user, admin_token - ): - """同时使用 role + is_active 筛选。""" - resp = await client.get( - "/api/v1/users?role=developer&is_active=false", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - items = data.get("items", data) if isinstance(data, dict) else data - for u in items: - assert u["role"] == "developer" - assert u["is_active"] is False - - async def test_list_users_empty_result(self, client: AsyncClient, admin_token): - """不存在的角色返回空列表。""" - resp = await client.get( - "/api/v1/users?role=guest", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - items = data.get("items", data) if isinstance(data, dict) else data - assert items == [] - - async def test_list_users_non_admin_forbidden( - self, client: AsyncClient, developer_token - ): - """developer 角色无权访问用户列表。""" - resp = await client.get( - "/api/v1/users", headers=auth_header(developer_token) - ) - assert resp.status_code == 403 - - async def test_list_users_guest_forbidden( - self, client: AsyncClient, guest_token - ): - """guest 角色无权访问用户列表。""" - resp = await client.get( - "/api/v1/users", headers=auth_header(guest_token) - ) - assert resp.status_code == 403 - - async def test_list_users_unauthenticated(self, client: AsyncClient): - """未认证请求返回 401。""" - resp = await client.get("/api/v1/users") - assert resp.status_code == 401 - - -# ═══════════════════════════════════════════════════════════════ -# POST /users — 创建 -# ═══════════════════════════════════════════════════════════════ - - -class TestCreateUser: - - async def test_create_user_success(self, client: AsyncClient, admin_token): - """成功创建用户。""" - resp = await client.post( - "/api/v1/users", - headers=auth_header(admin_token), - json={ - "username": "newuser", - "display_name": "新用户", - "password": "secure123", - "role": "developer", - }, - ) - assert resp.status_code == 201 - data = resp.json() - assert data["username"] == "newuser" - assert data["display_name"] == "新用户" - assert data["role"] == "developer" - assert "id" in data - assert "hashed_password" not in data - - async def test_create_user_with_all_fields(self, client: AsyncClient, admin_token): - """创建用户时填写所有可选字段。""" - resp = await client.post( - "/api/v1/users", - headers=auth_header(admin_token), - json={ - "username": "fulluser", - "display_name": "完整用户", - "password": "secure123", - "email": "full@example.com", - "phone": "13800138000", - "role": "dba", - }, - ) - assert resp.status_code == 201 - data = resp.json() - assert data["email"] == "full@example.com" - assert data["phone"] == "13800138000" - assert data["role"] == "dba" - - async def test_create_duplicate_username( - self, client: AsyncClient, admin_user, admin_token - ): - """用户名重复返回 400。""" - resp = await client.post( - "/api/v1/users", - headers=auth_header(admin_token), - json={ - "username": "admin", - "display_name": "重复", - "password": "123456", - "role": "developer", - }, - ) - assert resp.status_code == 400 - assert "已存在" in resp.json()["detail"] - - async def test_create_user_short_password(self, client: AsyncClient, admin_token): - """密码少于 6 位返回 422。""" - resp = await client.post( - "/api/v1/users", - headers=auth_header(admin_token), - json={ - "username": "shortpw", - "display_name": "短密码", - "password": "123", - "role": "developer", - }, - ) - assert resp.status_code == 422 - - async def test_create_user_invalid_role(self, client: AsyncClient, admin_token): - """无效角色返回 422。""" - resp = await client.post( - "/api/v1/users", - headers=auth_header(admin_token), - json={ - "username": "badrole", - "display_name": "坏角色", - "password": "123456", - "role": "superadmin", - }, - ) - assert resp.status_code == 422 - - async def test_create_user_missing_username(self, client: AsyncClient, admin_token): - """缺少 username 字段返回 422。""" - resp = await client.post( - "/api/v1/users", - headers=auth_header(admin_token), - json={ - "display_name": "无用户名", - "password": "123456", - "role": "developer", - }, - ) - assert resp.status_code == 422 - - async def test_create_user_non_admin_forbidden( - self, client: AsyncClient, developer_token - ): - """developer 角色无权创建用户。""" - resp = await client.post( - "/api/v1/users", - headers=auth_header(developer_token), - json={ - "username": "unauthorized", - "display_name": "不应成功", - "password": "123456", - "role": "developer", - }, - ) - assert resp.status_code == 403 - - async def test_create_user_default_role(self, client: AsyncClient, admin_token): - """不传 role 时默认为 developer。""" - resp = await client.post( - "/api/v1/users", - headers=auth_header(admin_token), - json={ - "username": "defaultrole", - "display_name": "默认角色", - "password": "123456", - }, - ) - assert resp.status_code == 201 - assert resp.json()["role"] == "developer" - - -# ═══════════════════════════════════════════════════════════════ -# GET /users/{user_id} — 获取单个 -# ═══════════════════════════════════════════════════════════════ - - -class TestGetUser: - - async def test_get_user_success( - self, client: AsyncClient, admin_user, admin_token - ): - """成功获取单个用户。""" - resp = await client.get( - f"/api/v1/users/{admin_user.id}", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["username"] == "admin" - - async def test_get_user_nonexistent(self, client: AsyncClient, admin_token): - """查询不存在的用户返回 404。""" - resp = await client.get( - "/api/v1/users/9999", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - assert "不存在" in resp.json()["detail"] - - async def test_get_user_non_admin_forbidden( - self, client: AsyncClient, admin_user, developer_token - ): - """developer 角色无权查看用户详情。""" - resp = await client.get( - f"/api/v1/users/{admin_user.id}", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - async def test_get_user_unauthenticated(self, client: AsyncClient, admin_user): - """未认证请求返回 401。""" - resp = await client.get(f"/api/v1/users/{admin_user.id}") - assert resp.status_code == 401 - - -# ═══════════════════════════════════════════════════════════════ -# PUT /users/{user_id} — 更新 -# ═══════════════════════════════════════════════════════════════ - - -class TestUpdateUser: - - async def test_update_display_name( - self, client: AsyncClient, admin_user, admin_token - ): - """更新 display_name。""" - resp = await client.put( - f"/api/v1/users/{admin_user.id}", - headers=auth_header(admin_token), - json={"display_name": "管理员改名"}, - ) - assert resp.status_code == 200 - assert resp.json()["display_name"] == "管理员改名" - - async def test_update_email( - self, client: AsyncClient, admin_user, admin_token - ): - """更新 email。""" - resp = await client.put( - f"/api/v1/users/{admin_user.id}", - headers=auth_header(admin_token), - json={"email": "admin@example.com"}, - ) - assert resp.status_code == 200 - assert resp.json()["email"] == "admin@example.com" - - async def test_update_role( - self, client: AsyncClient, admin_user, dba_user, admin_token - ): - """更新用户角色。""" - resp = await client.put( - f"/api/v1/users/{dba_user.id}", - headers=auth_header(admin_token), - json={"role": "developer"}, - ) - assert resp.status_code == 200 - assert resp.json()["role"] == "developer" - - async def test_update_is_active( - self, client: AsyncClient, dba_user, admin_token - ): - """禁用用户。""" - resp = await client.put( - f"/api/v1/users/{dba_user.id}", - headers=auth_header(admin_token), - json={"is_active": False}, - ) - assert resp.status_code == 200 - assert resp.json()["is_active"] is False - - async def test_update_multiple_fields( - self, client: AsyncClient, dba_user, admin_token - ): - """同时更新多个字段。""" - resp = await client.put( - f"/api/v1/users/{dba_user.id}", - headers=auth_header(admin_token), - json={ - "display_name": "DBA改名", - "phone": "13900139000", - }, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["display_name"] == "DBA改名" - assert data["phone"] == "13900139000" - - async def test_update_nonexistent_user(self, client: AsyncClient, admin_token): - """更新不存在的用户返回 404。""" - resp = await client.put( - "/api/v1/users/9999", - headers=auth_header(admin_token), - json={"display_name": "不存在"}, - ) - assert resp.status_code == 404 - - async def test_update_invalid_role( - self, client: AsyncClient, admin_user, admin_token - ): - """无效角色返回 422。""" - resp = await client.put( - f"/api/v1/users/{admin_user.id}", - headers=auth_header(admin_token), - json={"role": "superadmin"}, - ) - assert resp.status_code == 422 - - async def test_update_non_admin_forbidden( - self, client: AsyncClient, admin_user, developer_token - ): - """developer 角色无权更新用户。""" - resp = await client.put( - f"/api/v1/users/{admin_user.id}", - headers=auth_header(developer_token), - json={"display_name": "越权"}, - ) - assert resp.status_code == 403 - - -# ═══════════════════════════════════════════════════════════════ -# DELETE /users/{user_id} — 删除 -# ═══════════════════════════════════════════════════════════════ - - -class TestDeleteUser: - - async def test_delete_user_success( - self, client: AsyncClient, dba_user, admin_token - ): - """成功删除用户。""" - resp = await client.delete( - f"/api/v1/users/{dba_user.id}", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert "已删除" in resp.json()["message"] - - # 确认已被删除 - resp2 = await client.get( - f"/api/v1/users/{dba_user.id}", - headers=auth_header(admin_token), - ) - assert resp2.status_code == 404 - - async def test_delete_self( - self, client: AsyncClient, admin_user, admin_token - ): - """不能删除自己。""" - resp = await client.delete( - f"/api/v1/users/{admin_user.id}", - headers=auth_header(admin_token), - ) - assert resp.status_code == 400 - assert "不能删除自己" in resp.json()["detail"] - - async def test_delete_nonexistent_user(self, client: AsyncClient, admin_token): - """删除不存在的用户返回 404。""" - resp = await client.delete( - "/api/v1/users/9999", - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - async def test_delete_non_admin_forbidden( - self, client: AsyncClient, admin_user, developer_token - ): - """developer 角色无权删除用户。""" - resp = await client.delete( - f"/api/v1/users/{admin_user.id}", - headers=auth_header(developer_token), - ) - assert resp.status_code == 403 - - async def test_delete_unauthenticated(self, client: AsyncClient, admin_user): - """未认证请求返回 401。""" - resp = await client.delete(f"/api/v1/users/{admin_user.id}") - assert resp.status_code == 401 - - async def test_delete_guest_forbidden( - self, client: AsyncClient, admin_user, guest_token - ): - """guest 角色无权删除用户。""" - resp = await client.delete( - f"/api/v1/users/{admin_user.id}", - headers=auth_header(guest_token), - ) - assert resp.status_code == 403 diff --git a/backend/tests/test_user_endpoint.py b/backend/tests/test_user_endpoint.py deleted file mode 100644 index b37cd77..0000000 --- a/backend/tests/test_user_endpoint.py +++ /dev/null @@ -1,232 +0,0 @@ -"""用户管理 API 端点测试。""" -import pytest -from httpx import AsyncClient - -from app.core.security import hash_password, create_access_token -from app.models.user import User - - -def auth_header(token: str) -> dict: - return {"Authorization": f"Bearer {token}"} - - -@pytest.mark.asyncio -class TestListUsers: - """GET /api/v1/users""" - - async def test_list_users_empty(self, client: AsyncClient, admin_token: str): - resp = await client.get("/api/v1/users", headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - # admin_user fixture 创建了 1 个 - assert isinstance(data, dict) and "items" in data - - async def test_list_users_filter_role( - self, client: AsyncClient, db, admin_token: str - ): - # 创建一个 dba 用户 - dba = User( - username="dba_test", - display_name="DBA", - role="dba", - hashed_password=hash_password("123456"), - ) - db.add(dba) - await db.commit() - - resp = await client.get( - "/api/v1/users?role=dba", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - data = resp.json() - items = data.get("items", data) if isinstance(data, dict) else data - assert all(u["role"] == "dba" for u in items) - - async def test_list_users_filter_active( - self, client: AsyncClient, db, admin_token: str - ): - dev = User( - username="inactive_dev", - display_name="离线", - role="developer", - is_active=False, - hashed_password=hash_password("123456"), - ) - db.add(dev) - await db.commit() - - resp = await client.get( - "/api/v1/users?is_active=false", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - data = resp.json() - items = data.get("items", data) if isinstance(data, dict) else data - assert all(u["is_active"] is False for u in items) - - async def test_list_users_forbidden_non_admin( - self, client: AsyncClient, developer_token: str - ): - resp = await client.get("/api/v1/users", headers=auth_header(developer_token)) - assert resp.status_code == 403 - - async def test_list_users_unauthorized(self, client: AsyncClient): - resp = await client.get("/api/v1/users") - assert resp.status_code == 401 - - -@pytest.mark.asyncio -class TestCreateUser: - """POST /api/v1/users""" - - async def test_create_user_success(self, client: AsyncClient, admin_token: str): - resp = await client.post( - "/api/v1/users", - json={ - "username": "newuser", - "display_name": "新用户", - "email": "new@test.com", - "phone": "13800001111", - "role": "developer", - "password": "pass123", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 201 - data = resp.json() - assert data["username"] == "newuser" - assert data["role"] == "developer" - assert "hashed_password" not in data - - async def test_create_user_duplicate_username( - self, client: AsyncClient, admin_token: str, admin_user: User - ): - resp = await client.post( - "/api/v1/users", - json={ - "username": "admin", - "display_name": "重复", - "role": "developer", - "password": "123456", - }, - headers=auth_header(admin_token), - ) - assert resp.status_code == 400 - assert "已存在" in resp.json()["detail"] - - async def test_create_user_forbidden_dba( - self, client: AsyncClient, dba_token: str - ): - resp = await client.post( - "/api/v1/users", - json={ - "username": "hack", - "display_name": "hack", - "role": "developer", - "password": "123", - }, - headers=auth_header(dba_token), - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -class TestGetUser: - """GET /api/v1/users/{user_id}""" - - async def test_get_user_success( - self, client: AsyncClient, admin_token: str, admin_user: User - ): - resp = await client.get( - f"/api/v1/users/{admin_user.id}", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - data = resp.json() - assert data["username"] == "admin" - - async def test_get_user_not_found(self, client: AsyncClient, admin_token: str): - resp = await client.get( - "/api/v1/users/9999", headers=auth_header(admin_token) - ) - assert resp.status_code == 404 - - async def test_get_user_forbidden(self, client: AsyncClient, developer_token: str): - resp = await client.get( - "/api/v1/users/1", headers=auth_header(developer_token) - ) - assert resp.status_code == 403 - - -@pytest.mark.asyncio -class TestUpdateUser: - """PUT /api/v1/users/{user_id}""" - - async def test_update_user_display_name( - self, client: AsyncClient, admin_token: str, dba_user - ): - resp = await client.put( - f"/api/v1/users/{dba_user.id}", - json={"display_name": "DBA王五"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert data["display_name"] == "DBA王五" - - async def test_update_user_role( - self, client: AsyncClient, admin_token: str, developer_user - ): - resp = await client.put( - f"/api/v1/users/{developer_user.id}", - json={"role": "dba"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["role"] == "dba" - - async def test_update_user_not_found(self, client: AsyncClient, admin_token: str): - resp = await client.put( - "/api/v1/users/9999", - json={"display_name": "x"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 404 - - async def test_update_user_partial( - self, client: AsyncClient, admin_token: str, dba_user - ): - resp = await client.put( - f"/api/v1/users/{dba_user.id}", - json={"email": "dba@test.com"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["email"] == "dba@test.com" - - -@pytest.mark.asyncio -class TestDeleteUser: - """DELETE /api/v1/users/{user_id}""" - - async def test_delete_user_success( - self, client: AsyncClient, admin_token: str, developer_user - ): - resp = await client.delete( - f"/api/v1/users/{developer_user.id}", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - assert "已删除" in resp.json()["message"] - - async def test_delete_self_forbidden( - self, client: AsyncClient, admin_token: str, admin_user: User - ): - resp = await client.delete( - f"/api/v1/users/{admin_user.id}", headers=auth_header(admin_token) - ) - assert resp.status_code == 400 - assert "不能删除自己" in resp.json()["detail"] - - async def test_delete_user_not_found(self, client: AsyncClient, admin_token: str): - resp = await client.delete( - "/api/v1/users/9999", headers=auth_header(admin_token) - ) - assert resp.status_code == 404 diff --git a/backend/tests/test_users.py b/backend/tests/test_users.py deleted file mode 100644 index 0d2008b..0000000 --- a/backend/tests/test_users.py +++ /dev/null @@ -1,78 +0,0 @@ -import pytest -from httpx import AsyncClient - -from tests.conftest import auth_header - - -class TestListUsers: - async def test_admin_list_users(self, client: AsyncClient, admin_token): - resp = await client.get("/api/v1/users", headers=auth_header(admin_token)) - assert resp.status_code == 200 - data = resp.json() - assert isinstance(data, dict) and "items" in data - - async def test_non_admin_list_users(self, client: AsyncClient, developer_token): - resp = await client.get("/api/v1/users", headers=auth_header(developer_token)) - assert resp.status_code == 403 - - -class TestCreateUser: - async def test_create_user_success(self, client: AsyncClient, admin_token): - resp = await client.post("/api/v1/users", headers=auth_header(admin_token), json={ - "username": "newuser", - "display_name": "新用户", - "password": "123456", - "role": "developer", - }) - assert resp.status_code == 201 - data = resp.json() - assert data["username"] == "newuser" - assert data["role"] == "developer" - assert "id" in data - - async def test_create_duplicate_username(self, client: AsyncClient, admin_user, admin_token): - resp = await client.post("/api/v1/users", headers=auth_header(admin_token), json={ - "username": "admin", - "display_name": "重复", - "password": "123456", - "role": "developer", - }) - assert resp.status_code == 400 - - -class TestGetUser: - async def test_get_single_user(self, client: AsyncClient, admin_user, admin_token): - resp = await client.get(f"/api/v1/users/{admin_user.id}", headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert resp.json()["username"] == "admin" - - async def test_get_nonexistent_user(self, client: AsyncClient, admin_token): - resp = await client.get("/api/v1/users/9999", headers=auth_header(admin_token)) - assert resp.status_code == 404 - - -class TestUpdateUser: - async def test_update_user(self, client: AsyncClient, admin_user, admin_token): - resp = await client.put( - f"/api/v1/users/{admin_user.id}", - headers=auth_header(admin_token), - json={"display_name": "管理员改名"}, - ) - assert resp.status_code == 200 - assert resp.json()["display_name"] == "管理员改名" - - -class TestDeleteUser: - async def test_delete_user(self, client: AsyncClient, admin_user, dba_user, admin_token): - resp = await client.delete( - f"/api/v1/users/{dba_user.id}", - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - - async def test_delete_self(self, client: AsyncClient, admin_user, admin_token): - resp = await client.delete( - f"/api/v1/users/{admin_user.id}", - headers=auth_header(admin_token), - ) - assert resp.status_code == 400 diff --git a/backend/tests/test_workflow.py b/backend/tests/test_workflow.py deleted file mode 100644 index cf668db..0000000 --- a/backend/tests/test_workflow.py +++ /dev/null @@ -1,324 +0,0 @@ -"""审批流程管理 API 测试 — 覆盖 CRUD + 设默认 + 克隆功能。""" -import pytest -from httpx import AsyncClient - -from tests.conftest import auth_header - -BASE = "/api/v1/workflows" - -SAMPLE_WORKFLOW = { - "name": "标准审批流", - "description": "DBA初审 + 管理员终审", - "is_default": True, - "steps": [ - { - "step_number": 1, - "name": "DBA审核", - "step_type": "serial", - "approver_role": "dba", - "is_required": True, - "timeout_hours": 24, - "auto_approve_on_timeout": False, - }, - { - "step_number": 2, - "name": "管理员审核", - "step_type": "serial", - "approver_role": "admin", - "is_required": True, - "timeout_hours": 48, - "auto_approve_on_timeout": False, - }, - ], -} - - -async def _create_workflow(client: AsyncClient, token: str, overrides: dict = None) -> dict: - payload = {**SAMPLE_WORKFLOW} - if overrides: - payload.update(overrides) - resp = await client.post(BASE, json=payload, headers=auth_header(token)) - return resp - - -# ────────────────────────── Permission Tests ────────────────────────── - - -class TestWorkflowPermissions: - """权限检查 — workflow 管理 admin/dba 可读,仅 admin 可写。""" - - @pytest.mark.asyncio - async def test_list_dba_allowed(self, client, dba_token): - resp = await client.get(BASE, headers=auth_header(dba_token)) - assert resp.status_code == 200 - - @pytest.mark.asyncio - async def test_list_developer_forbidden(self, client, developer_token): - resp = await client.get(BASE, headers=auth_header(developer_token)) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_list_guest_forbidden(self, client, guest_token): - resp = await client.get(BASE, headers=auth_header(guest_token)) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_create_dba_forbidden(self, client, dba_token): - resp = await _create_workflow(client, dba_token) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_create_developer_forbidden(self, client, developer_token): - resp = await _create_workflow(client, developer_token) - assert resp.status_code == 403 - - @pytest.mark.asyncio - async def test_no_auth_returns_401(self, client): - resp = await client.get(BASE) - assert resp.status_code == 401 - resp2 = await client.post(BASE, json=SAMPLE_WORKFLOW) - assert resp2.status_code == 401 - - -# ────────────────────────── Create Tests ────────────────────────── - - -class TestCreateWorkflow: - """创建审批流程。""" - - @pytest.mark.asyncio - async def test_create_success(self, client, admin_token): - resp = await _create_workflow(client, admin_token) - assert resp.status_code == 201 - body = resp.json() - assert body["name"] == "标准审批流" - assert body["is_default"] is True - assert len(body["steps"]) == 2 - assert body["steps"][0]["name"] == "DBA审核" - assert body["steps"][1]["name"] == "管理员审核" - - @pytest.mark.asyncio - async def test_create_non_default(self, client, admin_token): - resp = await _create_workflow(client, admin_token, {"is_default": False}) - assert resp.status_code == 201 - assert resp.json()["is_default"] is False - - @pytest.mark.asyncio - async def test_create_only_default_one(self, client, admin_token): - """设为默认时,取消其他默认。""" - r1 = await _create_workflow(client, admin_token, {"name": "WF1", "is_default": True}) - assert r1.status_code == 201 - r2 = await _create_workflow(client, admin_token, {"name": "WF2", "is_default": True}) - assert r2.status_code == 201 - # r2 should be default now - assert r2.json()["is_default"] is True - # list and verify only one default - resp = await client.get(BASE, headers=auth_header(admin_token)) - workflows = resp.json() - defaults = [w for w in workflows if w["is_default"]] - assert len(defaults) == 1 - assert defaults[0]["name"] == "WF2" - - -# ────────────────────────── List Tests ────────────────────────── - - -class TestListWorkflows: - - @pytest.mark.asyncio - async def test_list_empty(self, client, admin_token): - resp = await client.get(BASE, headers=auth_header(admin_token)) - assert resp.status_code == 200 - assert resp.json() == [] - - @pytest.mark.asyncio - async def test_list_returns_created(self, client, admin_token): - await _create_workflow(client, admin_token, {"name": "WF-A"}) - await _create_workflow(client, admin_token, {"name": "WF-B", "is_default": False}) - resp = await client.get(BASE, headers=auth_header(admin_token)) - assert resp.status_code == 200 - names = [w["name"] for w in resp.json()] - assert "WF-A" in names - assert "WF-B" in names - - -# ────────────────────────── Get Detail Tests ────────────────────────── - - -class TestGetWorkflow: - - @pytest.mark.asyncio - async def test_get_success(self, client, admin_token): - created = (await _create_workflow(client, admin_token)).json() - resp = await client.get( - f"{BASE}/{created['id']}", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - assert resp.json()["name"] == created["name"] - - @pytest.mark.asyncio - async def test_get_not_found(self, client, admin_token): - resp = await client.get(f"{BASE}/9999", headers=auth_header(admin_token)) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_get_dba_allowed(self, client, admin_token, dba_token): - created = (await _create_workflow(client, admin_token)).json() - resp = await client.get( - f"{BASE}/{created['id']}", headers=auth_header(dba_token) - ) - assert resp.status_code == 200 - - -# ────────────────────────── Update Tests ────────────────────────── - - -class TestUpdateWorkflow: - - @pytest.mark.asyncio - async def test_update_name(self, client, admin_token): - created = (await _create_workflow(client, admin_token)).json() - resp = await client.put( - f"{BASE}/{created['id']}", - json={"name": "新名称"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "新名称" - - @pytest.mark.asyncio - async def test_update_description(self, client, admin_token): - created = (await _create_workflow(client, admin_token)).json() - resp = await client.put( - f"{BASE}/{created['id']}", - json={"description": "新描述"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["description"] == "新描述" - - @pytest.mark.asyncio - async def test_deactivate(self, client, admin_token): - created = (await _create_workflow(client, admin_token)).json() - resp = await client.put( - f"{BASE}/{created['id']}", - json={"is_active": False}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - assert resp.json()["is_active"] is False - - @pytest.mark.asyncio - async def test_update_not_found(self, client, admin_token): - resp = await client.put( - f"{BASE}/9999", json={"name": "x"}, headers=auth_header(admin_token) - ) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_update_dba_forbidden(self, client, admin_token, dba_token): - created = (await _create_workflow(client, admin_token)).json() - resp = await client.put( - f"{BASE}/{created['id']}", - json={"name": "x"}, - headers=auth_header(dba_token), - ) - assert resp.status_code == 403 - - -# ────────────────────────── Delete Tests ────────────────────────── - - -class TestDeleteWorkflow: - """删除 = 软删除 (is_active=False)。""" - - @pytest.mark.asyncio - async def test_delete_success(self, client, admin_token): - created = (await _create_workflow(client, admin_token)).json() - resp = await client.delete( - f"{BASE}/{created['id']}", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - # should not appear in list (only active) - resp2 = await client.get(BASE, headers=auth_header(admin_token)) - ids = [w["id"] for w in resp2.json()] - assert created["id"] not in ids - - @pytest.mark.asyncio - async def test_delete_not_found(self, client, admin_token): - resp = await client.delete(f"{BASE}/9999", headers=auth_header(admin_token)) - assert resp.status_code == 404 - - @pytest.mark.asyncio - async def test_delete_dba_forbidden(self, client, admin_token, dba_token): - created = (await _create_workflow(client, admin_token)).json() - resp = await client.delete( - f"{BASE}/{created['id']}", headers=auth_header(dba_token) - ) - assert resp.status_code == 403 - - -# ────────────────────────── Set Default Tests ────────────────────────── - - -class TestSetDefaultWorkflow: - - @pytest.mark.asyncio - async def test_set_default(self, client, admin_token): - r1 = await _create_workflow(client, admin_token, {"name": "WF1", "is_default": True}) - r2 = await _create_workflow(client, admin_token, {"name": "WF2", "is_default": False}) - wf1_id = r1.json()["id"] - wf2_id = r2.json()["id"] - - # Set WF2 as default - resp = await client.post( - f"{BASE}/{wf2_id}/set-default", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - assert "已设为默认" in resp.json()["message"] - - # Verify WF2 is default - resp2 = await client.get(BASE, headers=auth_header(admin_token)) - workflows = resp2.json() - defaults = [w for w in workflows if w["is_default"]] - assert len(defaults) == 1 - assert defaults[0]["id"] == wf2_id - - @pytest.mark.asyncio - async def test_set_default_not_found(self, client, admin_token): - resp = await client.post(f"{BASE}/9999/set-default", headers=auth_header(admin_token)) - assert resp.status_code == 404 - - -# ────────────────────────── Clone Tests ────────────────────────── - - -class TestCloneWorkflow: - - @pytest.mark.asyncio - async def test_clone_success(self, client, admin_token): - created = (await _create_workflow(client, admin_token)).json() - resp = await client.post( - f"{BASE}/{created['id']}/clone", headers=auth_header(admin_token) - ) - assert resp.status_code == 200 - cloned = resp.json() - assert cloned["name"] == "标准审批流 (副本)" - assert cloned["is_default"] is False - assert cloned["id"] != created["id"] - # Steps cloned - assert len(cloned["steps"]) == 2 - assert cloned["steps"][0]["workflow_id"] == cloned["id"] - - @pytest.mark.asyncio - async def test_clone_not_found(self, client, admin_token): - resp = await client.post(f"{BASE}/9999/clone", headers=auth_header(admin_token)) - assert resp.status_code in (404, 422) - - @pytest.mark.asyncio - async def test_clone_dba_forbidden(self, client, admin_token, dba_token): - created = (await _create_workflow(client, admin_token)).json() - resp = await client.post( - f"{BASE}/{created['id']}/clone", headers=auth_header(dba_token) - ) - assert resp.status_code == 403