feat: Phase 3 - AES encryption, SQL audit engine, audit logs (85 tests)

Phase 3.1: Instance password AES-256-GCM encryption
Phase 3.2: SQL audit rule engine (10 rules)
Phase 3.3: Auto-audit on ticket creation
Phase 3.4: Operation audit logs + GET /audit/logs
Tests: 44 -> 85 (41 new), Frontend: AuditLogsPage
This commit is contained in:
logan committed 2026-05-22 13:08:55 +08:00
1 parent 27989274f5
commit 66b09e4d85
16 files changed
+1146 -6

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
"""Audit log endpoints."""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.services.audit_log_service import AuditLogService
router = APIRouter(prefix="/audit", tags=["审计日志"])
@router.get("/logs")
async def list_audit_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: Optional[int] = None,
action: Optional[str] = None,
target_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
return await AuditLogService.list_logs(
db, page=page, page_size=page_size,
user_id=user_id, action=action, target_type=target_type,
)
+28 -2
View File
@@ -10,6 +10,8 @@ from app.models.user import User
from app.models.instance import Instance from app.models.instance import Instance
from app.schemas.instance import InstanceCreate, InstanceUpdate, InstanceResponse, ConnectionTestResult from app.schemas.instance import InstanceCreate, InstanceUpdate, InstanceResponse, ConnectionTestResult
from app.services.instance_service import InstanceService from app.services.instance_service import InstanceService
from app.core.crypto import encrypt_password, decrypt_password
from app.services.audit_log_service import AuditLogService
router = APIRouter(prefix="/instances", tags=["数据源管理"]) router = APIRouter(prefix="/instances", tags=["数据源管理"])
@@ -42,12 +44,18 @@ async def create_instance(
host=data.host, host=data.host,
port=data.port, port=data.port,
username=data.username, username=data.username,
password=data.password, password=encrypt_password(data.password),
database=data.database, database=data.database,
env=data.env, env=data.env,
description=data.description, description=data.description,
) )
db.add(instance) db.add(instance)
await db.flush()
await AuditLogService.log(
db, user_id=current_user.id, action="CREATE_INSTANCE",
target_type="instance", target_id=instance.id,
detail=f"创建数据源: {data.name} ({data.db_type}@{data.host}:{data.port})",
)
await db.commit() await db.commit()
await db.refresh(instance) await db.refresh(instance)
return instance return instance
@@ -79,6 +87,8 @@ async def update_instance(
raise HTTPException(status_code=404, detail="数据源不存在") raise HTTPException(status_code=404, detail="数据源不存在")
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
if "password" in update_data:
update_data["password"] = encrypt_password(update_data["password"])
for key, value in update_data.items(): for key, value in update_data.items():
setattr(instance, key, value) setattr(instance, key, value)
await db.commit() await db.commit()
@@ -97,6 +107,11 @@ async def delete_instance(
if not instance: if not instance:
raise HTTPException(status_code=404, detail="数据源不存在") raise HTTPException(status_code=404, detail="数据源不存在")
await AuditLogService.log(
db, user_id=current_user.id, action="DELETE_INSTANCE",
target_type="instance", target_id=instance.id,
detail=f"删除数据源: {instance.name}",
)
await db.delete(instance) await db.delete(instance)
await db.commit() await db.commit()
return {"message": "数据源已删除"} return {"message": "数据源已删除"}
@@ -113,7 +128,18 @@ async def test_connection(
if not instance: if not instance:
raise HTTPException(status_code=404, detail="数据源不存在") raise HTTPException(status_code=404, detail="数据源不存在")
return await InstanceService.test_connection(instance) # Decrypt password for connection test
from app.models.instance import Instance as InstModel
decrypted = instance.__class__(
name=instance.name, db_type=instance.db_type, host=instance.host,
port=instance.port, username=instance.username,
password=decrypt_password(instance.password),
database=instance.database, env=instance.env,
description=instance.description, status=instance.status,
version=instance.version,
)
decrypted.id = instance.id
return await InstanceService.test_connection(decrypted)
@router.get("/{instance_id}/databases") @router.get("/{instance_id}/databases")
+4
View File
@@ -9,6 +9,7 @@ from app.models.instance import Instance
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
from app.schemas.query import QueryRequest, QueryResult from app.schemas.query import QueryRequest, QueryResult
from app.services.query_service import QueryService from app.services.query_service import QueryService
from app.core.crypto import decrypt_password
router = APIRouter(prefix="/query", tags=["SQL 查询"]) router = APIRouter(prefix="/query", tags=["SQL 查询"])
@@ -27,6 +28,9 @@ async def execute_query(
if not instance: if not instance:
raise HTTPException(status_code=404, detail="数据源不存在") raise HTTPException(status_code=404, detail="数据源不存在")
# Decrypt password for query execution
instance.password = decrypt_password(instance.password)
try: try:
query_result = await QueryService.execute_query(instance, req.database, req.sql, current_user.id) query_result = await QueryService.execute_query(instance, req.database, req.sql, current_user.id)
except ValueError as e: except ValueError as e:
+29 -1
View File
@@ -11,6 +11,10 @@ from app.models.user import User
from app.models.ticket import Ticket from app.models.ticket import Ticket
from app.models.instance import Instance from app.models.instance import Instance
from app.schemas.ticket import TicketCreate, TicketReview, TicketResponse from app.schemas.ticket import TicketCreate, TicketReview, TicketResponse
from app.core.crypto import decrypt_password
from app.services.audit_engine import SQLAuditEngine
from app.services.audit_log_service import AuditLogService
import json
router = APIRouter(prefix="/tickets", tags=["工单管理"]) router = APIRouter(prefix="/tickets", tags=["工单管理"])
@@ -104,6 +108,10 @@ async def create_ticket(
if not inst.scalar_one_or_none(): if not inst.scalar_one_or_none():
raise HTTPException(status_code=400, detail="数据源不存在") raise HTTPException(status_code=400, detail="数据源不存在")
# Auto-audit SQL before creating ticket
engine = SQLAuditEngine(env="production" if data.db_type == "mysql" else "development")
report = engine.audit(data.sql_content)
ticket = Ticket( ticket = Ticket(
title=data.title, title=data.title,
db_type=data.db_type, db_type=data.db_type,
@@ -115,11 +123,23 @@ async def create_ticket(
priority=data.priority, priority=data.priority,
applicant_id=current_user.id, applicant_id=current_user.id,
status="pending", status="pending",
audit_result=json.dumps(report.to_dict(), ensure_ascii=False) if report.findings else None,
) )
db.add(ticket) db.add(ticket)
await db.flush() # flush to get ticket.id for audit log
# Log ticket creation
await AuditLogService.log(
db, user_id=current_user.id, action="CREATE_TICKET",
target_type="ticket", target_id=ticket.id,
detail=f"SQL审核评分: {report.score}, 通过: {report.passed}",
)
await db.commit() await db.commit()
await db.refresh(ticket) await db.refresh(ticket)
return await _enrich_ticket(ticket, db) result = await _enrich_ticket(ticket, db)
result["audit_report"] = report.to_dict()
return result
@router.get("/statistics") @router.get("/statistics")
@@ -184,6 +204,13 @@ async def review_ticket(
ticket.approver_id = current_user.id ticket.approver_id = current_user.id
ticket.review_time = datetime.now(timezone.utc) ticket.review_time = datetime.now(timezone.utc)
ticket.audit_result = data.comment ticket.audit_result = data.comment
await AuditLogService.log(
db, user_id=current_user.id, action="REVIEW_TICKET",
target_type="ticket", target_id=ticket.id,
detail=f"审核动作: {data.action}, 备注: {data.comment}",
)
await db.commit() await db.commit()
await db.refresh(ticket) await db.refresh(ticket)
return await _enrich_ticket(ticket, db) return await _enrich_ticket(ticket, db)
@@ -213,6 +240,7 @@ async def execute_ticket(
raise HTTPException(status_code=400, detail="数据源不存在") raise HTTPException(status_code=400, detail="数据源不存在")
from app.services.query_service import QueryService from app.services.query_service import QueryService
instance.password = decrypt_password(instance.password)
exec_result = await QueryService.execute_dml(instance, ticket.target_database, ticket.sql_content) exec_result = await QueryService.execute_dml(instance, ticket.target_database, ticket.sql_content)
ticket.status = "success" ticket.status = "success"
+12
View File
@@ -8,6 +8,7 @@ from app.core.database import get_db
from app.core.security import get_current_user, hash_password, require_role from app.core.security import get_current_user, hash_password, require_role
from app.models.user import User from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate, UserResponse from app.schemas.user import UserCreate, UserUpdate, UserResponse
from app.services.audit_log_service import AuditLogService
router = APIRouter(prefix="/users", tags=["用户管理"]) router = APIRouter(prefix="/users", tags=["用户管理"])
@@ -47,6 +48,12 @@ async def create_user(
hashed_password=hash_password(data.password), hashed_password=hash_password(data.password),
) )
db.add(user) db.add(user)
await db.flush()
await AuditLogService.log(
db, user_id=current_user.id, action="CREATE_USER",
target_type="user", target_id=user.id,
detail=f"创建用户: {data.username} ({data.role})",
)
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
return user return user
@@ -99,6 +106,11 @@ async def delete_user(
if not user: if not user:
raise HTTPException(status_code=404, detail="用户不存在") raise HTTPException(status_code=404, detail="用户不存在")
await AuditLogService.log(
db, user_id=current_user.id, action="DELETE_USER",
target_type="user", target_id=user.id,
detail=f"删除用户: {user.username}",
)
await db.delete(user) await db.delete(user)
await db.commit() await db.commit()
return {"message": "用户已删除"} return {"message": "用户已删除"}
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints import auth, user, instance, ticket, query, dashboard from app.api.v1.endpoints import auth, user, instance, ticket, query, dashboard, audit
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
@@ -10,3 +10,4 @@ api_router.include_router(instance.router)
api_router.include_router(ticket.router) api_router.include_router(ticket.router)
api_router.include_router(query.router) api_router.include_router(query.router)
api_router.include_router(dashboard.router) api_router.include_router(dashboard.router)
api_router.include_router(audit.router)
+45
View File
@@ -0,0 +1,45 @@
"""AES-256-GCM encryption for instance passwords."""
import base64
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from app.core.config import get_settings
_NONCE_SIZE = 12 # 96-bit nonce for AES-GCM
def _get_key() -> bytes:
"""Derive a 32-byte AES key from settings.SECRET_KEY."""
settings = get_settings()
# Use first 32 bytes of utf-8 encoded secret key (padded if needed)
raw = settings.SECRET_KEY.encode("utf-8")
if len(raw) >= 32:
return raw[:32]
return raw.ljust(32, b"\x00")
def encrypt_password(plain: str) -> str:
"""Encrypt a password string, return base64-encoded ciphertext (nonce + ciphertext)."""
key = _get_key()
nonce = os.urandom(_NONCE_SIZE)
aesgcm = AESGCM(key)
ct = aesgcm.encrypt(nonce, plain.encode("utf-8"), None)
return base64.b64encode(nonce + ct).decode("ascii")
def decrypt_password(encrypted: str) -> str:
"""Decrypt a base64-encoded encrypted password back to plaintext.
If the value is not valid base64 (e.g., legacy plaintext), return as-is.
"""
try:
key = _get_key()
raw = base64.b64decode(encrypted, validate=True)
if len(raw) <= _NONCE_SIZE:
return encrypted # too short to be encrypted
nonce = raw[:_NONCE_SIZE]
ct = raw[_NONCE_SIZE:]
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ct, None).decode("utf-8")
except Exception:
# Not encrypted (legacy plaintext password) — return as-is
return encrypted
+234
View File
@@ -0,0 +1,234 @@
"""SQL Audit Engine — rule-based SQL review for tickets.
Checks SQL against configurable rules and returns structured results:
- level: "error" (must fix), "warning" (should review), "info" (FYI)
- rule: rule identifier
- message: human-readable description
"""
import re
from dataclasses import dataclass, field
from typing import List, Optional
from enum import Enum
class Severity(str, Enum):
ERROR = "error"
WARNING = "warning"
INFO = "info"
@dataclass
class AuditFinding:
rule: str
level: Severity
message: str
@dataclass
class AuditReport:
sql: str
findings: List[AuditFinding] = field(default_factory=list)
@property
def passed(self) -> bool:
return not any(f.level == Severity.ERROR for f in self.findings)
@property
def has_warnings(self) -> bool:
return any(f.level == Severity.WARNING for f in self.findings)
@property
def score(self) -> int:
"""100 = clean, -10 per error, -5 per warning."""
s = 100
for f in self.findings:
if f.level == Severity.ERROR:
s -= 10
elif f.level == Severity.WARNING:
s -= 5
return max(0, s)
def to_dict(self) -> dict:
return {
"passed": self.passed,
"score": self.score,
"findings": [
{"rule": f.rule, "level": f.level.value, "message": f.message}
for f in self.findings
],
}
class SQLAuditEngine:
"""Rule-based SQL audit engine."""
def __init__(self, env: str = "development"):
self.env = env
def audit(self, sql: str) -> AuditReport:
report = AuditReport(sql=sql)
clean = sql.strip()
upper = clean.upper()
# Rule 1: DDL without IF EXISTS
self._check_ddl_if_exists(clean, upper, report)
# Rule 2: DROP TABLE danger
self._check_drop_table(upper, report)
# Rule 3: UPDATE/DELETE without WHERE
self._check_missing_where(clean, upper, report)
# Rule 4: SELECT * usage
self._check_select_star(clean, upper, report)
# Rule 5: LIMIT on DML
self._check_limit_dml(clean, upper, report)
# Rule 6: Production env extra checks
if self.env == "production":
self._check_production_rules(clean, upper, report)
# Rule 7: Multiple statements (semicolon)
self._check_multiple_statements(clean, report)
# Rule 8: Large IN clause
self._check_large_in(clean, upper, report)
# Rule 9: No WHERE with LIKE '%...'
self._check_full_like(clean, upper, report)
# Rule 10: JOIN without ON
self._check_join_without_on(upper, report)
return report
def _check_ddl_if_exists(self, clean: str, upper: str, report: AuditReport):
"""ALTER/DROP statements should use IF EXISTS where applicable."""
if re.search(r"\bDROP\s+TABLE\b", upper) and "IF EXISTS" not in upper:
report.findings.append(AuditFinding(
rule="DDL_IF_EXISTS",
level=Severity.WARNING,
message="DROP TABLE 建议添加 IF EXISTS 防止表不存在时报错",
))
def _check_drop_table(self, upper: str, report: AuditReport):
"""DROP TABLE is always dangerous in production."""
if re.search(r"\bDROP\s+TABLE\b", upper):
if self.env == "production":
report.findings.append(AuditFinding(
rule="DROP_TABLE_PRODUCTION",
level=Severity.ERROR,
message="生产环境禁止 DROP TABLE 操作",
))
else:
report.findings.append(AuditFinding(
rule="DROP_TABLE",
level=Severity.WARNING,
message="DROP TABLE 操作不可逆,请确认已备份",
))
if re.search(r"\bTRUNCATE\b", upper):
report.findings.append(AuditFinding(
rule="TRUNCATE",
level=Severity.ERROR if self.env == "production" else Severity.WARNING,
message="TRUNCATE 会清空表数据且不可回滚",
))
def _check_missing_where(self, clean: str, upper: str, report: AuditReport):
"""UPDATE/DELETE without WHERE clause affects all rows."""
if re.match(r"\s*UPDATE\s+", upper, re.IGNORECASE):
if "WHERE" not in upper:
report.findings.append(AuditFinding(
rule="UPDATE_NO_WHERE",
level=Severity.ERROR,
message="UPDATE 缺少 WHERE 条件,将更新全表",
))
if re.match(r"\s*DELETE\s+FROM\s+", upper, re.IGNORECASE):
if "WHERE" not in upper:
report.findings.append(AuditFinding(
rule="DELETE_NO_WHERE",
level=Severity.ERROR,
message="DELETE 缺少 WHERE 条件,将删除全表数据",
))
def _check_select_star(self, clean: str, upper: str, report: AuditReport):
"""SELECT * is inefficient for large tables."""
if re.search(r"SELECT\s+\*(?:\s|,|\))", upper):
report.findings.append(AuditFinding(
rule="SELECT_STAR",
level=Severity.INFO,
message="SELECT * 可能返回过多列,建议明确指定字段",
))
def _check_limit_dml(self, clean: str, upper: str, report: AuditReport):
"""UPDATE/DELETE with LIMIT is MySQL-specific and risky."""
for kw in ("UPDATE", "DELETE"):
if re.match(rf"\s*{kw}\b", upper):
if "LIMIT" in upper:
report.findings.append(AuditFinding(
rule="DML_WITH_LIMIT",
level=Severity.WARNING,
message=f"{kw} 使用 LIMIT 可能导致部分数据变更,请确认意图",
))
def _check_production_rules(self, clean: str, upper: str, report: AuditReport):
"""Extra strict rules for production."""
if re.search(r"\bALTER\s+TABLE\b", upper):
report.findings.append(AuditFinding(
rule="ALTER_TABLE_PRODUCTION",
level=Severity.ERROR,
message="生产环境 ALTER TABLE 需 DBA 特别审批",
))
if re.search(r"\bCREATE\s+(TABLE|INDEX)\b", upper):
report.findings.append(AuditFinding(
rule="CREATE_DDL_PRODUCTION",
level=Severity.WARNING,
message="生产环境创建对象需确认影响范围",
))
def _check_multiple_statements(self, clean: str, report: AuditReport):
"""Multiple SQL statements separated by semicolons."""
# Count semicolons not at the end
stripped = clean.rstrip(";").strip()
if ";" in stripped:
report.findings.append(AuditFinding(
rule="MULTI_STATEMENT",
level=Severity.ERROR,
message="单次提交不支持多条 SQL 语句(分号分隔),请拆分为多个工单",
))
def _check_large_in(self, clean: str, upper: str, report: AuditReport):
"""IN clause with many values is slow."""
in_matches = re.findall(r"\bIN\s*\(([^)]+)\)", upper)
for m in in_matches:
count = m.count(",") + 1
if count > 100:
report.findings.append(AuditFinding(
rule="LARGE_IN",
level=Severity.WARNING,
message=f"IN 子句包含 {count} 个值,建议使用临时表或 JOIN 替代",
))
def _check_full_like(self, clean: str, upper: str, report: AuditReport):
"""LIKE '%...' cannot use index."""
if re.search(r"\bLIKE\s+'%[^']*'", clean, re.IGNORECASE):
report.findings.append(AuditFinding(
rule="FULL_LIKE",
level=Severity.INFO,
message="LIKE '%...' 无法使用索引,大数据量下性能较差",
))
def _check_join_without_on(self, upper: str, report: AuditReport):
"""JOIN without ON produces cartesian product."""
# Match JOIN but not followed by ON (within reasonable distance)
if re.search(r"\bJOIN\b", upper) and "ON" not in upper:
# Could be NATURAL JOIN, allow that
if not re.search(r"\bNATURAL\s+JOIN\b", upper):
report.findings.append(AuditFinding(
rule="JOIN_NO_ON",
level=Severity.WARNING,
message="JOIN 缺少 ON 条件,可能产生笛卡尔积",
))
+78
View File
@@ -0,0 +1,78 @@
"""Audit log service — centralized logging for all operations."""
from typing import Optional, List, Dict, Any
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog
class AuditLogService:
@staticmethod
async def log(
db: AsyncSession,
user_id: int,
action: str,
target_type: str,
target_id: Optional[int] = None,
detail: Optional[str] = None,
ip_address: Optional[str] = None,
) -> AuditLog:
"""Create an audit log entry."""
entry = AuditLog(
user_id=user_id,
action=action,
target_type=target_type,
target_id=target_id,
detail=detail[:500] if detail else None,
ip_address=ip_address,
)
db.add(entry)
# Don't commit — let the caller's transaction handle it
await db.flush()
return entry
@staticmethod
async def list_logs(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
user_id: Optional[int] = None,
action: Optional[str] = None,
target_type: Optional[str] = None,
) -> Dict[str, Any]:
"""List audit logs with pagination and filters."""
stmt = select(AuditLog)
if user_id:
stmt = stmt.where(AuditLog.user_id == user_id)
if action:
stmt = stmt.where(AuditLog.action == action)
if target_type:
stmt = stmt.where(AuditLog.target_type == target_type)
total_stmt = select(func.count()).select_from(stmt.subquery())
total = (await db.execute(total_stmt)).scalar()
stmt = stmt.order_by(AuditLog.created_at.desc())
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
logs = result.scalars().all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": log.id,
"user_id": log.user_id,
"action": log.action,
"target_type": log.target_type,
"target_id": log.target_id,
"detail": log.detail,
"ip_address": log.ip_address,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
],
}
+194
View File
@@ -0,0 +1,194 @@
"""Tests for SQL Audit Engine (Phase 3.2)."""
import pytest
from app.services.audit_engine import SQLAuditEngine, Severity
class TestSQLAuditEngine:
def setup_method(self):
self.engine = SQLAuditEngine(env="development")
# --- DDL Rules ---
def test_drop_table_without_if_exists(self):
report = self.engine.audit("DROP TABLE users;")
assert not report.passed or report.has_warnings
rules = [f.rule for f in report.findings]
assert "DDL_IF_EXISTS" in rules
assert "DROP_TABLE" in rules
def test_drop_table_production_blocked(self):
engine = SQLAuditEngine(env="production")
report = engine.audit("DROP TABLE users;")
assert not report.passed
errors = [f for f in report.findings if f.level == Severity.ERROR]
assert any(f.rule == "DROP_TABLE_PRODUCTION" for f in errors)
def test_truncate_warning(self):
report = self.engine.audit("TRUNCATE TABLE logs;")
rules = [f.rule for f in report.findings]
assert "TRUNCATE" in rules
def test_truncate_production_error(self):
engine = SQLAuditEngine(env="production")
report = engine.audit("TRUNCATE TABLE logs;")
errors = [f for f in report.findings if f.level == Severity.ERROR]
assert any(f.rule == "TRUNCATE" for f in errors)
# --- Missing WHERE ---
def test_update_no_where_error(self):
report = self.engine.audit("UPDATE users SET status = 1;")
assert not report.passed
errors = [f.rule for f in report.findings if f.level == Severity.ERROR]
assert "UPDATE_NO_WHERE" in errors
def test_delete_no_where_error(self):
report = self.engine.audit("DELETE FROM users;")
assert not report.passed
errors = [f.rule for f in report.findings if f.level == Severity.ERROR]
assert "DELETE_NO_WHERE" in errors
def test_update_with_where_passes(self):
report = self.engine.audit("UPDATE users SET name = 'test' WHERE id = 1;")
error_rules = [f.rule for f in report.findings if f.level == Severity.ERROR]
assert "UPDATE_NO_WHERE" not in error_rules
def test_delete_with_where_passes(self):
report = self.engine.audit("DELETE FROM users WHERE id = 1;")
error_rules = [f.rule for f in report.findings if f.level == Severity.ERROR]
assert "DELETE_NO_WHERE" not in error_rules
# --- SELECT * ---
def test_select_star_info(self):
report = self.engine.audit("SELECT * FROM users WHERE id = 1;")
rules = [f.rule for f in report.findings]
assert "SELECT_STAR" in rules
star_finding = next(f for f in report.findings if f.rule == "SELECT_STAR")
assert star_finding.level == Severity.INFO
def test_select_columns_clean(self):
report = self.engine.audit("SELECT id, name FROM users WHERE id = 1;")
rules = [f.rule for f in report.findings]
assert "SELECT_STAR" not in rules
# --- Multiple statements ---
def test_multi_statement_error(self):
report = self.engine.audit("UPDATE users SET name = 'a'; DELETE FROM logs;")
assert not report.passed
rules = [f.rule for f in report.findings if f.level == Severity.ERROR]
assert "MULTI_STATEMENT" in rules
def test_single_statement_ok(self):
report = self.engine.audit("SELECT id FROM users LIMIT 10;")
rules = [f.rule for f in report.findings]
assert "MULTI_STATEMENT" not in rules
# --- Large IN clause ---
def test_large_in_warning(self):
values = ", ".join([str(i) for i in range(150)])
sql = f"SELECT * FROM users WHERE id IN ({values});"
report = self.engine.audit(sql)
rules = [f.rule for f in report.findings]
assert "LARGE_IN" in rules
def test_small_in_ok(self):
report = self.engine.audit("SELECT * FROM users WHERE id IN (1, 2, 3);")
rules = [f.rule for f in report.findings]
assert "LARGE_IN" not in rules
# --- Production extra rules ---
def test_alter_table_production_error(self):
engine = SQLAuditEngine(env="production")
report = engine.audit("ALTER TABLE users ADD COLUMN age INT;")
assert not report.passed
errors = [f.rule for f in report.findings if f.level == Severity.ERROR]
assert "ALTER_TABLE_PRODUCTION" in errors
def test_alter_table_dev_ok(self):
report = self.engine.audit("ALTER TABLE users ADD COLUMN age INT;")
error_rules = [f.rule for f in report.findings if f.level == Severity.ERROR]
assert "ALTER_TABLE_PRODUCTION" not in error_rules
# --- JOIN without ON ---
def test_join_no_on_warning(self):
report = self.engine.audit("SELECT * FROM users JOIN orders;")
rules = [f.rule for f in report.findings]
assert "JOIN_NO_ON" in rules
def test_join_with_on_ok(self):
report = self.engine.audit("SELECT * FROM users JOIN orders ON users.id = orders.user_id;")
rules = [f.rule for f in report.findings]
assert "JOIN_NO_ON" not in rules
# --- Score ---
def test_clean_sql_score_100(self):
report = self.engine.audit("SELECT id, name FROM users WHERE id = 1 LIMIT 10;")
assert report.score == 100
assert report.passed
def test_error_reduces_score(self):
report = self.engine.audit("UPDATE users SET name = 'test';")
assert report.score < 100
assert not report.passed
def test_report_to_dict(self):
report = self.engine.audit("SELECT * FROM users;")
d = report.to_dict()
assert "passed" in d
assert "score" in d
assert "findings" in d
assert isinstance(d["findings"], list)
class TestAuditEngineIntegration:
"""Test audit engine integration with ticket creation via API."""
@pytest.mark.asyncio
async def test_ticket_creation_returns_audit_report(self, client, db, admin_token, test_instance):
from tests.conftest import auth_header
resp = await client.post(
"/api/v1/tickets",
json={
"title": "SQL审核测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "SELECT * FROM users WHERE id = 1;",
"reason": "测试审核引擎",
"priority": "medium",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
data = resp.json()
assert "audit_report" in data
assert "score" in data["audit_report"]
assert "findings" in data["audit_report"]
@pytest.mark.asyncio
async def test_ticket_with_dangerous_sql_has_low_score(self, client, db, admin_token, test_instance):
from tests.conftest import auth_header
resp = await client.post(
"/api/v1/tickets",
json={
"title": "危险SQL工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "UPDATE users SET status = 1;",
"reason": "全表更新测试",
"priority": "high",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
data = resp.json()
report = data["audit_report"]
assert report["passed"] is False
assert report["score"] < 100
+190
View File
@@ -0,0 +1,190 @@
"""Tests for Audit Log API (Phase 3.4)."""
import pytest
from tests.conftest import auth_header
class TestAuditLogAPI:
@pytest.mark.asyncio
async def test_list_audit_logs_admin(self, client, admin_token):
"""Admin can list audit logs."""
resp = await client.get(
"/api/v1/audit/logs",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert "items" in data
assert "total" in data
assert "page" in data
@pytest.mark.asyncio
async def test_list_audit_logs_dba(self, client, dba_token):
"""DBA can also list audit logs."""
resp = await client.get(
"/api/v1/audit/logs",
headers=auth_header(dba_token),
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_list_audit_logs_developer_forbidden(self, client, developer_token):
"""Developer cannot access audit logs."""
resp = await client.get(
"/api/v1/audit/logs",
headers=auth_header(developer_token),
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_create_instance_logs_audit(self, client, db, admin_token):
"""Creating an instance produces an audit log entry."""
from app.models.audit_log import AuditLog
from sqlalchemy import select
resp = await client.post(
"/api/v1/instances",
json={
"name": "审计测试实例",
"db_type": "mysql",
"host": "10.0.0.100",
"port": 3306,
"username": "root",
"password": "test123",
"database": "test",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
# Check audit log was created
result = await db.execute(
select(AuditLog).where(AuditLog.action == "CREATE_INSTANCE")
)
log = result.scalar_one_or_none()
assert log is not None
assert "审计测试实例" in log.detail
@pytest.mark.asyncio
async def test_create_user_logs_audit(self, client, db, admin_token):
"""Creating a user produces an audit log entry."""
from app.models.audit_log import AuditLog
from sqlalchemy import select
resp = await client.post(
"/api/v1/users",
json={
"username": "audit_test_user",
"display_name": "审计测试",
"password": "123456",
"role": "developer",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
result = await db.execute(
select(AuditLog).where(AuditLog.action == "CREATE_USER")
)
log = result.scalar_one_or_none()
assert log is not None
assert "audit_test_user" in log.detail
@pytest.mark.asyncio
async def test_create_ticket_logs_audit(self, client, db, admin_token, test_instance):
"""Creating a ticket produces an audit log entry with SQL score."""
from app.models.audit_log import AuditLog
from sqlalchemy import select
resp = await client.post(
"/api/v1/tickets",
json={
"title": "审计日志工单测试",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "SELECT id FROM users LIMIT 10;",
"reason": "测试审计日志",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
result = await db.execute(
select(AuditLog).where(AuditLog.action == "CREATE_TICKET")
)
log = result.scalar_one_or_none()
assert log is not None
assert "SQL审核评分" in log.detail
@pytest.mark.asyncio
async def test_audit_log_pagination(self, client, admin_token):
"""Audit log pagination works."""
resp = await client.get(
"/api/v1/audit/logs?page=1&page_size=5",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert data["page_size"] == 5
@pytest.mark.asyncio
async def test_audit_log_filter_by_action(self, client, admin_token, db, test_instance):
"""Can filter audit logs by action."""
# Create a ticket first
await client.post(
"/api/v1/tickets",
json={
"title": "过滤测试工单",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "SELECT 1;",
"reason": "过滤测试用",
},
headers=auth_header(admin_token),
)
resp = await client.get(
"/api/v1/audit/logs?action=CREATE_TICKET",
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
for item in data["items"]:
assert item["action"] == "CREATE_TICKET"
@pytest.mark.asyncio
async def test_review_ticket_logs_audit(self, client, db, admin_token, dba_token, test_instance):
"""Reviewing a ticket creates an audit log."""
from app.models.audit_log import AuditLog
from sqlalchemy import select
# Create ticket as admin
resp = await client.post(
"/api/v1/tickets",
json={
"title": "审核日志测试",
"db_type": "mysql",
"instance_id": test_instance.id,
"target_database": "test_db",
"sql_content": "SELECT 1;",
"reason": "审核日志测试",
},
headers=auth_header(admin_token),
)
ticket_id = resp.json()["id"]
# Review as DBA
resp = await client.post(
f"/api/v1/tickets/{ticket_id}/review",
json={"action": "approve", "comment": "批准"},
headers=auth_header(dba_token),
)
assert resp.status_code == 200
result = await db.execute(
select(AuditLog).where(AuditLog.action == "REVIEW_TICKET")
)
log = result.scalar_one_or_none()
assert log is not None
assert "approve" in log.detail
+138
View File
@@ -0,0 +1,138 @@
"""Tests for AES encryption (Phase 3.1)."""
import pytest
from app.core.crypto import encrypt_password, decrypt_password
class TestEncryption:
def test_encrypt_decrypt_roundtrip(self):
"""Encrypt then decrypt returns original."""
plain = "my_secret_password_123"
encrypted = encrypt_password(plain)
assert encrypted != plain
assert decrypt_password(encrypted) == plain
def test_encrypt_produces_different_ciphertext(self):
"""Each encryption uses a random nonce, so ciphertext differs."""
plain = "same_password"
e1 = encrypt_password(plain)
e2 = encrypt_password(plain)
assert e1 != e2 # different nonces
assert decrypt_password(e1) == plain
assert decrypt_password(e2) == plain
def test_encrypt_empty_string(self):
"""Can encrypt and decrypt empty string."""
encrypted = encrypt_password("")
assert decrypt_password(encrypted) == ""
def test_encrypt_special_characters(self):
"""Handles special characters in passwords."""
plain = 'p@ssw0rd!#$%^&*()_+-=[]{}|;\':",./<>?'
encrypted = encrypt_password(plain)
assert decrypt_password(encrypted) == plain
def test_encrypt_unicode(self):
"""Handles unicode characters."""
plain = "密码测试🔑"
encrypted = encrypt_password(plain)
assert decrypt_password(encrypted) == plain
def test_decrypt_invalid_data_returns_as_is(self):
"""Invalid ciphertext returns original string (backward compat)."""
result = decrypt_password("not_valid_base64!!!")
assert result == "not_valid_base64!!!"
class TestInstanceEncryption:
"""Test that instance passwords are stored encrypted via API."""
@pytest.mark.asyncio
async def test_create_instance_encrypts_password(self, client, admin_token):
"""POST /instances stores password as encrypted ciphertext."""
from tests.conftest import auth_header
resp = await client.post(
"/api/v1/instances",
json={
"name": "加密测试",
"db_type": "mysql",
"host": "10.0.0.1",
"port": 3306,
"username": "root",
"password": "plain_text_pw",
"database": "test",
"env": "development",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
data = resp.json()
# Response should NOT contain password at all (InstanceResponse excludes it)
assert "password" not in data
@pytest.mark.asyncio
async def test_password_not_stored_as_plaintext(self, client, db, admin_token):
"""Verify the database actually stores encrypted, not plain text."""
from tests.conftest import auth_header
from app.models.instance import Instance
from sqlalchemy import select
plain_pw = "super_secret_123"
resp = await client.post(
"/api/v1/instances",
json={
"name": "明文检查",
"db_type": "mysql",
"host": "10.0.0.2",
"port": 3306,
"username": "root",
"password": plain_pw,
"database": "test",
"env": "development",
},
headers=auth_header(admin_token),
)
assert resp.status_code == 201
instance_id = resp.json()["id"]
# Read raw from database
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one()
assert instance.password != plain_pw # Not stored as plaintext
assert len(instance.password) > 20 # Encrypted is longer
@pytest.mark.asyncio
async def test_update_instance_re_encrypts_password(self, client, db, admin_token):
"""PUT /instances/:id re-encrypts password when updated."""
from tests.conftest import auth_header
from app.models.instance import Instance
from sqlalchemy import select
# Create
resp = await client.post(
"/api/v1/instances",
json={
"name": "更新加密",
"db_type": "mysql",
"host": "10.0.0.3",
"port": 3306,
"username": "root",
"password": "old_password",
"database": "test",
},
headers=auth_header(admin_token),
)
iid = resp.json()["id"]
# Update password
resp = await client.put(
f"/api/v1/instances/{iid}",
json={"password": "new_password"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
# Verify encrypted
result = await db.execute(select(Instance).where(Instance.id == iid))
inst = result.scalar_one()
assert inst.password != "new_password"
assert inst.password != "old_password"
+2
View File
@@ -15,6 +15,7 @@ import WorkflowPage from '@/pages/WorkflowPage';
import AutoTaskPage from '@/pages/AutoTaskPage'; import AutoTaskPage from '@/pages/AutoTaskPage';
import AnnouncementPage from '@/pages/AnnouncementPage'; import AnnouncementPage from '@/pages/AnnouncementPage';
import ConfigPlatformPage from '@/pages/ConfigPlatformPage'; import ConfigPlatformPage from '@/pages/ConfigPlatformPage';
import AuditLogsPage from '@/pages/AuditLogsPage';
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘 // 路由守卫:未登录跳转登录页,无权限跳转仪表盘
function RouteGuard() { function RouteGuard() {
@@ -60,6 +61,7 @@ function AppRoutes() {
<Route path="users" element={<UsersPage />} /> <Route path="users" element={<UsersPage />} />
<Route path="data-permission" element={<DataPermissionPage />} /> <Route path="data-permission" element={<DataPermissionPage />} />
<Route path="audit-rules" element={<AuditRulesPage />} /> <Route path="audit-rules" element={<AuditRulesPage />} />
<Route path="audit-logs" element={<AuditLogsPage />} />
<Route path="workflow" element={<WorkflowPage />} /> <Route path="workflow" element={<WorkflowPage />} />
<Route path="auto-task" element={<AutoTaskPage />} /> <Route path="auto-task" element={<AutoTaskPage />} />
<Route path="announcement" element={<AnnouncementPage />} /> <Route path="announcement" element={<AnnouncementPage />} />
+1
View File
@@ -46,6 +46,7 @@ const routePermissionMap: Record<string, UserRole[]> = {
users: ['admin'], users: ['admin'],
'data-permission': ['admin', 'dba'], 'data-permission': ['admin', 'dba'],
'audit-rules': ['admin', 'dba'], 'audit-rules': ['admin', 'dba'],
'audit-logs': ['admin', 'dba'],
workflow: ['admin', 'dba'], workflow: ['admin', 'dba'],
'auto-task': ['admin'], 'auto-task': ['admin'],
announcement: ['admin'], announcement: ['admin'],
+3 -2
View File
@@ -32,6 +32,7 @@ export const menuItems = [
{ id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] }, { id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] },
{ id: 'data-permission', label: '数据权限', icon: 'Lock', section: 'permission', roles: ['admin', 'dba'] }, { id: 'data-permission', label: '数据权限', icon: 'Lock', section: 'permission', roles: ['admin', 'dba'] },
{ id: 'audit-rules', label: '审核规则', icon: 'Shield', section: 'system', roles: ['admin', 'dba'] }, { id: 'audit-rules', label: '审核规则', icon: 'Shield', section: 'system', roles: ['admin', 'dba'] },
{ id: 'audit-logs', label: '审计日志', icon: 'FileText', section: 'system', roles: ['admin', 'dba'] },
{ id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'] }, { id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'] },
{ id: 'auto-task', label: '自动任务', icon: 'Clock', section: 'system', roles: ['admin'] }, { id: 'auto-task', label: '自动任务', icon: 'Clock', section: 'system', roles: ['admin'] },
{ id: 'announcement', label: '公告管理', icon: 'Megaphone', section: 'system', roles: ['admin'] }, { id: 'announcement', label: '公告管理', icon: 'Megaphone', section: 'system', roles: ['admin'] },
@@ -165,8 +166,8 @@ export const notificationChannels: NotificationChannel[] = [
]; ];
export const roles: Role[] = [ export const roles: Role[] = [
{ id: 'admin', name: '超级管理员', description: '拥有系统的所有权限,包括用户管理、系统配置等', icon: 'Crown', iconClass: 'text-purple-400 bg-purple-400/10', userCount: 2, permissionCount: 56, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'queries', 'roles', 'users', 'data-permission', 'audit-rules', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'] }, { id: 'admin', name: '超级管理员', description: '拥有系统的所有权限,包括用户管理、系统配置等', icon: 'Crown', iconClass: 'text-purple-400 bg-purple-400/10', userCount: 2, permissionCount: 56, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'queries', 'roles', 'users', 'data-permission', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'] },
{ id: 'dba', name: '数据库管理员', description: '负责数据库运维、工单审批和 SQL 审核', icon: 'Database', iconClass: 'text-blue-400 bg-blue-400/10', userCount: 5, permissionCount: 32, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'tickets', 'queries', 'data-permission', 'audit-rules', 'workflow'] }, { id: 'dba', name: '数据库管理员', description: '负责数据库运维、工单审批和 SQL 审核', icon: 'Database', iconClass: 'text-blue-400 bg-blue-400/10', userCount: 5, permissionCount: 32, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'tickets', 'queries', 'data-permission', 'audit-rules', 'audit-logs', 'workflow'] },
{ id: 'developer', name: '开发人员', description: '可以提交变更工单和执行数据查询', icon: 'Code', iconClass: 'text-green-400 bg-green-400/10', userCount: 28, permissionCount: 12, permissions: ['dashboard', 'tickets', 'queries'] }, { id: 'developer', name: '开发人员', description: '可以提交变更工单和执行数据查询', icon: 'Code', iconClass: 'text-green-400 bg-green-400/10', userCount: 28, permissionCount: 12, permissions: ['dashboard', 'tickets', 'queries'] },
{ id: 'guest', name: '访客', description: '仅可查看,无法执行任何操作', icon: 'Eye', iconClass: 'text-gray-400 bg-gray-400/10', userCount: 8, permissionCount: 4, permissions: ['dashboard', 'queries'] }, { id: 'guest', name: '访客', description: '仅可查看,无法执行任何操作', icon: 'Eye', iconClass: 'text-gray-400 bg-gray-400/10', userCount: 8, permissionCount: 4, permissions: ['dashboard', 'queries'] },
]; ];
+159
View File
@@ -0,0 +1,159 @@
import { useState, useEffect } from 'react';
import request from '../api/request';
interface AuditLog {
id: number;
user_id: number;
action: string;
target_type: string;
target_id: number | null;
detail: string | null;
ip_address: string | null;
created_at: string | null;
}
interface AuditLogResponse {
total: number;
page: number;
page_size: number;
items: AuditLog[];
}
const ACTION_COLORS: Record<string, string> = {
CREATE_TICKET: 'bg-blue-100 text-blue-800',
REVIEW_TICKET: 'bg-yellow-100 text-yellow-800',
EXECUTE_TICKET: 'bg-green-100 text-green-800',
CREATE_INSTANCE: 'bg-purple-100 text-purple-800',
DELETE_INSTANCE: 'bg-red-100 text-red-800',
CREATE_USER: 'bg-indigo-100 text-indigo-800',
DELETE_USER: 'bg-red-100 text-red-800',
QUERY: 'bg-cyan-100 text-cyan-800',
};
const TARGET_ICONS: Record<string, string> = {
ticket: '📋',
instance: '🗄️',
user: '👤',
query: '🔍',
};
export default function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [filterAction, setFilterAction] = useState('');
const pageSize = 20;
useEffect(() => {
fetchLogs();
}, [page, filterAction]);
const fetchLogs = async () => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (filterAction) params.set('action', filterAction);
const res = await request.get(`/audit/logs?${params}`);
const data = res.data as AuditLogResponse;
setLogs(data.items || []);
setTotal(data.total);
} catch (e) {
console.error('获取审计日志失败', e);
} finally {
setLoading(false);
}
};
const totalPages = Math.ceil(total / pageSize);
const formatDate = (d: string | null) => {
if (!d) return '-';
return new Date(d).toLocaleString('zh-CN');
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-800">审计日志</h2>
<span className="text-sm text-gray-500">共 {total} 条记录</span>
</div>
{/* Filters */}
<div className="flex gap-2">
{['', 'CREATE_TICKET', 'REVIEW_TICKET', 'EXECUTE_TICKET', 'CREATE_INSTANCE', 'DELETE_INSTANCE', 'QUERY'].map(action => (
<button
key={action}
onClick={() => { setFilterAction(action); setPage(1); }}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
filterAction === action
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{action || '全部'}
</button>
))}
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 text-gray-600">
<th className="px-4 py-3 text-left font-medium">操作</th>
<th className="px-4 py-3 text-left font-medium">目标</th>
<th className="px-4 py-3 text-left font-medium">详情</th>
<th className="px-4 py-3 text-left font-medium">时间</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading ? (
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400">加载中...</td></tr>
) : logs.length === 0 ? (
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400">暂无审计日志</td></tr>
) : (
logs.map(log => (
<tr key={log.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${ACTION_COLORS[log.action] || 'bg-gray-100 text-gray-600'}`}>
{log.action}
</span>
</td>
<td className="px-4 py-3">
<span className="mr-1">{TARGET_ICONS[log.target_type] || '📌'}</span>
<span className="text-gray-600">{log.target_type}</span>
{log.target_id && <span className="text-gray-400 ml-1">#{log.target_id}</span>}
</td>
<td className="px-4 py-3 text-gray-500 max-w-md truncate">{log.detail || '-'}</td>
<td className="px-4 py-3 text-gray-400 whitespace-nowrap">{formatDate(log.created_at)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-3 py-1 rounded bg-gray-100 text-gray-600 disabled:opacity-50"
>
上一页
</button>
<span className="px-3 py-1 text-gray-500">{page} / {totalPages}</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="px-3 py-1 rounded bg-gray-100 text-gray-600 disabled:opacity-50"
>
下一页
</button>
</div>
)}
</div>
);
}