From 1ac6a13a2c7a40e1960cc8ee6017698d0c2be721 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 12 Sep 2026 14:52:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(audit):=20=E4=BF=AE=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E6=97=A5=E5=BF=97=E6=97=B6=E5=8C=BA=E5=B7=AE=E5=B9=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BE=9B=E5=BA=94=E5=95=86=E5=8D=95=E5=8F=B7?= =?UTF-8?q?=E6=90=9C=E7=B4=A2=E4=B8=8E=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 解决审计日志 8 小时时区差:后端输出带时区 UTC ISO 字符串,前端 formatTime 增加 UTC 自动解析换算 - 审计日志支持单号检索:list_audit_logs 关联 DouyuTask 匹配供应商单号、外部单号、UID、昵称等 - 列表摘要与详情抽屉优化:摘要直观展示供应商单号与外部单号,抽屉增加专用供应商订单列表并支持一键复制 - 增加测试用例覆盖时间格式与单号反向检索 --- tests/test_audit_logs.py | 104 +++++++++++ web/backend/routers/audit.py | 225 +++++++++++++++++------ web/frontend/src/pages/AuditLogsPage.tsx | 170 +++++++++++++++-- web/frontend/src/utils/time.ts | 20 +- 4 files changed, 441 insertions(+), 78 deletions(-) diff --git a/tests/test_audit_logs.py b/tests/test_audit_logs.py index 4a180fd..8dc6ba6 100644 --- a/tests/test_audit_logs.py +++ b/tests/test_audit_logs.py @@ -137,3 +137,107 @@ class TestAuditLog: assert detail["recharge_accounts"][0]["douyu_uid"] == "10001" assert detail["recharge_channel"] == "wechat_qr" assert detail["payment_method"] == "微信扫码支付" + + def test_audit_log_created_at_has_utc_timezone_suffix(self): + record_audit( + self.session, + self.admin, + action="recharge:yyb:payment", + target="yyb_task:1", + detail={"test": 1}, + success=True, + ) + self.session.commit() + + result = list_audit_logs( + page=1, + page_size=10, + username=None, + action=None, + keyword=None, + success=None, + start_time=None, + end_time=None, + db=self.session, + _=self.admin, + ) + + created_at_str = result["items"][0]["created_at"] + assert created_at_str is not None + assert "+00:00" in created_at_str or created_at_str.endswith("Z") + + def test_search_audit_logs_by_supplier_order_id_and_out_order_id(self): + account = Account( + username="test-user", + password="password", + email="test@example.com", + email_password="pwd", + uid="473705204", + nickname="用户29160540", + ) + self.session.add(account) + self.session.commit() + + task = DouyuTask( + batch_id="01dee6f6a272", + account_id=account.id, + task_type="create_gold_qr", + status="success", + supplier_out_order_id="DYGF01DEE6F6A272T99458", + result={ + "recharge_channel": "supplier_api", + "out_order_id": "DYGF01DEE6F6A272T99458", + "buy_num": 280, + "supplier_order": { + "order_id": "200000124398920260912141221", + "order_status": 2, + }, + }, + created_by=self.admin.id, + ) + self.session.add(task) + self.session.add( + AuditLog( + user_id=self.admin.id, + username=self.admin.username, + action="recharge:douyu:create", + target="douyu_batch:01dee6f6a272", + detail=json.dumps({"batch_id": "01dee6f6a272"}), + success=True, + ) + ) + self.session.commit() + + # 1. 按供应商单号搜索 + result_by_order_id = list_audit_logs( + page=1, + page_size=10, + username=None, + action=None, + keyword="200000124398920260912141221", + success=None, + start_time=None, + end_time=None, + db=self.session, + _=self.admin, + ) + assert result_by_order_id["total"] == 1 + detail = json.loads(result_by_order_id["items"][0]["detail"]) + assert "200000124398920260912141221" in detail.get("supplier_order_ids", []) + assert "DYGF01DEE6F6A272T99458" in detail.get("supplier_out_order_ids", []) + assert detail.get("payment_method") == "API 直充支付" + + # 2. 按外部单号搜索 + result_by_out_id = list_audit_logs( + page=1, + page_size=10, + username=None, + action=None, + keyword="DYGF01DEE6F6A272T99458", + success=None, + start_time=None, + end_time=None, + db=self.session, + _=self.admin, + ) + assert result_by_out_id["total"] == 1 diff --git a/web/backend/routers/audit.py b/web/backend/routers/audit.py index 6fd8de3..c78f010 100644 --- a/web/backend/routers/audit.py +++ b/web/backend/routers/audit.py @@ -3,9 +3,10 @@ from __future__ import annotations import json -from datetime import datetime +from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import String, cast, or_ from sqlalchemy.orm import Session from ..database import get_db @@ -23,56 +24,135 @@ def require_super_admin(current: User = Depends(get_current_user)) -> User: 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", + try: + detail = json.loads(detail_text) if detail_text else {} + except json.JSONDecodeError: + detail = None + + if ( + isinstance(detail, dict) + and row.action == "recharge:douyu:create" + and row.target.startswith("douyu_batch:") + ): + batch_id = row.target.removeprefix("douyu_batch:") + tasks_and_accounts = ( + db.query(DouyuTask, Account) + .outerjoin(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 tasks_and_accounts: + task_info_by_id: dict[int, dict] = {} + supplier_orders: list[dict] = [] + supplier_order_ids: list[str] = [] + supplier_out_order_ids: list[str] = [] + has_supplier = False + + for task, account in tasks_and_accounts: + task_result = task.result if isinstance(task.result, dict) else {} + channel = str(task_result.get("recharge_channel") or "") + supplier_order = ( + task_result.get("supplier_order") + if isinstance(task_result.get("supplier_order"), dict) + else {} ) - .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 - if isinstance(detail, dict) and not detail.get("payment_method"): - batch_id = row.target.removeprefix("douyu_batch:") - task = ( - db.query(DouyuTask) - .filter( - DouyuTask.batch_id == batch_id, - DouyuTask.task_type == "create_gold_qr", + supplier_order_id = str( + supplier_order.get("order_id") + or supplier_order.get("orderId") + or task_result.get("order_id") + or "" + ).strip() + out_order_id = str( + task.supplier_out_order_id or task_result.get("out_order_id") or "" + ).strip() + + if channel == "supplier_api" or supplier_order_id or out_order_id: + has_supplier = True + + info = { + "task_id": task.id, + "username": (account.username if account else "") or "", + "douyu_uid": (account.uid if account else "") or "", + "douyu_nickname": (account.nickname if account else "") or "", + "status": task.status, + "supplier_out_order_id": out_order_id, + "supplier_order_id": supplier_order_id, + "amount": task_result.get("buy_num") + or task_result.get("pay_amount"), + } + task_info_by_id[task.id] = info + + if out_order_id or supplier_order_id: + supplier_orders.append( + { + "task_id": task.id, + "supplier_out_order_id": out_order_id, + "supplier_order_id": supplier_order_id, + "douyu_nickname": info["douyu_nickname"] + or info["username"], + "douyu_uid": info["douyu_uid"], + "status": task.status, + "amount": info["amount"], + } + ) + if ( + supplier_order_id + and supplier_order_id not in supplier_order_ids + ): + supplier_order_ids.append(supplier_order_id) + if out_order_id and out_order_id not in supplier_out_order_ids: + supplier_out_order_ids.append(out_order_id) + + if supplier_orders: + detail["supplier_orders"] = supplier_orders + detail["supplier_order_ids"] = supplier_order_ids + detail["supplier_out_order_ids"] = supplier_out_order_ids + + if not detail.get("recharge_accounts"): + detail["recharge_accounts"] = list(task_info_by_id.values()) + detail["recharge_accounts_truncated"] = len(tasks_and_accounts) == 100 + else: + for item in detail.get("recharge_accounts", []): + if isinstance(item, dict) and "task_id" in item: + tid = item.get("task_id") + if tid in task_info_by_id: + matched = task_info_by_id[tid] + if matched["supplier_out_order_id"]: + item["supplier_out_order_id"] = matched[ + "supplier_out_order_id" + ] + if matched["supplier_order_id"]: + item["supplier_order_id"] = matched["supplier_order_id"] + if matched["status"]: + item["status"] = matched["status"] + if matched["amount"] is not None: + item["amount"] = matched["amount"] + + if not detail.get("payment_method") or not detail.get("recharge_channel"): + detail["recharge_channel"] = ( + "supplier_api" if has_supplier else "wechat_qr" ) - .order_by(DouyuTask.id.asc()) - .first() - ) - task_result = task.result if task and isinstance(task.result, dict) else {} - recharge_channel = str(task_result.get("recharge_channel") or "wechat_qr") - detail["recharge_channel"] = recharge_channel - detail["payment_method"] = ( - "API 直充支付" if recharge_channel == "supplier_api" else "微信扫码支付" - ) - if isinstance(detail, dict): - detail_text = json.dumps(detail, ensure_ascii=False, separators=(",", ":")) + detail["payment_method"] = ( + "API 直充支付" if has_supplier else "微信扫码支付" + ) + + detail_text = json.dumps(detail, ensure_ascii=False, separators=(",", ":")) + + created_at = row.created_at + if created_at is not None: + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=UTC) + created_at_out = created_at.isoformat() + else: + created_at_out = None + return { "id": row.id, "user_id": row.user_id, @@ -81,7 +161,7 @@ def _audit_log_out(db: Session, row: AuditLog) -> dict: "target": row.target or "", "detail": detail_text, "success": row.success, - "created_at": row.created_at, + "created_at": created_at_out, } @@ -105,16 +185,53 @@ def list_audit_logs( 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)) + kw = keyword.strip() + pattern = f"%{kw}%" + conditions = [ + AuditLog.target.ilike(pattern), + AuditLog.detail.ilike(pattern), + AuditLog.username.ilike(pattern), + ] + # 关联查询 DouyuTask,支持按外部单号、供应商单号、账号昵称、UID 直接检索对应的批次审计记录 + matching_tasks = ( + db.query(DouyuTask.batch_id) + .outerjoin(Account, Account.id == DouyuTask.account_id) + .filter( + or_( + DouyuTask.supplier_out_order_id.ilike(pattern), + cast(DouyuTask.result, String).ilike(pattern), + Account.uid.ilike(pattern), + Account.nickname.ilike(pattern), + Account.username.ilike(pattern), + ) + ) + .distinct() + .limit(100) + .all() ) + batch_targets = [f"douyu_batch:{b[0]}" for b in matching_tasks if b[0]] + if batch_targets: + conditions.append(AuditLog.target.in_(batch_targets)) + + query = query.filter(or_(*conditions)) + if success is not None: query = query.filter(AuditLog.success == success) if start_time: - query = query.filter(AuditLog.created_at >= start_time) + st = ( + start_time.astimezone(UTC).replace(tzinfo=None) + if start_time.tzinfo + else start_time + ) + query = query.filter(AuditLog.created_at >= st) if end_time: - query = query.filter(AuditLog.created_at <= end_time) + et = ( + end_time.astimezone(UTC).replace(tzinfo=None) + if end_time.tzinfo + else end_time + ) + query = query.filter(AuditLog.created_at <= et) + total = query.count() rows = ( query.order_by(AuditLog.id.desc()) diff --git a/web/frontend/src/pages/AuditLogsPage.tsx b/web/frontend/src/pages/AuditLogsPage.tsx index f87e6bb..46b8932 100644 --- a/web/frontend/src/pages/AuditLogsPage.tsx +++ b/web/frontend/src/pages/AuditLogsPage.tsx @@ -5,6 +5,7 @@ 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'; +import { formatTime } from '../utils/time'; const { Text } = Typography; @@ -30,10 +31,6 @@ const ACTION_OPTIONS = [ const ACTION_LABELS = new Map(ACTION_OPTIONS.map((item) => [item.value, item.label])); -function formatTime(value: string): string { - return new Date(value).toLocaleString('zh-CN', { hour12: false }); -} - function actionLabel(action: string): string { return ACTION_LABELS.get(action) || action; } @@ -51,6 +48,29 @@ function detailSummary(detail: string): string { try { const data = JSON.parse(detail) as Record; const rechargeAccounts = Array.isArray(data.recharge_accounts) ? data.recharge_accounts : []; + const supplierOrders = Array.isArray(data.supplier_orders) ? data.supplier_orders : []; + const supplierOrderIds = Array.isArray(data.supplier_order_ids) ? data.supplier_order_ids : []; + const supplierOutOrderIds = Array.isArray(data.supplier_out_order_ids) ? data.supplier_out_order_ids : []; + + // 优先直观展示充值账号与供应商单号 + if (supplierOrderIds.length > 0 || supplierOutOrderIds.length > 0 || supplierOrders.length > 0) { + const names = rechargeAccounts.slice(0, 2).map((item) => { + const account = item as Record; + return String(account.douyu_nickname || account.username || account.douyu_uid || '-'); + }); + const accText = names.length > 0 + ? `充值账号: ${names.join('、')}${rechargeAccounts.length > 2 ? ` 等(${rechargeAccounts.length})` : ''}` + : ''; + const orderId = String(supplierOrderIds[0] || (supplierOrders[0] as Record)?.supplier_order_id || '').trim(); + const outId = String(supplierOutOrderIds[0] || (supplierOrders[0] as Record)?.supplier_out_order_id || '').trim(); + + const orderParts: string[] = []; + if (orderId) orderParts.push(`供应商单号: ${orderId}`); + if (outId && outId !== orderId) orderParts.push(`外部单号: ${outId}`); + + return [accText, orderParts.join(' | ')].filter(Boolean).join(' | '); + } + if (rechargeAccounts.length > 0) { const names = rechargeAccounts.slice(0, 3).map((item) => { const account = item as Record; @@ -58,6 +78,15 @@ function detailSummary(detail: string): string { }); return `充值账号: ${names.join('、')}${rechargeAccounts.length > 3 ? ' 等' : ''}`; } + + if (data.out_order_id || data.order_id) { + const parts: string[] = []; + if (data.order_id) parts.push(`供应商单号: ${String(data.order_id)}`); + if (data.out_order_id) parts.push(`外部单号: ${String(data.out_order_id)}`); + if (data.message) parts.push(String(data.message)); + return parts.join(' | '); + } + return Object.entries(data) .filter(([key]) => key !== 'message') .slice(0, 4) @@ -76,6 +105,15 @@ function formatDetail(detail: string): string { } } +function parseDetailData(detail: string): Record | null { + try { + const data = JSON.parse(detail); + return typeof data === 'object' && data !== null ? (data as Record) : null; + } catch { + return null; + } +} + export default function AuditLogsPage() { const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); @@ -131,21 +169,23 @@ export default function AuditLogsPage() { } onClick={() => void load()} loading={loading}>刷新}> updateFilter({ username: event.currentTarget.value || undefined })} onBlur={(event) => updateFilter({ username: event.currentTarget.value || undefined })} /> updateFilter({ success: value === undefined ? undefined : value === 'true' })} /> updateFilter({ keyword: value || undefined })} /> @@ -162,19 +202,109 @@ export default function AuditLogsPage() { }, }} /> - setSelected(null)} width={560}> - {selected && ( - - {formatTime(selected.created_at)} - {selected.username || '-'} - {actionLabel(selected.action)} - {selected.target || '-'} - {paymentMethod(selected.detail)} - {selected.success === null ? '历史记录' : selected.success ? '成功' : '失败'} -
{formatDetail(selected.detail)}
-
- )} + setSelected(null)} width={640}> + {selected && (() => { + const detailData = parseDetailData(selected.detail); + const supplierOrders = Array.isArray(detailData?.supplier_orders) + ? (detailData?.supplier_orders as Array>) + : []; + const supplierOrderIds = Array.isArray(detailData?.supplier_order_ids) + ? (detailData?.supplier_order_ids as string[]) + : []; + const supplierOutOrderIds = Array.isArray(detailData?.supplier_out_order_ids) + ? (detailData?.supplier_out_order_ids as string[]) + : []; + + return ( + + + {formatTime(selected.created_at)} + {selected.username || '-'} + {actionLabel(selected.action)} + {selected.target || '-'} + {paymentMethod(selected.detail)} + {selected.success === null ? '历史记录' : selected.success ? '成功' : '失败'} + {supplierOrderIds.length > 0 && ( + + {supplierOrderIds.map((id) => ( +
{id}
+ ))} +
+ )} + {supplierOutOrderIds.length > 0 && ( + + {supplierOutOrderIds.map((id) => ( +
{id}
+ ))} +
+ )} +
+ + {supplierOrders.length > 0 && ( + + > + size="small" + pagination={false} + rowKey={(r) => String(r.task_id || r.supplier_out_order_id || Math.random())} + dataSource={supplierOrders} + columns={[ + { + title: '充值账号', + key: 'account', + render: (_: unknown, r: Record) => ( +
+
{String(r.douyu_nickname || r.username || '-')}
+ {Boolean(r.douyu_uid) && UID: {String(r.douyu_uid)}} +
+ ), + }, + { + title: '数量', + dataIndex: 'amount', + key: 'amount', + width: 90, + render: (v: unknown) => (v ? `${String(v)} 鱼翅` : '-'), + }, + { + title: '供应商单号', + dataIndex: 'supplier_order_id', + key: 'supplier_order_id', + render: (v: unknown) => (v ? {String(v)} : '-'), + }, + { + title: '外部单号', + dataIndex: 'supplier_out_order_id', + key: 'supplier_out_order_id', + render: (v: unknown) => (v ? {String(v)} : '-'), + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 80, + render: (v: unknown) => { + const s = String(v || ''); + if (s === 'success') return 成功; + if (s === 'failed') return 失败; + if (s === 'running') return 处理中; + return {s || '-'}; + }, + }, + ]} + /> +
+ )} + + +
+                  {formatDetail(selected.detail)}
+                
+
+
+ ); + })()}
); } + diff --git a/web/frontend/src/utils/time.ts b/web/frontend/src/utils/time.ts index 8249884..03722d2 100644 --- a/web/frontend/src/utils/time.ts +++ b/web/frontend/src/utils/time.ts @@ -7,12 +7,22 @@ dayjs.extend(timezone); /** * 将后端返回的 ISO 时间字符串格式化为本地可读格式。 - * 后端统一输出带时区的 ISO 字符串(如 2026-06-22T15:51:26+00:00), - * 此函数自动转为本地时区并格式化为 "YYYY-MM-DD HH:mm:ss"。 + * 兼容带时区(如 2026-06-22T15:51:26+00:00 / 2026-06-22T15:51:26Z)与 + * 不带时区后缀的 UTC 时间字符串(如 2026-06-22T15:51:26 / 2026-06-22 15:51:26), + * 自动转为本地时区并格式化为 "YYYY-MM-DD HH:mm:ss"。 */ +function normalizeUtcDateStr(val: string): string { + let raw = val.trim(); + if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(raw)) { + raw = raw.replace(' ', 'T') + 'Z'; + } + return raw; +} + export function formatTime(val: string | null | undefined): string { if (!val) return '-'; - return dayjs(val).format('YYYY-MM-DD HH:mm:ss'); + const d = dayjs(normalizeUtcDateStr(val)); + return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : val; } /** @@ -20,5 +30,7 @@ export function formatTime(val: string | null | undefined): string { */ export function formatTimeShort(val: string | null | undefined): string { if (!val) return '-'; - return dayjs(val).format('MM-DD HH:mm'); + const d = dayjs(normalizeUtcDateStr(val)); + return d.isValid() ? d.format('MM-DD HH:mm') : val; } +