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

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)