增加充值审计日志

This commit is contained in:
yml2213
2026-08-14 11:52:30 +08:00
parent 7fefe7e9f9
commit 7fe6228901
16 changed files with 675 additions and 3 deletions
+103
View File
@@ -0,0 +1,103 @@
"""充值审计日志的权限、查询和脱敏测试。"""
import json
import os
import unittest
os.environ.setdefault("DATABASE_URL", "sqlite://")
os.environ.setdefault("APP_ENCRYPTION_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from web.backend.database import Base
from web.backend.models import Account, AuditLog, DouyuTask, User
from web.backend.routers.audit import list_audit_logs, require_super_admin
from web.backend.services.audit_service import record_audit
class AuditLogTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite://")
Base.metadata.create_all(self.engine)
self.session = sessionmaker(bind=self.engine)()
self.admin = User(username="admin", password_hash="hash", role="super_admin")
self.operator = User(
username="operator", password_hash="hash", role="operation",
custom_permissions=["audit:view"],
)
self.session.add_all([self.admin, self.operator])
self.session.commit()
def tearDown(self):
self.session.close()
Base.metadata.drop_all(self.engine)
self.engine.dispose()
def test_only_super_admin_can_read_audit_logs(self):
with self.assertRaises(HTTPException) as context:
require_super_admin(self.operator)
self.assertEqual(context.exception.status_code, 403)
self.assertIs(require_super_admin(self.admin), self.admin)
def test_list_audit_logs_supports_pagination_and_result_filter(self):
for index in range(3):
record_audit(
self.session, self.admin, action="recharge:yyb:payment",
target=f"yyb_task:{index}", detail={"task_id": index}, success=index != 1,
)
self.session.commit()
result = list_audit_logs(
page=1, page_size=1, username=None, action="recharge:yyb:payment",
keyword=None, success=True, start_time=None, end_time=None,
db=self.session, _=self.admin,
)
self.assertEqual(result["total"], 2)
self.assertEqual(len(result["items"]), 1)
self.assertTrue(result["items"][0]["success"])
def test_audit_detail_masks_sensitive_fields(self):
record_audit(
self.session, self.admin, action="recharge:yyb:payment",
detail={"token": "token-value", "nested": {"cookie": "cookie-value"}, "order_id": "order-1"},
)
self.session.commit()
entry = self.session.query(AuditLog).one()
detail = json.loads(entry.detail)
self.assertEqual(detail["token"], "[已脱敏]")
self.assertEqual(detail["nested"]["cookie"], "[已脱敏]")
self.assertEqual(detail["order_id"], "order-1")
def test_old_douyu_recharge_log_is_enriched_with_account_identity(self):
account = Account(
username="douyu-login", password="password", email="mail@example.com",
email_password="mail-password", uid="10001", nickname="斗鱼昵称",
)
self.session.add(account)
self.session.commit()
self.session.add(DouyuTask(
batch_id="batch-old", account_id=account.id, task_type="create_gold_qr",
handbook_scope="elite", created_by=self.admin.id,
))
self.session.add(AuditLog(
user_id=self.admin.id, username=self.admin.username,
action="recharge:douyu:create", target="douyu_batch:batch-old", detail="{}", success=True,
))
self.session.commit()
result = list_audit_logs(
page=1, page_size=50, username=None, action=None, keyword=None, success=None,
start_time=None, end_time=None, db=self.session, _=self.admin,
)
detail = json.loads(result["items"][0]["detail"])
self.assertEqual(detail["recharge_accounts"][0]["username"], "douyu-login")
self.assertEqual(detail["recharge_accounts"][0]["douyu_uid"], "10001")
if __name__ == "__main__":
unittest.main()
+26 -1
View File
@@ -12,9 +12,10 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from web.backend.database import Base from web.backend.database import Base
from web.backend.models import Account, DouyuTask, User from web.backend.models import Account, AuditLog, DouyuTask, User
from web.backend.routers.douyu import supplier_recharge_callback from web.backend.routers.douyu import supplier_recharge_callback
from web.backend.services.douyu_runner import DouyuBatchRunner from web.backend.services.douyu_runner import DouyuBatchRunner
from web.backend.services.audit_service import record_audit
from core.douyu import FishFinRechargeClient, FishFinRechargeConfig from core.douyu import FishFinRechargeClient, FishFinRechargeConfig
@@ -123,6 +124,26 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
"DYGFHISTORICAL-001", "DYGFHISTORICAL-001",
) )
def test_recharge_audit_detail_includes_safe_account_identity(self):
record_audit(
self.session,
self.session.get(User, self.task.created_by),
action="recharge:douyu:create",
detail={
"recharge_accounts": [{
"username": self.account.username,
"douyu_uid": self.account.uid,
"douyu_nickname": self.account.nickname,
}],
},
)
self.session.commit()
audit = self.session.query(AuditLog).one()
self.assertIn("douyu_uid", audit.detail)
self.assertIn("10001", audit.detail)
self.assertNotIn("password", audit.detail)
@patch("web.backend.services.douyu_runner.FishFinRechargeClient") @patch("web.backend.services.douyu_runner.FishFinRechargeClient")
def test_supplier_channel_marks_non_200_create_response_as_failed(self, client_class): def test_supplier_channel_marks_non_200_create_response_as_failed(self, client_class):
supplier = Mock() supplier = Mock()
@@ -174,6 +195,10 @@ class DouyuGoldRechargeChannelTests(unittest.TestCase):
self.assertEqual(self.account.bind_status, "gold_recharged") self.assertEqual(self.account.bind_status, "gold_recharged")
self.assertEqual(self.task.result["order_id"], "supplier-001") self.assertEqual(self.task.result["order_id"], "supplier-001")
self.assertEqual(self.task.result["supplier_order_status"], 2) self.assertEqual(self.task.result["supplier_order_status"], 2)
audit = self.session.query(AuditLog).filter(AuditLog.action == "recharge:douyu:callback").one()
self.assertEqual(audit.username, "supplier")
self.assertTrue(audit.success)
self.assertNotIn("sign", audit.detail)
# 同一终态回调可重复投递,不会改写已结束任务。 # 同一终态回调可重复投递,不会改写已结束任务。
asyncio.run(supplier_recharge_callback(request, self.session)) asyncio.run(supplier_recharge_callback(request, self.session))
self.session.refresh(self.task) self.session.refresh(self.task)
+2 -1
View File
@@ -12,7 +12,7 @@ from fastapi.responses import FileResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from .database import init_db from .database import init_db
from .routers import auth, users, accounts, account_check, dashboard, login, proxy, cookies, huya, douyu, yyb from .routers import auth, users, accounts, account_check, dashboard, login, proxy, cookies, huya, douyu, yyb, audit
from .schemas import AppInfo from .schemas import AppInfo
from .version import get_app_version from .version import get_app_version
from utils import setup_logger from utils import setup_logger
@@ -102,6 +102,7 @@ app.include_router(cookies.router)
app.include_router(huya.router) app.include_router(huya.router)
app.include_router(douyu.router) app.include_router(douyu.router)
app.include_router(yyb.router) app.include_router(yyb.router)
app.include_router(audit.router)
@app.get("/api/health") @app.get("/api/health")
@@ -0,0 +1,45 @@
"""为审计日志增加操作结果标记。
Revision ID: 20260814_0029
Revises: 20260814_0028
Create Date: 2026-08-14
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260814_0029"
down_revision: Union[str, None] = "20260814_0028"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""保留历史日志,新增可空结果字段。"""
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("audit_logs"):
return
columns = {column["name"] for column in inspector.get_columns("audit_logs")}
if "success" not in columns:
op.add_column("audit_logs", sa.Column("success", sa.Boolean(), nullable=True))
indexes = {index["name"] for index in inspector.get_indexes("audit_logs")}
if "ix_audit_logs_success" not in indexes:
op.create_index("ix_audit_logs_success", "audit_logs", ["success"])
def downgrade() -> None:
"""移除新增结果字段。"""
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("audit_logs"):
return
indexes = {index["name"] for index in inspector.get_indexes("audit_logs")}
if "ix_audit_logs_success" in indexes:
op.drop_index("ix_audit_logs_success", table_name="audit_logs")
columns = {column["name"] for column in inspector.get_columns("audit_logs")}
if "success" in columns:
op.drop_column("audit_logs", "success")
+2
View File
@@ -458,4 +458,6 @@ class AuditLog(Base):
action = Column(String(128), nullable=False) action = Column(String(128), nullable=False)
target = Column(String(256), default="") target = Column(String(256), default="")
detail = Column(Text, default="") detail = Column(Text, default="")
# 旧记录未标记结果时为 NULL,避免把历史操作误判成失败。
success = Column(Boolean, nullable=True, index=True)
created_at = Column(DateTime, default=_utcnow) created_at = Column(DateTime, default=_utcnow)
+103
View File
@@ -0,0 +1,103 @@
"""审计日志查询接口,仅超级管理员可访问。"""
from __future__ import annotations
import json
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import Account, AuditLog, DouyuTask, User
router = APIRouter(prefix="/api/audit-logs", tags=["审计日志"])
def require_super_admin(current: User = Depends(get_current_user)) -> User:
"""审计日志属于高敏感运维数据,只允许超级管理员查看。"""
if current.role != "super_admin":
raise HTTPException(status_code=403, detail="仅超级管理员可查看审计日志")
return current
def _audit_log_out(db: Session, row: AuditLog) -> dict:
"""序列化审计记录,并为旧斗鱼直充批次补齐可安全展示的账号身份。"""
detail_text = row.detail or ""
if row.action == "recharge:douyu:create" and row.target.startswith("douyu_batch:"):
try:
detail = json.loads(detail_text) if detail_text else {}
except json.JSONDecodeError:
detail = None
if isinstance(detail, dict) and not detail.get("recharge_accounts"):
batch_id = row.target.removeprefix("douyu_batch:")
accounts = (
db.query(DouyuTask, Account)
.join(Account, Account.id == DouyuTask.account_id)
.filter(DouyuTask.batch_id == batch_id, DouyuTask.task_type == "create_gold_qr")
.order_by(DouyuTask.id.asc())
.limit(100)
.all()
)
if accounts:
detail["recharge_accounts"] = [
{
"task_id": task.id,
"username": account.username,
"douyu_uid": account.uid or "",
"douyu_nickname": account.nickname or "",
}
for task, account in accounts
]
detail["recharge_accounts_truncated"] = len(accounts) == 100
detail_text = json.dumps(detail, ensure_ascii=False, separators=(",", ":"))
return {
"id": row.id,
"user_id": row.user_id,
"username": row.username or "",
"action": row.action,
"target": row.target or "",
"detail": detail_text,
"success": row.success,
"created_at": row.created_at,
}
@router.get("")
def list_audit_logs(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
username: str | None = Query(None, max_length=64),
action: str | None = Query(None, max_length=128),
keyword: str | None = Query(None, max_length=128),
success: bool | None = Query(None),
start_time: datetime | None = Query(None),
end_time: datetime | None = Query(None),
db: Session = Depends(get_db),
_: User = Depends(require_super_admin),
):
"""分页查询审计记录,不提供修改和删除能力。"""
query = db.query(AuditLog)
if username:
query = query.filter(AuditLog.username.ilike(f"%{username.strip()}%"))
if action:
query = query.filter(AuditLog.action == action.strip())
if keyword:
pattern = f"%{keyword.strip()}%"
query = query.filter((AuditLog.target.ilike(pattern)) | (AuditLog.detail.ilike(pattern)))
if success is not None:
query = query.filter(AuditLog.success == success)
if start_time:
query = query.filter(AuditLog.created_at >= start_time)
if end_time:
query = query.filter(AuditLog.created_at <= end_time)
total = query.count()
rows = query.order_by(AuditLog.id.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"items": [_audit_log_out(db, row) for row in rows],
"total": total,
"page": page,
"page_size": page_size,
}
+69
View File
@@ -26,6 +26,7 @@ from ..schemas import (
DouyuXpdGoodsOut, DouyuXpdGoodsOut,
) )
from ..services.douyu_runner import DouyuBatchRunner, douyu_batch_registry from ..services.douyu_runner import DouyuBatchRunner, douyu_batch_registry
from ..services.audit_service import record_audit
from ..services.douyu_service import ( from ..services.douyu_service import (
DOUYU_CONFIG_FIELDS, DOUYU_CONFIG_FIELDS,
DOUYU_HANDBOOK_TASK_TYPES, DOUYU_HANDBOOK_TASK_TYPES,
@@ -110,6 +111,19 @@ async def supplier_recharge_callback(request: Request, db: Session = Depends(get
task.status = "running" task.status = "running"
task.message = f"供应商直充订单处理中(状态 {status if status is not None else '-'}" task.message = f"供应商直充订单处理中(状态 {status if status is not None else '-'}"
task.result = result task.result = result
callback_success = status not in {3, 4}
record_audit(
db, None, action="recharge:douyu:callback", target=f"supplier_order:{out_order_id}",
detail={
"out_order_id": out_order_id,
"task_id": task.id,
"order_id": result.get("order_id"),
"supplier_order_status": status,
"task_status": task.status,
"message": task.message,
},
success=callback_success,
)
db.commit() db.commit()
logger.info("[douyu] 供应商直充回调已处理: out_order_id={} status={}", out_order_id, status) logger.info("[douyu] 供应商直充回调已处理: out_order_id={} status={}", out_order_id, status)
acknowledgement = {"code": 200, "message": "success"} acknowledgement = {"code": 200, "message": "success"}
@@ -400,13 +414,21 @@ def update_config(
): ):
"""更新斗鱼活动配置。""" """更新斗鱼活动配置。"""
config = ensure_douyu_config(db) config = ensure_douyu_config(db)
changed_fields = []
for field in DOUYU_CONFIG_FIELDS: for field in DOUYU_CONFIG_FIELDS:
value = getattr(req, field) value = getattr(req, field)
if value is None: if value is None:
continue continue
setattr(config, field, value.strip() if isinstance(value, str) else value) setattr(config, field, value.strip() if isinstance(value, str) else value)
changed_fields.append(field)
apply_douyu_config_defaults(config) apply_douyu_config_defaults(config)
config.updated_at = datetime.now(timezone.utc) config.updated_at = datetime.now(timezone.utc)
recharge_fields = [field for field in changed_fields if field.startswith("gold_")]
if recharge_fields:
record_audit(
db, current, action="recharge:douyu:config", target="douyu_config",
detail={"changed_fields": recharge_fields},
)
db.commit() db.commit()
db.refresh(config) db.refresh(config)
return _config_out(config) return _config_out(config)
@@ -474,6 +496,35 @@ async def create_task_batch(
if count == 0: if count == 0:
raise HTTPException(status_code=400, detail="没有可执行的斗鱼账号,请先登录获取 Cookie") raise HTTPException(status_code=400, detail="没有可执行的斗鱼账号,请先登录获取 Cookie")
if req.task_type == "create_gold_qr":
recharge_accounts = (
db.query(DouyuTask, Account)
.join(Account, Account.id == DouyuTask.account_id)
.filter(DouyuTask.batch_id == batch_id, DouyuTask.task_type == "create_gold_qr")
.order_by(DouyuTask.id.asc())
.limit(100)
.all()
)
record_audit(
db, current, action="recharge:douyu:create", target=f"douyu_batch:{batch_id}",
detail={
"batch_id": batch_id, "task_type": req.task_type, "count": count,
"account_count": len(req.account_ids), "handbook_scope": req.handbook_scope,
# 仅记录充值身份,禁止在审计中写入 Cookie、密码等凭据。
"recharge_accounts": [
{
"task_id": task.id,
"username": account.username,
"douyu_uid": account.uid or "",
"douyu_nickname": account.nickname or "",
}
for task, account in recharge_accounts
],
"recharge_accounts_truncated": count > len(recharge_accounts),
},
)
db.commit()
log_queue = asyncio.Queue() log_queue = asyncio.Queue()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
thread_db = SessionLocal() thread_db = SessionLocal()
@@ -565,6 +616,12 @@ def stop_batch(
): ):
"""停止正在运行的斗鱼批次。""" """停止正在运行的斗鱼批次。"""
_require_batch_owner(db, current, batch_id) _require_batch_owner(db, current, batch_id)
is_recharge_batch = (
db.query(DouyuTask.id)
.filter(DouyuTask.batch_id == batch_id, DouyuTask.task_type == "create_gold_qr")
.first()
is not None
)
batch = douyu_batch_registry.get(batch_id) batch = douyu_batch_registry.get(batch_id)
if batch: if batch:
if batch.get("finished"): if batch.get("finished"):
@@ -574,10 +631,22 @@ def stop_batch(
return {"message": f"批次已结束,已清理 {cleaned} 个残留任务", "success": True} return {"message": f"批次已结束,已清理 {cleaned} 个残留任务", "success": True}
raise HTTPException(status_code=404, detail="批次已结束") raise HTTPException(status_code=404, detail="批次已结束")
batch["runner"].stop() batch["runner"].stop()
if is_recharge_batch:
record_audit(
db, current, action="recharge:douyu:stop", target=f"douyu_batch:{batch_id}",
detail={"batch_id": batch_id, "mode": "running"},
)
db.commit()
return {"message": "已发送停止信号", "success": True} return {"message": "已发送停止信号", "success": True}
cleaned = cleanup_orphan_douyu_tasks(db, batch_id=batch_id, message="任务已停止(批次不存在)") cleaned = cleanup_orphan_douyu_tasks(db, batch_id=batch_id, message="任务已停止(批次不存在)")
if cleaned: if cleaned:
if is_recharge_batch:
record_audit(
db, current, action="recharge:douyu:stop", target=f"douyu_batch:{batch_id}",
detail={"batch_id": batch_id, "mode": "orphan_cleanup", "cleaned": cleaned},
)
db.commit()
return {"message": f"已清理 {cleaned} 个残留任务", "success": True} return {"message": f"已清理 {cleaned} 个残留任务", "success": True}
raise HTTPException(status_code=404, detail="批次不存在或已结束") raise HTTPException(status_code=404, detail="批次不存在或已结束")
+36
View File
@@ -73,6 +73,7 @@ from ..services.huya_service import (
upsert_huya_cookie, upsert_huya_cookie,
) )
from ..services.huya_runner import HuyaBatchRunner, huya_batch_registry from ..services.huya_runner import HuyaBatchRunner, huya_batch_registry
from ..services.audit_service import record_audit
from ..services.huya_register_runner import ( from ..services.huya_register_runner import (
export_success_logs_text, export_success_logs_text,
huya_register_registry, huya_register_registry,
@@ -1292,12 +1293,19 @@ def update_config(
): ):
"""更新虎牙配置。""" """更新虎牙配置。"""
config = ensure_huya_config(db) config = ensure_huya_config(db)
changed_fields = []
for field in HUYA_CONFIG_FIELDS: for field in HUYA_CONFIG_FIELDS:
value = getattr(req, field) value = getattr(req, field)
if value is not None: if value is not None:
setattr(config, field, value.strip()) setattr(config, field, value.strip())
changed_fields.append(field)
apply_huya_config_defaults(config) apply_huya_config_defaults(config)
config.updated_at = datetime.now(timezone.utc) config.updated_at = datetime.now(timezone.utc)
if changed_fields:
record_audit(
db, current, action="recharge:huya:config", target="huya_config",
detail={"changed_fields": changed_fields},
)
db.commit() db.commit()
db.refresh(config) db.refresh(config)
return _config_out(config) return _config_out(config)
@@ -1356,6 +1364,16 @@ async def create_task_batch(
if count == 0: if count == 0:
raise HTTPException(status_code=400, detail="没有有效的虎牙账号") raise HTTPException(status_code=400, detail="没有有效的虎牙账号")
if req.task_type == "create_recharge_order":
record_audit(
db, current, action="recharge:huya:create", target=f"huya_batch:{batch_id}",
detail={
"batch_id": batch_id, "task_type": req.task_type, "count": count,
"account_count": len(req.account_ids),
},
)
db.commit()
log_queue = asyncio.Queue() log_queue = asyncio.Queue()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
thread_db = SessionLocal() thread_db = SessionLocal()
@@ -1436,6 +1454,12 @@ def stop_batch(
): ):
"""停止正在运行的虎牙批次。""" """停止正在运行的虎牙批次。"""
_require_huya_batch_owner(db, current, batch_id) _require_huya_batch_owner(db, current, batch_id)
is_recharge_batch = (
db.query(HuyaTask.id)
.filter(HuyaTask.batch_id == batch_id, HuyaTask.task_type == "create_recharge_order")
.first()
is not None
)
batch = huya_batch_registry.get(batch_id) batch = huya_batch_registry.get(batch_id)
if batch: if batch:
if batch.get("finished"): if batch.get("finished"):
@@ -1449,6 +1473,12 @@ def stop_batch(
return {"message": f"批次已结束,已清理 {cleaned} 个残留任务", "success": True} return {"message": f"批次已结束,已清理 {cleaned} 个残留任务", "success": True}
raise HTTPException(status_code=404, detail="批次已结束") raise HTTPException(status_code=404, detail="批次已结束")
batch["runner"].stop() batch["runner"].stop()
if is_recharge_batch:
record_audit(
db, current, action="recharge:huya:stop", target=f"huya_batch:{batch_id}",
detail={"batch_id": batch_id, "mode": "running"},
)
db.commit()
return {"message": "已发送停止信号", "success": True} return {"message": "已发送停止信号", "success": True}
cleaned = cleanup_orphan_huya_tasks( cleaned = cleanup_orphan_huya_tasks(
@@ -1457,6 +1487,12 @@ def stop_batch(
message="任务已停止(批次不存在)", message="任务已停止(批次不存在)",
) )
if cleaned: if cleaned:
if is_recharge_batch:
record_audit(
db, current, action="recharge:huya:stop", target=f"huya_batch:{batch_id}",
detail={"batch_id": batch_id, "mode": "orphan_cleanup", "cleaned": cleaned},
)
db.commit()
return {"message": f"已清理 {cleaned} 个残留任务", "success": True} return {"message": f"已清理 {cleaned} 个残留任务", "success": True}
raise HTTPException(status_code=404, detail="批次不存在或已结束") raise HTTPException(status_code=404, detail="批次不存在或已结束")
+35
View File
@@ -14,6 +14,7 @@ from ..permissions import user_has_permission
from ..schemas import YybLoginRequest, YybSelectionRequest, YybTaskCreateRequest from ..schemas import YybLoginRequest, YybSelectionRequest, YybTaskCreateRequest
from ..services.yyb_service import _utcnow, public_task, sync_task from ..services.yyb_service import _utcnow, public_task, sync_task
from ..services.yyb_worker_client import YybWorkerClient, YybWorkerError from ..services.yyb_worker_client import YybWorkerClient, YybWorkerError
from ..services.audit_service import record_audit
router = APIRouter(prefix="/api/yyb", tags=["应用宝充值"]) router = APIRouter(prefix="/api/yyb", tags=["应用宝充值"])
@@ -58,6 +59,10 @@ def create_task(payload: YybTaskCreateRequest, db: Session = Depends(get_db), cu
worker_job_id=str(data["job_id"]), status=str(data.get("status", "created")), worker_job_id=str(data["job_id"]), status=str(data.get("status", "created")),
phase="login", message="请选择登录方式") phase="login", message="请选择登录方式")
db.add(task) db.add(task)
record_audit(
db, current, action="recharge:yyb:create", target=f"yyb_task:{task.task_id}",
detail={"task_id": task.task_id, "status": task.status},
)
db.commit() db.commit()
db.refresh(task) db.refresh(task)
return public_task(task, include_qr=False, creator_username=current.username) return public_task(task, include_qr=False, creator_username=current.username)
@@ -73,6 +78,10 @@ def login(task_id: int, payload: YybLoginRequest, db: Session = Depends(get_db),
task.message = "请扫码登录并在手机确认" task.message = "请扫码登录并在手机确认"
if data.get("qr_data"): if data.get("qr_data"):
task.login_qr_data = data["qr_data"] task.login_qr_data = data["qr_data"]
record_audit(
db, current, action="recharge:yyb:login", target=f"yyb_task:{task.task_id}",
detail={"task_id": task.task_id, "provider": payload.provider, "status": task.status},
)
db.commit() db.commit()
return public_task(task, creator_username=_creator_username(db, task)) return public_task(task, creator_username=_creator_username(db, task))
@@ -129,6 +138,14 @@ def selection(task_id: int, payload: YybSelectionRequest, db: Session = Depends(
setattr(task, field, selected[field]) setattr(task, field, selected[field])
task.price_fen = int(selected.get("price_fen") or 0) task.price_fen = int(selected.get("price_fen") or 0)
task.phase, task.status, task.message = "payment", "ready", "选择已保存,可以生成付款码" task.phase, task.status, task.message = "payment", "ready", "选择已保存,可以生成付款码"
record_audit(
db, current, action="recharge:yyb:selection", target=f"yyb_task:{task.task_id}",
detail={
"task_id": task.task_id, "product_id": task.product_id, "points": task.points,
"zone_id": task.zone_id, "zone_name": task.zone_name, "role_id": task.role_id,
"role_name": task.role_name, "price_fen": task.price_fen,
},
)
db.commit() db.commit()
return public_task(task, creator_username=_creator_username(db, task)) return public_task(task, creator_username=_creator_username(db, task))
@@ -156,11 +173,20 @@ def payment(task_id: int, db: Session = Depends(get_db), current: User = Depends
task.phase = "payment" task.phase = "payment"
task.message = "生成付款码失败,请稍后重试" task.message = "生成付款码失败,请稍后重试"
task.payment_started_at = None task.payment_started_at = None
record_audit(
db, current, action="recharge:yyb:payment", target=f"yyb_task:{task.task_id}",
detail="生成付款码失败", success=False,
)
db.commit() db.commit()
raise raise
task.status = str(data.get("status", "ordering")) task.status = str(data.get("status", "ordering"))
task.phase = str(data.get("phase", "payment")) task.phase = str(data.get("phase", "payment"))
task.message = str(data.get("message", task.message)) task.message = str(data.get("message", task.message))
record_audit(
db, current, action="recharge:yyb:payment", target=f"yyb_task:{task.task_id}",
detail={"task_id": task.task_id, "product_id": task.product_id, "points": task.points,
"price_fen": task.price_fen, "status": task.status},
)
db.commit() db.commit()
return public_task(task, include_qr=False, creator_username=_creator_username(db, task)) return public_task(task, include_qr=False, creator_username=_creator_username(db, task))
@@ -177,6 +203,11 @@ def payment_check(task_id: int, db: Session = Depends(get_db), current: User = D
task.payment_last_checked_at = _utcnow() task.payment_last_checked_at = _utcnow()
if task.status == "success": if task.status == "success":
task.phase = "completed" task.phase = "completed"
record_audit(
db, current, action="recharge:yyb:payment_check", target=f"yyb_task:{task.task_id}",
detail={"task_id": task.task_id, "status": task.status, "message": task.message},
success=task.status != "failed",
)
db.commit() db.commit()
return public_task(task, include_qr=False, creator_username=_creator_username(db, task)) return public_task(task, include_qr=False, creator_username=_creator_username(db, task))
@@ -189,5 +220,9 @@ def stop(task_id: int, db: Session = Depends(get_db), current: User = Depends(re
task.phase = str(data.get("phase", "stopped")) task.phase = str(data.get("phase", "stopped"))
task.message = str(data.get("message", "任务已停止")) task.message = str(data.get("message", "任务已停止"))
task.finished_at = _utcnow() task.finished_at = _utcnow()
record_audit(
db, current, action="recharge:yyb:stop", target=f"yyb_task:{task.task_id}",
detail={"task_id": task.task_id, "status": task.status},
)
db.commit() db.commit()
return public_task(task, creator_username=_creator_username(db, task)) return public_task(task, creator_username=_creator_username(db, task))
+63
View File
@@ -0,0 +1,63 @@
"""操作审计记录服务,统一处理详情脱敏与序列化。"""
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any
from sqlalchemy.orm import Session
from ..models import AuditLog, User
_SENSITIVE_KEY_PARTS = (
"cookie", "token", "password", "passwd", "secret", "signature", "sign",
"qr_data", "authorization", "credential", "private_key",
)
def _safe_value(value: Any, key: str = "") -> Any:
"""递归过滤敏感字段,避免把凭据、二维码和签名写入审计表。"""
key_lower = key.lower()
if any(part in key_lower for part in _SENSITIVE_KEY_PARTS):
return "[已脱敏]"
if isinstance(value, Mapping):
return {str(k): _safe_value(v, str(k)) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_safe_value(item) for item in value]
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return str(value)
def record_audit(
db: Session,
current: User | None,
*,
action: str,
target: str = "",
detail: Mapping[str, Any] | str | None = None,
success: bool = True,
) -> AuditLog:
"""加入一条审计记录;调用方负责与业务变更一起提交事务。"""
if isinstance(detail, Mapping):
detail_text = json.dumps(_safe_value(detail), ensure_ascii=False, separators=(",", ":"))
elif detail is None:
detail_text = ""
else:
detail_text = str(_safe_value(detail))
if not success:
detail_text = json.dumps(
{"message": detail_text}, ensure_ascii=False, separators=(",", ":")
)
entry = AuditLog(
user_id=current.id if current else None,
username=current.username if current else "supplier",
action=action,
target=target,
detail=detail_text,
success=success,
)
db.add(entry)
return entry
+3
View File
@@ -25,6 +25,7 @@ const HuyaCookiePage = lazy(() => import('./pages/HuyaCookiePage'));
const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage')); const HuyaRegisterPage = lazy(() => import('./pages/HuyaRegisterPage'));
const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage')); const HuyaTasksPage = lazy(() => import('./pages/HuyaTasksPage'));
const YybRechargePage = lazy(() => import('./pages/YybRechargePage')); const YybRechargePage = lazy(() => import('./pages/YybRechargePage'));
const AuditLogsPage = lazy(() => import('./pages/AuditLogsPage'));
function RouteFallback() { function RouteFallback() {
return ( return (
@@ -43,6 +44,7 @@ function AppContent() {
const [authVersion, setAuthVersion] = useState(0); const [authVersion, setAuthVersion] = useState(0);
const refreshAuth = useCallback(() => setAuthVersion((v) => v + 1), []); const refreshAuth = useCallback(() => setAuthVersion((v) => v + 1), []);
const isLoggedIn = !!getUser(); const isLoggedIn = !!getUser();
const isSuperAdmin = getUser()?.role === 'super_admin';
const { isDark } = useTheme(); const { isDark } = useTheme();
// localStorage 仅是前端缓存;每次加载应用时以服务端的实时权限为准。 // localStorage 仅是前端缓存;每次加载应用时以服务端的实时权限为准。
@@ -93,6 +95,7 @@ function AppContent() {
<Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} /> <Route path="yyb/recharge" element={lazyRoute(<YybRechargePage />)} />
<Route path="proxy" element={lazyRoute(<ProxyPage />)} /> <Route path="proxy" element={lazyRoute(<ProxyPage />)} />
<Route path="users" element={lazyRoute(<UsersPage />)} /> <Route path="users" element={lazyRoute(<UsersPage />)} />
<Route path="audit-logs" element={isSuperAdmin ? lazyRoute(<AuditLogsPage />) : <Navigate to="/" replace />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
+7
View File
@@ -0,0 +1,7 @@
import api from './client';
import type { AuditLogEntry, AuditLogQuery, PaginatedResponse } from './types';
export const auditApi = {
list: (params: AuditLogQuery) =>
api.get<PaginatedResponse<AuditLogEntry>, PaginatedResponse<AuditLogEntry>>('/audit-logs', { params }),
};
+1
View File
@@ -2,6 +2,7 @@ export * from './types';
export { accountCheckApi } from './accountCheck'; export { accountCheckApi } from './accountCheck';
export { accountApi } from './accounts'; export { accountApi } from './accounts';
export { appApi } from './app'; export { appApi } from './app';
export { auditApi } from './audit';
export { authApi } from './auth'; export { authApi } from './auth';
export { cookieApi } from './cookies'; export { cookieApi } from './cookies';
export { dashboardApi } from './dashboard'; export { dashboardApi } from './dashboard';
+20
View File
@@ -44,6 +44,26 @@ export interface PaginatedResponse<T> {
page_size: number; page_size: number;
} }
export interface AuditLogEntry {
id: number;
user_id: number | null;
username: string;
action: string;
target: string;
detail: string;
success: boolean | null;
created_at: string;
}
export interface AuditLogQuery {
page: number;
page_size: number;
username?: string;
action?: string;
keyword?: string;
success?: boolean;
}
export interface BasicSummary { export interface BasicSummary {
total: number; total: number;
assigned_count: number; assigned_count: number;
+4 -1
View File
@@ -7,7 +7,7 @@ import {
MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined, MenuFoldOutlined, MenuUnfoldOutlined, SwapOutlined,
SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined, MobileOutlined, SunOutlined, MoonOutlined, DesktopOutlined, GiftOutlined, ShoppingCartOutlined, ApartmentOutlined, MobileOutlined,
BookOutlined, TrophyOutlined, ShopOutlined, BookOutlined, TrophyOutlined, ShopOutlined,
SafetyCertificateOutlined, SafetyCertificateOutlined, FileSearchOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate, useLocation, Outlet } from 'react-router-dom'; import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { getUser, clearAuth, type AuthUser } from '../store/auth'; import { getUser, clearAuth, type AuthUser } from '../store/auth';
@@ -115,6 +115,9 @@ export default function MainLayout({ onLogout }: { onLogout?: () => void }) {
if (can('user:view')) { if (can('user:view')) {
systemItems.push({ key: '/users', label: '用户管理', icon: <TeamOutlined /> }); systemItems.push({ key: '/users', label: '用户管理', icon: <TeamOutlined /> });
} }
if (user.role === 'super_admin') {
systemItems.push({ key: '/audit-logs', label: '审计日志', icon: <FileSearchOutlined /> });
}
if (systemItems.length > 0) { if (systemItems.length > 0) {
menuItems.push({ key: 'group-system', type: 'group', label: '系统', children: systemItems }); menuItems.push({ key: 'group-system', type: 'group', label: '系统', children: systemItems });
} }
+156
View File
@@ -0,0 +1,156 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, Card, Descriptions, Drawer, Input, Select, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ReloadOutlined } from '@ant-design/icons';
import { auditApi, type AuditLogEntry, type AuditLogQuery } from '../api/modules';
import { getErrorMessage } from '../utils/error';
import { message } from '../utils/antdMessage';
const { Text } = Typography;
const ACTION_OPTIONS = [
{ value: 'recharge:yyb:create', label: '应用宝创建任务' },
{ value: 'recharge:yyb:login', label: '应用宝发起登录' },
{ value: 'recharge:yyb:selection', label: '应用宝选择商品角色' },
{ value: 'recharge:yyb:payment', label: '应用宝生成付款码' },
{ value: 'recharge:yyb:payment_check', label: '应用宝检测到账' },
{ value: 'recharge:yyb:stop', label: '应用宝停止任务' },
{ value: 'recharge:douyu:create', label: '斗鱼创建直充批次' },
{ value: 'recharge:douyu:callback', label: '斗鱼供应商回调' },
{ value: 'recharge:douyu:stop', label: '斗鱼停止直充批次' },
{ value: 'recharge:douyu:config', label: '斗鱼直充配置' },
{ value: 'recharge:huya:create', label: '虎牙创建充值批次' },
{ value: 'recharge:huya:stop', label: '虎牙停止充值批次' },
{ value: 'recharge:huya:config', label: '虎牙充值配置' },
];
function formatTime(value: string): string {
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
function detailSummary(detail: string): string {
try {
const data = JSON.parse(detail) as Record<string, unknown>;
const rechargeAccounts = Array.isArray(data.recharge_accounts) ? data.recharge_accounts : [];
if (rechargeAccounts.length > 0) {
const names = rechargeAccounts.slice(0, 3).map((item) => {
const account = item as Record<string, unknown>;
return String(account.douyu_nickname || account.username || account.douyu_uid || '-');
});
return `充值账号: ${names.join('、')}${rechargeAccounts.length > 3 ? ' 等' : ''}`;
}
return Object.entries(data)
.filter(([key]) => key !== 'message')
.slice(0, 4)
.map(([key, value]) => `${key}: ${String(value)}`)
.join(' | ') || String(data.message || '');
} catch {
return detail;
}
}
function formatDetail(detail: string): string {
try {
return JSON.stringify(JSON.parse(detail), null, 2);
} catch {
return detail || '-';
}
}
export default function AuditLogsPage() {
const [rows, setRows] = useState<AuditLogEntry[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<AuditLogQuery>({ page: 1, page_size: 50 });
const [selected, setSelected] = useState<AuditLogEntry | null>(null);
const load = useCallback(async (nextQuery = query) => {
setLoading(true);
try {
const data = await auditApi.list(nextQuery);
setRows(data.items);
setTotal(data.total);
} catch (error: unknown) {
message.error(getErrorMessage(error));
} finally {
setLoading(false);
}
}, [query]);
useEffect(() => {
void load();
}, [load]);
const updateFilter = (changes: Partial<AuditLogQuery>) => {
const next = { ...query, ...changes, page: 1 };
setQuery(next);
void load(next);
};
const columns: ColumnsType<AuditLogEntry> = [
{ title: '时间', dataIndex: 'created_at', width: 180, render: formatTime },
{ title: '操作者', dataIndex: 'username', width: 120, render: (value) => value || '-' },
{ title: '操作', dataIndex: 'action', width: 210 },
{ title: '目标', dataIndex: 'target', width: 210, ellipsis: true },
{
title: '结果', dataIndex: 'success', width: 88,
render: (value: boolean | null) => (
value === null ? <Tag></Tag> : <Tag color={value ? 'success' : 'error'}>{value ? '成功' : '失败'}</Tag>
),
},
{
title: '摘要', dataIndex: 'detail', ellipsis: true,
render: (value: string) => <Text ellipsis style={{ maxWidth: 460 }}>{detailSummary(value)}</Text>,
},
];
return (
<Card title="审计日志" extra={<Button icon={<ReloadOutlined />} onClick={() => void load()} loading={loading}></Button>}>
<Space wrap style={{ marginBottom: 16 }}>
<Input
allowClear placeholder="操作者" style={{ width: 150 }}
onPressEnter={(event) => updateFilter({ username: event.currentTarget.value || undefined })}
onBlur={(event) => updateFilter({ username: event.currentTarget.value || undefined })}
/>
<Select
allowClear placeholder="操作类型" options={ACTION_OPTIONS} style={{ width: 210 }}
onChange={(value) => updateFilter({ action: value || undefined })}
/>
<Select
allowClear placeholder="结果" style={{ width: 120 }}
options={[{ value: 'true', label: '成功' }, { value: 'false', label: '失败' }]}
onChange={(value) => updateFilter({ success: value === undefined ? undefined : value === 'true' })}
/>
<Input.Search
allowClear placeholder="目标或摘要" style={{ width: 220 }}
onSearch={(value) => updateFilter({ keyword: value || undefined })}
/>
</Space>
<Table
rowKey="id" columns={columns} dataSource={rows} loading={loading} size="middle"
onRow={(record) => ({ onClick: () => setSelected(record), style: { cursor: 'pointer' } })}
pagination={{
current: query.page, pageSize: query.page_size, total, showSizeChanger: true,
showTotal: (count) => `${count}`,
onChange: (page, pageSize) => {
const next = { ...query, page, page_size: pageSize };
setQuery(next);
void load(next);
},
}}
/>
<Drawer title="审计详情" open={!!selected} onClose={() => setSelected(null)} width={560}>
{selected && (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="时间">{formatTime(selected.created_at)}</Descriptions.Item>
<Descriptions.Item label="操作者">{selected.username || '-'}</Descriptions.Item>
<Descriptions.Item label="操作">{selected.action}</Descriptions.Item>
<Descriptions.Item label="目标">{selected.target || '-'}</Descriptions.Item>
<Descriptions.Item label="结果">{selected.success === null ? '历史记录' : selected.success ? '成功' : '失败'}</Descriptions.Item>
<Descriptions.Item label="详情"><pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{formatDetail(selected.detail)}</pre></Descriptions.Item>
</Descriptions>
)}
</Drawer>
</Card>
);
}