feat: Phase 4 - SQL AST analyzer, audit rules CRUD, Alembic, Docker (128 tests)

This commit is contained in:
logan committed 2026-05-22 13:35:35 +08:00
1 parent e45fbf3586
commit ff0a330e8a
23 files changed
+1384 -139

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
venv/
__pycache__/
*.pyc
.pytest_cache/
*.db
alembic/versions/__pycache__/
+17 -2
View File
@@ -2,15 +2,30 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Install system deps
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libmariadb-dev libpq-dev && \ gcc libffi-dev && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# Install Python deps
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Copy app
COPY . . 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 EXPOSE 9000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9000", "--reload"] # Run alembic + uvicorn
CMD ["sh", "-c", "alembic upgrade head && python -m uvicorn app.main:app --host 0.0.0.0 --port 9000"]
+147
View File
@@ -0,0 +1,147 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+61
View File
@@ -0,0 +1,61 @@
from logging.config import fileConfig
import os
import sys
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.core.database import Base
from app.core.config import get_settings
# Import all models so Alembic can detect them
from app.models.user import User # noqa
from app.models.instance import Instance # noqa
from app.models.ticket import Ticket # noqa
from app.models.audit_log import AuditLog # noqa
from app.models.audit_rule import AuditRule # noqa
config = context.config
# Override sqlalchemy.url from settings
config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL.replace("+aiosqlite", ""))
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${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"}
@@ -0,0 +1,32 @@
"""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 ###
+138
View File
@@ -0,0 +1,138 @@
"""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
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")),
):
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:
stmt = stmt.where((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("/{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")),
):
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.get("/stats/summary")
async def rule_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
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}
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints import auth, user, instance, ticket, query, dashboard, audit from app.api.v1.endpoints import auth, user, instance, ticket, query, dashboard, audit, audit_rule
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
@@ -11,3 +11,4 @@ api_router.include_router(ticket.router)
api_router.include_router(query.router) api_router.include_router(query.router)
api_router.include_router(dashboard.router) api_router.include_router(dashboard.router)
api_router.include_router(audit.router) api_router.include_router(audit.router)
api_router.include_router(audit_rule.router)
+1
View File
@@ -48,6 +48,7 @@ async def lifespan(app: FastAPI):
import app.models.instance # noqa import app.models.instance # noqa
import app.models.ticket # noqa import app.models.ticket # noqa
import app.models.audit_log # noqa import app.models.audit_log # noqa
import app.models.audit_rule # noqa
await init_db() await init_db()
await seed_data() await seed_data()
+19
View File
@@ -0,0 +1,19 @@
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)
name = Column(String(100), nullable=False, unique=True)
description = Column(Text, nullable=True)
rule_type = Column(String(50), nullable=False) # syntax / performance / safety / naming
severity = Column(String(20), default="warning") # error / warning / info
db_type = Column(String(20), default="mysql") # mysql / postgres / all
pattern = Column(Text, nullable=True) # regex or keyword pattern
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())
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
from pydantic import BaseModel, Field
from typing import Optional, Literal
class AuditRuleCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
rule_type: Literal["syntax", "performance", "safety", "naming"]
severity: Literal["error", "warning", "info"] = "warning"
db_type: str = "mysql"
pattern: Optional[str] = None
is_enabled: bool = True
message: str = Field(..., min_length=1, max_length=500)
class AuditRuleUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
description: Optional[str] = None
rule_type: Optional[Literal["syntax", "performance", "safety", "naming"]] = None
severity: Optional[Literal["error", "warning", "info"]] = None
db_type: Optional[str] = None
pattern: Optional[str] = None
is_enabled: Optional[bool] = None
message: Optional[str] = Field(default=None, min_length=1, max_length=500)
class AuditRuleResponse(BaseModel):
id: int
name: str
description: Optional[str] = None
rule_type: str
severity: str
db_type: str
pattern: Optional[str] = None
is_enabled: bool
message: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
+9 -6
View File
@@ -5,13 +5,14 @@ from typing import Optional
from app.models.instance import Instance from app.models.instance import Instance
from app.schemas.query import QueryResult from app.schemas.query import QueryResult
from app.services.sql_analyzer import SQLAnalyzer
FORBIDDEN_KEYWORDS = ["DROP", "TRUNCATE", "DELETE", "UPDATE", "INSERT", "CREATE", "ALTER", "GRANT", "REVOKE"]
MAX_ROWS = 10000 MAX_ROWS = 10000
class QueryService: class QueryService:
_analyzer = SQLAnalyzer()
@staticmethod @staticmethod
async def execute_query( async def execute_query(
instance: Instance, database: Optional[str], sql: str, user_id: int instance: Instance, database: Optional[str], sql: str, user_id: int
@@ -19,11 +20,13 @@ class QueryService:
if instance.db_type != "mysql": if instance.db_type != "mysql":
raise ValueError(f"暂不支持 {instance.db_type} 查询") raise ValueError(f"暂不支持 {instance.db_type} 查询")
upper_sql = sql.strip().upper() # AST-based SQL validation (replaces keyword matching)
for kw in FORBIDDEN_KEYWORDS: is_safe, error_msg = QueryService._analyzer.validate_readonly(sql)
if upper_sql.startswith(kw) or f" {kw} " in upper_sql: if not is_safe:
raise ValueError(f"查询模式禁止执行 {kw} 操作") raise ValueError(error_msg)
# Auto-add LIMIT if missing
upper_sql = sql.strip().upper()
if not re.search(r"LIMIT\s+\d+", upper_sql, re.IGNORECASE): if not re.search(r"LIMIT\s+\d+", upper_sql, re.IGNORECASE):
sql = sql.rstrip(";") + f" LIMIT {MAX_ROWS};" sql = sql.rstrip(";") + f" LIMIT {MAX_ROWS};"
+255
View File
@@ -0,0 +1,255 @@
"""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
+12 -15
View File
@@ -1,15 +1,12 @@
fastapi==0.115.6 fastapi>=0.104.0
uvicorn[standard]==0.34.0 uvicorn[standard]>=0.24.0
sqlalchemy==2.0.36 sqlalchemy[asyncio]>=2.0.23
aiosqlite==0.20.0 aiosqlite>=0.19.0
python-jose[cryptography]==3.3.0 pydantic>=2.5.0
passlib[bcrypt]==1.7.4 pydantic-settings>=2.1.0
bcrypt==4.1.2 python-jose[cryptography]>=3.3.0
python-multipart==0.0.20 passlib[bcrypt]>=1.7.4
pydantic==2.10.4 python-multipart>=0.0.6
pydantic-settings==2.7.1 cryptography>=41.0.0
httpx==0.28.1 sqlparse>=0.4.4
pymysql==1.1.1 alembic>=1.13.0
aiomysql==0.2.0
cryptography==44.0.0
greenlet==3.1.1
+1
View File
@@ -11,6 +11,7 @@ from app.models.user import User
from app.models.instance import Instance from app.models.instance import Instance
from app.models.ticket import Ticket from app.models.ticket import Ticket
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.models.audit_rule import AuditRule
SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://" SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://"
+165
View File
@@ -0,0 +1,165 @@
"""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
+157
View File
@@ -0,0 +1,157 @@
"""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
+16 -18
View File
@@ -2,31 +2,29 @@ version: '3.8'
services: services:
backend: backend:
build: build: ./backend
context: ./backend container_name: sql-audit-backend
dockerfile: Dockerfile restart: unless-stopped
ports: ports:
- "9000:9000" - "9000:9000"
environment:
- ENV=development
- DEBUG=true
- SECRET_KEY=dev-secret-key-change-in-production
- DATABASE_URL=sqlite+aiosqlite:///./sqlite3.db
volumes: volumes:
- ./backend:/app - backend-data:/app/data
- backend_data:/app/data environment:
- DATABASE_URL=sqlite+aiosqlite:///./data/sqlite3.db
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
- ENV=production
- DEBUG=false
frontend: frontend:
build: build: ./frontend
context: ./frontend container_name: sql-audit-frontend
dockerfile: Dockerfile restart: unless-stopped
ports: ports:
- "5173:5173" - "80:80"
volumes:
- ./frontend:/app
- /app/node_modules
depends_on: depends_on:
- backend - backend
volumes: volumes:
backend_data: backend-data:
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.git/
+9 -8
View File
@@ -1,12 +1,13 @@
FROM node:20-alpine FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./
COPY package.json . RUN npm ci
RUN npm install
COPY . . COPY . .
RUN npm run build
EXPOSE 5173 FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
}
+243 -89
View File
@@ -1,134 +1,288 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Plus, Search, Edit2, Trash2, Shield, AlertTriangle, AlertCircle, Info, CheckCircle, ToggleLeft, ToggleRight, FileCode } from 'lucide-react'; import request from '../api/request';
import { Card, CardContent } from '@/components/ui/card';
import { auditRules, dbTypeColorMap } from '@/lib/mock';
const levelConfig = { interface AuditRule {
error: { icon: AlertTriangle, color: 'text-red-400 bg-red-400/10', label: '错误' }, id: number;
warning: { icon: AlertCircle, color: 'text-yellow-400 bg-yellow-400/10', label: '警告' }, name: string;
info: { icon: Info, color: 'text-blue-400 bg-blue-400/10', label: '提示' }, description: string | null;
rule_type: string;
severity: string;
db_type: string;
pattern: string | null;
is_enabled: boolean;
message: string;
created_at: string;
updated_at: string;
}
const RULE_TYPES = [
{ value: 'syntax', label: '语法检查', color: 'bg-blue-100 text-blue-700' },
{ value: 'performance', label: '性能优化', color: 'bg-yellow-100 text-yellow-700' },
{ value: 'safety', label: '安全检查', color: 'bg-red-100 text-red-700' },
{ value: 'naming', label: '命名规范', color: 'bg-green-100 text-green-700' },
];
const SEVERITY_MAP: Record<string, { label: string; color: string }> = {
error: { label: '错误', color: 'bg-red-500 text-white' },
warning: { label: '警告', color: 'bg-yellow-500 text-white' },
info: { label: '提示', color: 'bg-blue-500 text-white' },
}; };
export default function AuditRulesPage() { export default function AuditRulesPage() {
const [search, setSearch] = useState(''); const [rules, setRules] = useState<AuditRule[]>([]);
const [levelFilter, setLevelFilter] = useState(''); const [loading, setLoading] = useState(true);
const [rules, setRules] = useState(auditRules); const [filter, setFilter] = useState({ rule_type: '', severity: '', is_enabled: '' });
const [showCreate, setShowCreate] = useState(false);
const filtered = rules.filter(r => { const [stats, setStats] = useState<any>(null);
if (levelFilter && r.level !== levelFilter) return false; const [form, setForm] = useState({
if (search && !r.name.includes(search) && !r.description.includes(search)) return false; name: '', description: '', rule_type: 'safety', severity: 'warning',
return true; db_type: 'mysql', pattern: '', is_enabled: true, message: '',
}); });
const toggleRule = (id: string) => { useEffect(() => { fetchRules(); fetchStats(); }, []);
setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r));
}; async function fetchRules() {
try {
setLoading(true);
const params = new URLSearchParams();
if (filter.rule_type) params.set('rule_type', filter.rule_type);
if (filter.severity) params.set('severity', filter.severity);
if (filter.is_enabled !== '') params.set('is_enabled', filter.is_enabled);
const res = await request.get('/audit-rules', { params });
setRules(res.data);
} catch { /* */ } finally { setLoading(false); }
}
async function fetchStats() {
try {
const res = await request.get('/audit-rules/stats/summary');
setStats(res.data);
} catch { /* */ }
}
async function handleCreate() {
try {
await request.post('/audit-rules', form);
setShowCreate(false);
setForm({ name: '', description: '', rule_type: 'safety', severity: 'warning', db_type: 'mysql', pattern: '', is_enabled: true, message: '' });
fetchRules();
fetchStats();
} catch (err: any) {
alert(err.response?.data?.detail || '创建失败');
}
}
async function handleToggle(rule: AuditRule) {
try {
await request.post('/audit-rules/' + rule.id + '/toggle');
fetchRules();
fetchStats();
} catch { /* */ }
}
async function handleDelete(id: number) {
if (!confirm('确认删除此规则?')) return;
try {
await request.delete('/audit-rules/' + id);
fetchRules();
fetchStats();
} catch { /* */ }
}
const getTypeInfo = (type: string) => RULE_TYPES.find(t => t.value === type) || RULE_TYPES[0];
const getSeverityInfo = (sev: string) => SEVERITY_MAP[sev] || SEVERITY_MAP.info;
return ( return (
<div className="space-y-5"> <div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold">SQL 审核规则</h1> <h1 className="text-xl font-semibold text-gray-900">审核规则</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 SQL 审核规则,确保数据变更安全合规</p> <p className="text-sm text-gray-500 mt-1">管理 SQL 审核引擎的检查规则</p>
</div> </div>
<div className="flex gap-3"> <button
<button className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)] transition-all"> onClick={() => setShowCreate(true)}
<FileCode className="w-4 h-4" /> 导入规则 className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700"
>
新建规则
</button> </button>
<button className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
<Plus className="w-4 h-4" /> 添加规则
</button>
</div>
</div> </div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
{[ {[
{ label: '规则总数', value: rules.length, icon: Shield, color: 'text-blue-400', bg: 'bg-blue-400/10' }, { label: '总规则数', value: stats.total, color: 'text-gray-900' },
{ label: '已启用', value: rules.filter(r => r.enabled).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' }, { label: '已启用', value: stats.enabled, color: 'text-green-600' },
{ label: '错误级', value: rules.filter(r => r.level === 'error').length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' }, { label: '已禁用', value: stats.disabled, color: 'text-gray-400' },
{ label: '警告级', value: rules.filter(r => r.level === 'warning').length, icon: AlertCircle, color: 'text-yellow-400', bg: 'bg-yellow-400/10' }, { label: '类型', value: Object.keys(stats.by_type || {}).length, color: 'text-blue-600' },
].map((s, i) => ( ].map(s => (
<Card key={i} className="dg-card"> <div key={s.label} className="bg-white rounded-lg p-4 shadow-sm">
<CardContent className="p-4 flex items-center gap-3"> <p className="text-sm text-gray-500">{s.label}</p>
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div> <p className={'text-2xl font-bold ' + s.color}>{s.value}</p>
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div> </div>
</CardContent>
</Card>
))} ))}
</div> </div>
)}
<div className="flex items-center gap-3 p-4 dg-card flex-wrap"> {/* Filters */}
<div className="relative flex-1 max-w-xs"> <div className="flex gap-3">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" /> <select
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none" placeholder="搜索规则名称" value={search} onChange={e => setSearch(e.target.value)} /> value={filter.rule_type}
</div> onChange={e => setFilter({ ...filter, rule_type: e.target.value })}
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={levelFilter} onChange={e => setLevelFilter(e.target.value)}> className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
>
<option value="">全部类型</option>
{RULE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<select
value={filter.severity}
onChange={e => setFilter({ ...filter, severity: e.target.value })}
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
>
<option value="">全部级别</option> <option value="">全部级别</option>
<option value="error">错误</option> <option value="error">错误</option>
<option value="warning">警告</option> <option value="warning">警告</option>
<option value="info">提示</option> <option value="info">提示</option>
</select> </select>
<select
value={filter.is_enabled}
onChange={e => setFilter({ ...filter, is_enabled: e.target.value })}
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
>
<option value="">全部状态</option>
<option value="true">已启用</option>
<option value="false">已禁用</option>
</select>
<button onClick={fetchRules} className="px-3 py-2 bg-gray-100 rounded-lg text-sm hover:bg-gray-200">
刷新
</button>
</div> </div>
<Card className="dg-card"> {/* Create Modal */}
<CardContent className="p-0"> {showCreate && (
<div className="overflow-x-auto"> <div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50">
<table className="w-full text-sm"> <div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h2 className="text-lg font-semibold mb-4">新建审核规则</h2>
<div className="space-y-3">
<input
placeholder="规则名称"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
/>
<textarea
placeholder="描述(可选)"
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
rows={2}
/>
<div className="grid grid-cols-2 gap-3">
<select
value={form.rule_type}
onChange={e => setForm({ ...form, rule_type: e.target.value })}
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
>
{RULE_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<select
value={form.severity}
onChange={e => setForm({ ...form, severity: e.target.value })}
className="px-3 py-2 border border-gray-200 rounded-lg text-sm"
>
<option value="error">错误</option>
<option value="warning">警告</option>
<option value="info">提示</option>
</select>
</div>
<input
placeholder="匹配模式(正则表达式,可选)"
value={form.pattern}
onChange={e => setForm({ ...form, pattern: e.target.value })}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono"
/>
<input
placeholder="提示消息"
value={form.message}
onChange={e => setForm({ ...form, message: e.target.value })}
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm"
/>
</div>
<div className="flex justify-end gap-3 mt-5">
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
取消
</button>
<button onClick={handleCreate} className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
创建
</button>
</div>
</div>
</div>
)}
{/* Rules Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-400">加载中...</div>
) : rules.length === 0 ? (
<div className="p-8 text-center text-gray-400">暂无规则,点击"新建规则"添加</div>
) : (
<table className="w-full">
<thead> <thead>
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]"> <tr className="bg-gray-50 text-left text-xs text-gray-500">
<th className="text-left px-5 py-3 font-semibold">规则ID</th> <th className="px-4 py-3">规则名称</th>
<th className="text-left px-5 py-3 font-semibold">规则名称</th> <th className="px-4 py-3">类型</th>
<th className="text-left px-5 py-3 font-semibold">级别</th> <th className="px-4 py-3">级别</th>
<th className="text-left px-5 py-3 font-semibold">适用数据库</th> <th className="px-4 py-3">提示消息</th>
<th className="text-left px-5 py-3 font-semibold">描述</th> <th className="px-4 py-3">状态</th>
<th className="text-left px-5 py-3 font-semibold">状态</th> <th className="px-4 py-3 text-right">操作</th>
<th className="text-left px-5 py-3 font-semibold">操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="divide-y divide-gray-100">
{filtered.map(rule => { {rules.map(rule => {
const lc = levelConfig[rule.level]; const typeInfo = getTypeInfo(rule.rule_type);
const LIcon = lc.icon; const sevInfo = getSeverityInfo(rule.severity);
return ( return (
<tr key={rule.id} className={`border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors ${!rule.enabled ? 'opacity-50' : ''}`}> <tr key={rule.id} className="hover:bg-gray-50">
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{rule.id}</td> <td className="px-4 py-3">
<td className="px-5 py-3.5 font-medium">{rule.name}</td> <div>
<td className="px-5 py-3.5"> <p className="font-medium text-sm text-gray-900">{rule.name}</p>
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${lc.color}`}> {rule.description && <p className="text-xs text-gray-400 mt-0.5">{rule.description}</p>}
<LIcon className="w-3.5 h-3.5" /> {lc.label}
</span>
</td>
<td className="px-5 py-3.5">
<div className="flex flex-wrap gap-1">
{rule.dbTypes.map(dt => (
<span key={dt} className={`inline-flex px-2 py-0.5 rounded text-[11px] font-medium ${dbTypeColorMap[dt] || ''}`}>{dt.toUpperCase()}</span>
))}
</div> </div>
</td> </td>
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)] max-w-xs truncate">{rule.description}</td> <td className="px-4 py-3">
<td className="px-5 py-3.5"> <span className={'px-2 py-0.5 rounded text-xs ' + typeInfo.color}>{typeInfo.label}</span>
<button onClick={() => toggleRule(rule.id)} className="transition-colors"> </td>
{rule.enabled ? ( <td className="px-4 py-3">
<ToggleRight className="w-8 h-8 text-green-400" /> <span className={'px-2 py-0.5 rounded text-xs ' + sevInfo.color}>{sevInfo.label}</span>
) : ( </td>
<ToggleLeft className="w-8 h-8 text-[var(--dg-text-muted)]" /> <td className="px-4 py-3 text-sm text-gray-600 max-w-xs truncate">{rule.message}</td>
<td className="px-4 py-3">
<button
onClick={() => handleToggle(rule)}
className={'px-2 py-1 rounded text-xs ' + (
rule.is_enabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
)} )}
>
{rule.is_enabled ? '启用' : '禁用'}
</button> </button>
</td> </td>
<td className="px-5 py-3.5"> <td className="px-4 py-3 text-right">
<div className="flex gap-2"> <button
<button className="text-blue-400 hover:text-blue-300"><Edit2 className="w-4 h-4" /></button> onClick={() => handleDelete(rule.id)}
<button className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4" /></button> className="text-xs text-red-500 hover:text-red-700"
</div> >
删除
</button>
</td> </td>
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
)}
</div> </div>
</CardContent>
</Card>
</div> </div>
); );
} }