Initial commit: SQL audit platform v0.1.0

FastAPI backend with auth, user, instance, ticket, query, dashboard APIs.
React + TypeScript frontend with mock data UI (10 pages).
SQLite + async SQLAlchemy, JWT auth, role-based access control.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
loganandClaude Opus 4.7 committed 2026-05-22 11:11:57 +08:00
commit 439155c92e
290 files changed
+61950

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# DataGuard Backend Configuration
ENV=development
DEBUG=true
SECRET_KEY=change-me-to-a-secure-random-string
ACCESS_TOKEN_EXPIRE_MINUTES=30
# Platform Database (SQLite)
DATABASE_URL=sqlite+aiosqlite:///./sqlite3.db
# Default Admin
ADMIN_USERNAME=admin
ADMIN_PASSWORD=123456
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libmariadb-dev libpq-dev && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 9000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9000", "--reload"]
View File
Whitespace-only changes.
View File
Whitespace-only changes.
View File
Whitespace-only changes.
Whitespace-only changes.
+55
View File
@@ -0,0 +1,55 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, 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, 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=["认证"])
@router.post("/login", response_model=TokenResponse)
async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == req.username))
user = result.scalar_one_or_none()
if not user or not verify_password(req.password, user.hashed_password):
raise HTTPException(status_code=401, detail="用户名或密码错误")
if not user.is_active:
raise HTTPException(status_code=403, detail="用户已禁用")
user.last_login = datetime.now(timezone.utc)
await db.commit()
token = create_access_token(data={"sub": str(user.id), "role": user.role})
return TokenResponse(
access_token=token,
user=UserBrief(
id=user.id,
username=user.username,
display_name=user.display_name,
role=user.role,
email=user.email,
),
)
@router.get("/me", response_model=UserBrief)
async def get_me(current_user: User = Depends(get_current_user)):
return UserBrief(
id=current_user.id,
username=current_user.username,
display_name=current_user.display_name,
role=current_user.role,
email=current_user.email,
)
@router.post("/logout")
async def logout():
return {"message": "已登出"}
+62
View File
@@ -0,0 +1,62 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import select, func
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.schemas.dashboard import DashboardStats
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(AuditLog.id)).where(
AuditLog.action == "QUERY",
AuditLog.created_at >= today_start,
)
)).scalar() or 0
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,
)
+145
View File
@@ -0,0 +1,145 @@
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.models.user import User
from app.models.instance import Instance
from app.schemas.instance import InstanceCreate, InstanceUpdate, InstanceResponse, ConnectionTestResult
from app.services.instance_service import InstanceService
router = APIRouter(prefix="/instances", tags=["数据源管理"])
@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)
result = await db.execute(stmt)
return result.scalars().all()
@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=data.password,
database=data.database,
env=data.env,
description=data.description,
)
db.add(instance)
await db.commit()
await db.refresh(instance)
return instance
@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="数据源不存在")
return instance
@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)
for key, value in update_data.items():
setattr(instance, key, value)
await db.commit()
await db.refresh(instance)
return instance
@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 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="数据源不存在")
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="数据源不存在")
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="数据源不存在")
return await InstanceService.list_tables(instance, database)
+42
View File
@@ -0,0 +1,42 @@
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.models.audit_log import AuditLog
from app.schemas.query import QueryRequest, QueryResult
from app.services.query_service import QueryService
router = APIRouter(prefix="/query", tags=["SQL 查询"])
@router.post("", response_model=QueryResult)
async def execute_query(
req: QueryRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.role == "guest":
raise HTTPException(status_code=403, 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="数据源不存在")
query_result = await QueryService.execute_query(instance, req.database, req.sql, current_user.id)
# Record audit log
log = AuditLog(
user_id=current_user.id,
action="QUERY",
target_type="query",
target_id=instance.id,
detail=req.sql[:500],
)
db.add(log)
return query_result
+228
View File
@@ -0,0 +1,228 @@
from typing import Optional, List, Dict, Any
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
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.schemas.ticket import TicketCreate, TicketReview, TicketResponse
router = APIRouter(prefix="/tickets", tags=["工单管理"])
async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
data = {
"id": ticket.id,
"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,
"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 "未知"
return data
@router.get("")
async def list_tickets(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
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)
# developer 只能看到自己的工单
if current_user.role == "developer":
stmt = stmt.where(Ticket.applicant_id == current_user.id)
if status:
stmt = stmt.where(Ticket.status == status)
if db_type:
stmt = stmt.where(Ticket.db_type == db_type)
if keyword:
stmt = stmt.where(
or_(
Ticket.title.contains(keyword),
Ticket.sql_content.contains(keyword),
)
)
total_stmt = select(func.count()).select_from(stmt.subquery())
total = (await db.execute(total_stmt)).scalar()
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),
):
# Verify instance exists
inst = await db.execute(select(Instance).where(Instance.id == data.instance_id))
if not inst.scalar_one_or_none():
raise HTTPException(status_code=400, detail="数据源不存在")
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,
applicant_id=current_user.id,
status="pending",
)
db.add(ticket)
await db.commit()
await db.refresh(ticket)
return await _enrich_ticket(ticket, db)
@router.get("/statistics")
async def ticket_statistics(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
stmt = select(Ticket.status, func.count(Ticket.id)).group_by(Ticket.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),
}
@router.get("/{ticket_id}")
async def get_ticket(
ticket_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
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="工单不存在")
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.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")),
):
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="工单不存在")
if ticket.status not in ("pending", "reviewing"):
raise HTTPException(status_code=400, detail=f"工单状态为 {ticket.status},无法审核")
if data.action == "approve":
ticket.status = "approved"
else:
ticket.status = "rejected"
ticket.approver_id = current_user.id
ticket.review_time = datetime.now(timezone.utc)
ticket.audit_result = data.comment
await db.commit()
await db.refresh(ticket)
return await _enrich_ticket(ticket, db)
@router.post("/{ticket_id}/execute")
async def execute_ticket(
ticket_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
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="工单不存在")
if ticket.status != "approved":
raise HTTPException(status_code=400, detail="只能执行已审批的工单")
ticket.status = "executing"
await db.commit()
try:
inst_result = await db.execute(select(Instance).where(Instance.id == ticket.instance_id))
instance = inst_result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=400, detail="数据源不存在")
from app.services.query_service import QueryService
exec_result = await QueryService.execute_dml(instance, ticket.target_database, ticket.sql_content)
ticket.status = "success"
ticket.execute_result = exec_result
ticket.execute_time = datetime.now(timezone.utc)
except Exception as e:
ticket.status = "failed"
ticket.execute_result = str(e)
ticket.execute_time = datetime.now(timezone.utc)
await db.commit()
await db.refresh(ticket)
return await _enrich_ticket(ticket, db)
+104
View File
@@ -0,0 +1,104 @@
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
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
router = APIRouter(prefix="/users", tags=["用户管理"])
@router.get("", response_model=List[UserResponse])
async def list_users(
role: Optional[str] = None,
is_active: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
stmt = select(User).order_by(User.id)
if role:
stmt = stmt.where(User.role == role)
if is_active is not None:
stmt = stmt.where(User.is_active == is_active)
result = await db.execute(stmt)
return result.scalars().all()
@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,
email=data.email,
phone=data.phone,
role=data.role,
hashed_password=hash_password(data.password),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@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)
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": "用户已删除"}
+12
View File
@@ -0,0 +1,12 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, user, instance, ticket, query, dashboard
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)
View File
Whitespace-only changes.
+25
View File
@@ -0,0 +1,25 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
ENV: str = "development"
DEBUG: bool = True
SECRET_KEY: str = "dev-secret-key-change-in-production"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ALGORITHM: str = "HS256"
DATABASE_URL: str = "sqlite+aiosqlite:///./sqlite3.db"
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = "123456"
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@lru_cache()
def get_settings() -> Settings:
return Settings()
+33
View File
@@ -0,0 +1,33 @@
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)
+73
View File
@@ -0,0 +1,73 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import jwt, JWTError
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.database import get_db
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
settings = get_settings()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> dict:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
):
from app.models.user import User
payload = decode_token(token)
user_id: Optional[int] = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="无效的认证凭据")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=401, detail="用户不存在")
if not user.is_active:
raise HTTPException(status_code=403, detail="用户已禁用")
return user
def require_role(*roles: str):
async def role_checker(current_user=Depends(get_current_user)):
if current_user.role not in roles:
raise HTTPException(status_code=403, detail="权限不足")
return current_user
return role_checker
Whitespace-only changes.
+83
View File
@@ -0,0 +1,83 @@
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.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_data():
"""Create default admin user and seed data."""
async with async_session() as db:
result = await db.execute(select(User).where(User.username == settings.ADMIN_USERNAME))
if not result.scalar_one_or_none():
users = [
User(username="admin", display_name="管理员", role="admin",
hashed_password=hash_password("123456")),
User(username="dba", display_name="DBA 张三", role="dba",
hashed_password=hash_password("123456")),
User(username="dev1", display_name="开发者 李四", role="developer",
hashed_password=hash_password("123456")),
User(username="dev2", display_name="开发者 王五", role="developer",
hashed_password=hash_password("123456")),
User(username="auditor", display_name="审计员 赵六", role="developer",
email="auditor@example.com", hashed_password=hash_password("123456")),
]
db.add_all(users)
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
await init_db()
await seed_data()
yield
app = FastAPI(
title="Web-Lite API",
version="1.0.0",
description="数据审核平台 API",
lifespan=lifespan,
docs_url="/docs" if settings.DEBUG else None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"] if settings.DEBUG else [],
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)
View File
Whitespace-only changes.
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, 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)
action = Column(String(50), nullable=False)
target_type = Column(String(50), nullable=False) # ticket / instance / user / query
target_id = Column(Integer, nullable=True)
detail = Column(Text, nullable=True)
ip_address = Column(String(50), nullable=True)
created_at = Column(DateTime, server_default=func.now())
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, func
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)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+26
View File
@@ -0,0 +1,26 @@
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)
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
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)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
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 / 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())
View File
Whitespace-only changes.
+24
View File
@@ -0,0 +1,24 @@
from typing import Optional
from pydantic import BaseModel
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
class Config:
from_attributes = True
+13
View File
@@ -0,0 +1,13 @@
from pydantic import BaseModel
from typing import Optional
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
+50
View File
@@ -0,0 +1,50 @@
from datetime import datetime
from pydantic import BaseModel, Field
from typing import Optional, Literal
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
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
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
created_at: datetime
class Config:
from_attributes = True
class ConnectionTestResult(BaseModel):
success: bool
message: str
version: Optional[str] = None
+16
View File
@@ -0,0 +1,16 @@
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field
class QueryRequest(BaseModel):
instance_id: int
database: Optional[str] = None
sql: str = Field(..., min_length=1)
class QueryResult(BaseModel):
columns: List[str]
rows: List[Dict[str, Any]]
total: int
elapsed_ms: int
truncated: bool = False
+52
View File
@@ -0,0 +1,52 @@
from datetime import datetime
from pydantic import BaseModel, 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)
rollback_sql: Optional[str] = None
reason: str = Field(..., min_length=5)
priority: Literal["high", "medium", "low"] = "medium"
class TicketReview(BaseModel):
action: Literal["approve", "reject"]
comment: str = Field(default="", max_length=500)
class TicketResponse(BaseModel):
id: int
title: str
db_type: str
instance_id: int
target_database: str
sql_content: str
rollback_sql: Optional[str] = None
status: str
priority: str
applicant_id: int
applicant_name: Optional[str] = None
approver_id: Optional[int] = None
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
class Config:
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
+40
View File
@@ -0,0 +1,40 @@
from datetime import datetime
from pydantic import BaseModel, 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|guest)$")
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|guest)$")
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
class Config:
from_attributes = True
class PasswordChange(BaseModel):
old_password: str
new_password: str = Field(..., min_length=6)
View File
Whitespace-only changes.
+79
View File
@@ -0,0 +1,79 @@
import asyncio
from typing import List, Dict, Any
from app.models.instance import Instance
from app.schemas.instance import ConnectionTestResult
class InstanceService:
@staticmethod
async def test_connection(instance: Instance) -> ConnectionTestResult:
try:
if instance.db_type == "mysql":
import aiomysql
conn = await asyncio.wait_for(
aiomysql.connect(
host=instance.host,
port=instance.port,
user=instance.username,
password=instance.password,
database=instance.database or None,
),
timeout=10,
)
async with conn.cursor() as cur:
await cur.execute("SELECT VERSION()")
version = (await cur.fetchone())[0]
conn.close()
return ConnectionTestResult(success=True, message="连接成功", version=str(version))
return ConnectionTestResult(success=False, message=f"暂不支持 {instance.db_type} 连接测试")
except asyncio.TimeoutError:
return ConnectionTestResult(success=False, message="连接超时")
except Exception as e:
return ConnectionTestResult(success=False, message=f"连接失败: {str(e)}")
@staticmethod
async def list_databases(instance: Instance) -> List[str]:
if instance.db_type != "mysql":
return []
import aiomysql
conn = await aiomysql.connect(
host=instance.host, port=instance.port,
user=instance.username, password=instance.password,
)
async with conn.cursor() as cur:
await cur.execute("SHOW DATABASES")
rows = await cur.fetchall()
conn.close()
return [row[0] for row in rows if row[0] not in ("information_schema", "mysql", "performance_schema", "sys")]
@staticmethod
async def list_tables(instance: Instance, database: str) -> List[Dict[str, Any]]:
if instance.db_type != "mysql":
return []
import aiomysql
conn = await aiomysql.connect(
host=instance.host, port=instance.port,
user=instance.username, password=instance.password,
database=database,
)
async with conn.cursor() as cur:
await cur.execute("SHOW TABLES")
tables = [row[0] for row in await cur.fetchall()]
result = []
for table in tables:
await cur.execute(f"SHOW TABLE STATUS LIKE '{table}'")
status = await cur.fetchone()
result.append({
"name": table,
"engine": status[1],
"rows": status[4],
"size": status[6],
"comment": status[17],
})
conn.close()
return result
+78
View File
@@ -0,0 +1,78 @@
import asyncio
import re
import time
from typing import Optional
from app.models.instance import Instance
from app.schemas.query import QueryResult
FORBIDDEN_KEYWORDS = ["DROP", "TRUNCATE", "DELETE", "UPDATE", "INSERT", "CREATE", "ALTER", "GRANT", "REVOKE"]
MAX_ROWS = 10000
class QueryService:
@staticmethod
async def execute_query(
instance: Instance, database: Optional[str], sql: str, user_id: int
) -> QueryResult:
if instance.db_type != "mysql":
raise ValueError(f"暂不支持 {instance.db_type} 查询")
upper_sql = sql.strip().upper()
for kw in FORBIDDEN_KEYWORDS:
if upper_sql.startswith(kw) or f" {kw} " in upper_sql:
raise ValueError(f"查询模式禁止执行 {kw} 操作")
if not re.search(r"LIMIT\s+\d+", upper_sql, re.IGNORECASE):
sql = sql.rstrip(";") + f" LIMIT {MAX_ROWS};"
import aiomysql
conn = await aiomysql.connect(
host=instance.host, port=instance.port,
user=instance.username, password=instance.password,
db=database or instance.database,
)
start = time.time()
try:
async with conn.cursor(aiomysql.DictCursor) as cur:
await asyncio.wait_for(cur.execute(sql), timeout=30)
rows = await cur.fetchall()
columns = [desc[0] for desc in cur.description] if cur.description else []
elapsed = int((time.time() - start) * 1000)
return QueryResult(
columns=columns,
rows=rows,
total=len(rows),
elapsed_ms=elapsed,
truncated=len(rows) >= MAX_ROWS,
)
except asyncio.TimeoutError:
raise ValueError("查询超时(30秒)")
finally:
conn.close()
@staticmethod
async def execute_dml(instance: Instance, database: str, sql: str) -> str:
if instance.db_type != "mysql":
raise ValueError(f"暂不支持 {instance.db_type} 执行")
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=60)
affected = cur.rowcount
return f"执行成功,影响 {affected} 行"
except asyncio.TimeoutError:
raise ValueError("执行超时(60秒)")
finally:
conn.close()
View File
Whitespace-only changes.
+15
View File
@@ -0,0 +1,15 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
aiosqlite==0.20.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.1.2
python-multipart==0.0.20
pydantic==2.10.4
pydantic-settings==2.7.1
httpx==0.28.1
pymysql==1.1.1
aiomysql==0.2.0
cryptography==44.0.0
greenlet==3.1.1