fix(audit): 修复审计日志时区差并支持供应商单号搜索与展示

- 解决审计日志 8 小时时区差:后端输出带时区 UTC ISO 字符串,前端 formatTime 增加 UTC 自动解析换算
- 审计日志支持单号检索:list_audit_logs 关联 DouyuTask 匹配供应商单号、外部单号、UID、昵称等
- 列表摘要与详情抽屉优化:摘要直观展示供应商单号与外部单号,抽屉增加专用供应商订单列表并支持一键复制
- 增加测试用例覆盖时间格式与单号反向检索
This commit is contained in:
yml2213
2026-09-12 14:52:26 +08:00
parent 4518b726ac
commit 1ac6a13a2c
4 changed files with 441 additions and 78 deletions
+104
View File
@@ -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
+171 -54
View File
@@ -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())
+150 -20
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>)?.supplier_order_id || '').trim();
const outId = String(supplierOutOrderIds[0] || (supplierOrders[0] as Record<string, unknown>)?.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<string, unknown>;
@@ -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<string, unknown> | null {
try {
const data = JSON.parse(detail);
return typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : null;
} catch {
return null;
}
}
export default function AuditLogsPage() {
const [rows, setRows] = useState<AuditLogEntry[]>([]);
const [total, setTotal] = useState(0);
@@ -131,21 +169,23 @@ export default function AuditLogsPage() {
<Card title="审计日志" extra={<Button icon={<ReloadOutlined />} onClick={() => void load()} loading={loading}>刷新</Button>}>
<Space wrap style={{ marginBottom: 16 }}>
<Input
allowClear placeholder="操作者" style={{ width: 150 }}
allowClear placeholder="操作者" style={{ width: 140 }}
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 }}
allowClear placeholder="操作类型" options={ACTION_OPTIONS} style={{ width: 190 }}
onChange={(value) => updateFilter({ action: value || undefined })}
/>
<Select
allowClear placeholder="结果" style={{ width: 120 }}
allowClear placeholder="结果" style={{ width: 110 }}
options={[{ value: 'true', label: '成功' }, { value: 'false', label: '失败' }]}
onChange={(value) => updateFilter({ success: value === undefined ? undefined : value === 'true' })}
/>
<Input.Search
allowClear placeholder="目标或摘要" style={{ width: 220 }}
allowClear
placeholder="供应商单号 / 外部单号 / 账号 / 目标 / 摘要"
style={{ width: 320 }}
onSearch={(value) => updateFilter({ keyword: value || undefined })}
/>
</Space>
@@ -162,19 +202,109 @@ export default function AuditLogsPage() {
},
}}
/>
<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="操作">{actionLabel(selected.action)}</Descriptions.Item>
<Descriptions.Item label="目标">{selected.target || '-'}</Descriptions.Item>
<Descriptions.Item label="充值方式">{paymentMethod(selected.detail)}</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 title="审计详情" open={!!selected} onClose={() => setSelected(null)} width={640}>
{selected && (() => {
const detailData = parseDetailData(selected.detail);
const supplierOrders = Array.isArray(detailData?.supplier_orders)
? (detailData?.supplier_orders as Array<Record<string, unknown>>)
: [];
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 (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<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="操作">{actionLabel(selected.action)}</Descriptions.Item>
<Descriptions.Item label="目标">{selected.target || '-'}</Descriptions.Item>
<Descriptions.Item label="充值方式">{paymentMethod(selected.detail)}</Descriptions.Item>
<Descriptions.Item label="结果">{selected.success === null ? '历史记录' : selected.success ? '成功' : '失败'}</Descriptions.Item>
{supplierOrderIds.length > 0 && (
<Descriptions.Item label="供应商单号">
{supplierOrderIds.map((id) => (
<div key={id}><Text copyable strong>{id}</Text></div>
))}
</Descriptions.Item>
)}
{supplierOutOrderIds.length > 0 && (
<Descriptions.Item label="外部单号">
{supplierOutOrderIds.map((id) => (
<div key={id}><Text copyable type="secondary">{id}</Text></div>
))}
</Descriptions.Item>
)}
</Descriptions>
{supplierOrders.length > 0 && (
<Card size="small" title="供应商直充订单列表">
<Table<Record<string, unknown>>
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, unknown>) => (
<div>
<div>{String(r.douyu_nickname || r.username || '-')}</div>
{Boolean(r.douyu_uid) && <Text type="secondary" style={{ fontSize: 12 }}>UID: {String(r.douyu_uid)}</Text>}
</div>
),
},
{
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 ? <Text copyable strong>{String(v)}</Text> : '-'),
},
{
title: '外部单号',
dataIndex: 'supplier_out_order_id',
key: 'supplier_out_order_id',
render: (v: unknown) => (v ? <Text copyable style={{ fontSize: 12 }}>{String(v)}</Text> : '-'),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
render: (v: unknown) => {
const s = String(v || '');
if (s === 'success') return <Tag color="success">成功</Tag>;
if (s === 'failed') return <Tag color="error">失败</Tag>;
if (s === 'running') return <Tag color="processing">处理中</Tag>;
return <Tag>{s || '-'}</Tag>;
},
},
]}
/>
</Card>
)}
<Card size="small" title="完整详情 (JSON)">
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 260, overflowY: 'auto' }}>
{formatDetail(selected.detail)}
</pre>
</Card>
</Space>
);
})()}
</Drawer>
</Card>
);
}
+16 -4
View File
@@ -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;
}