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:
commit
439155c92e
290 files changed
+61950
No files matched your search
@@ -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
|
||||
@@ -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"]
|
||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
@@ -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": "已登出"}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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": "用户已删除"}
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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.
@@ -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)
|
||||
Whitespace-only changes.
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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()
|
||||
Whitespace-only changes.
@@ -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
|
||||
Reference in new issue
Block a user