feat(ad-audit): 新增看广告金币审计页
- /ad-audit 复算对账页:公式快照 + reward_video/feed 发奖明细 + 实发与复算一致性校验 - 侧栏新增「金币审计」导航 - types 增加 AdCoinAuditRow / AdCoinFormula / AdCoinAudit Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,227 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DatePicker,
|
||||||
|
InputNumber,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
|
import { api, errMsg } from '@/lib/api';
|
||||||
|
import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types';
|
||||||
|
|
||||||
|
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
||||||
|
|
||||||
|
const SCENE_TAG: Record<string, { color: string; label: string }> = {
|
||||||
|
reward_video: { color: 'blue', label: '看视频' },
|
||||||
|
feed: { color: 'purple', label: '信息流' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_TAG: Record<string, string> = {
|
||||||
|
granted: 'green',
|
||||||
|
capped: 'orange',
|
||||||
|
ecpm_missing: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtFactorRange = (a: number | null, b: number | null) => {
|
||||||
|
if (a == null) return '-';
|
||||||
|
return a === b || b == null ? String(a) : `${a}→${b}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtIndexRange = (a: number | null, b: number | null) => {
|
||||||
|
if (a == null) return '-';
|
||||||
|
return a === b ? String(a) : `${a}–${b}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdAuditPage() {
|
||||||
|
const [date, setDate] = useState<Dayjs>(dayjs());
|
||||||
|
const [userId, setUserId] = useState<number | null>(null);
|
||||||
|
const [scene, setScene] = useState<string | undefined>();
|
||||||
|
const [limit, setLimit] = useState<number>(100);
|
||||||
|
const [data, setData] = useState<AdCoinAudit | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get<AdCoinAudit>('/admin/api/ad-coin-audit', {
|
||||||
|
params: {
|
||||||
|
date: date.format('YYYY-MM-DD'),
|
||||||
|
user_id: userId ?? undefined,
|
||||||
|
scene: scene ?? undefined,
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setData(res.data);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(errMsg(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [date, userId, scene, limit]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
// 仅首次自动拉今天;之后由「查询」按钮触发
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columns: ColumnsType<AdCoinAuditRow> = [
|
||||||
|
{
|
||||||
|
title: '场景',
|
||||||
|
dataIndex: 'scene',
|
||||||
|
width: 90,
|
||||||
|
render: (s: string) => {
|
||||||
|
const t = SCENE_TAG[s] ?? { color: 'default', label: s };
|
||||||
|
return <Tag color={t.color}>{t.label}</Tag>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '时间', dataIndex: 'created_at', render: dt, width: 175 },
|
||||||
|
{ title: '用户', dataIndex: 'user_id', width: 70 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 110,
|
||||||
|
render: (s: string) => <Tag color={STATUS_TAG[s] ?? 'default'}>{s}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: 'eCPM', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' },
|
||||||
|
{
|
||||||
|
title: '因子1',
|
||||||
|
dataIndex: 'ecpm_factor',
|
||||||
|
width: 70,
|
||||||
|
render: (v: number | null) => v ?? '-',
|
||||||
|
},
|
||||||
|
{ title: '份数', dataIndex: 'units', width: 60 },
|
||||||
|
{
|
||||||
|
title: 'LT累计条数',
|
||||||
|
key: 'lt_index',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, r: AdCoinAuditRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '因子2',
|
||||||
|
key: 'lt_factor',
|
||||||
|
width: 90,
|
||||||
|
render: (_: unknown, r: AdCoinAuditRow) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '应发金币',
|
||||||
|
dataIndex: 'expected_coin',
|
||||||
|
width: 100,
|
||||||
|
render: (v: number) => <b>{v}</b>,
|
||||||
|
},
|
||||||
|
{ title: '实发金币', dataIndex: 'actual_coin', width: 100 },
|
||||||
|
{
|
||||||
|
title: '一致',
|
||||||
|
dataIndex: 'matched',
|
||||||
|
width: 70,
|
||||||
|
render: (m: boolean) =>
|
||||||
|
m ? <Tag color="green">✓</Tag> : <Tag color="red">✗ 不符</Tag>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>看广告金币审计</h2>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||||
|
把「看视频赚金币」和「比价信息流广告」的发奖记录,用与正式发奖相同的公式复算一遍,核对实发金币是否按公式计算。纯只读对账。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Text strong>金币公式:</Typography.Text>{' '}
|
||||||
|
<Typography.Text code>{data.formula.description}</Typography.Text>
|
||||||
|
<div style={{ marginTop: 8, color: '#666', fontSize: 13 }}>
|
||||||
|
<div>
|
||||||
|
eCPM 口径:{data.formula.ecpm_unit},计算时 ÷100 转元;金币:元 ={' '}
|
||||||
|
{data.formula.coin_per_yuan}:1;信息流每 {data.formula.feed_unit_seconds} 秒折 1 条。两个点位「LT累计条数」各自独立计数。
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
因子1(按 eCPM 元判档,阈值单位:元):
|
||||||
|
{data.formula.ecpm_factor_tiers
|
||||||
|
.map(([f, lo, hi]) => `${lo}~${hi ?? '∞'}元→${f}`)
|
||||||
|
.join(' | ')}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
因子2(LT 累计条数):
|
||||||
|
{data.formula.lt_factor_tiers
|
||||||
|
.map(([f, lo, hi]) => `${lo}${hi != null && hi !== lo ? `~${hi}` : ''}→${f}`)
|
||||||
|
.join(' | ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<DatePicker value={date} onChange={(d) => d && setDate(d)} allowClear={false} />
|
||||||
|
<InputNumber
|
||||||
|
placeholder="用户 ID(可空)"
|
||||||
|
value={userId ?? undefined}
|
||||||
|
onChange={(v) => setUserId(v ?? null)}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="场景(全部)"
|
||||||
|
value={scene}
|
||||||
|
onChange={setScene}
|
||||||
|
allowClear
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'reward_video', label: '看视频' },
|
||||||
|
{ value: 'feed', label: '信息流' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={limit}
|
||||||
|
onChange={setLimit}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={[50, 100, 200, 500].map((n) => ({ value: n, label: `${n} 条` }))}
|
||||||
|
/>
|
||||||
|
<Button type="primary" onClick={load} loading={loading}>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{data && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
{data.mismatch_count > 0 ? (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={`共 ${data.total} 条,其中 ${data.mismatch_count} 条复算与实发不一致(✗)——公式可能未生效或发奖有问题`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Alert
|
||||||
|
type="success"
|
||||||
|
message={`共 ${data.total} 条,全部按公式发放(无不一致)`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey={(r) => `${r.scene}-${r.record_id}`}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={loading}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
rowClassName={(r) => (r.matched ? '' : 'row-mismatch')}
|
||||||
|
/>
|
||||||
|
<style jsx global>{`
|
||||||
|
.row-mismatch > td {
|
||||||
|
background: #fff1f0 !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation';
|
|||||||
import {
|
import {
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
FileSearchOutlined,
|
FileSearchOutlined,
|
||||||
|
FundProjectionScreenOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
MoneyCollectOutlined,
|
MoneyCollectOutlined,
|
||||||
@@ -23,6 +24,7 @@ const MENU = [
|
|||||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||||
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
||||||
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
|
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
|
||||||
|
{ key: '/ad-audit', icon: <FundProjectionScreenOutlined />, label: '金币审计' },
|
||||||
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
|
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
|
||||||
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
|
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
|
||||||
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
|
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
|
||||||
|
|||||||
@@ -178,6 +178,41 @@ export interface AuditLog {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdCoinAuditRow {
|
||||||
|
scene: string; // reward_video / feed
|
||||||
|
record_id: number;
|
||||||
|
user_id: number;
|
||||||
|
created_at: string;
|
||||||
|
status: string; // granted / capped / ecpm_missing
|
||||||
|
ecpm: string | null;
|
||||||
|
ecpm_factor: number | null;
|
||||||
|
units: number;
|
||||||
|
lt_index_start: number | null;
|
||||||
|
lt_index_end: number | null;
|
||||||
|
lt_factor_start: number | null;
|
||||||
|
lt_factor_end: number | null;
|
||||||
|
expected_coin: number;
|
||||||
|
actual_coin: number;
|
||||||
|
matched: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdCoinFormula {
|
||||||
|
description: string;
|
||||||
|
coin_per_yuan: number;
|
||||||
|
ecpm_unit: string;
|
||||||
|
feed_unit_seconds: number;
|
||||||
|
ecpm_factor_tiers: [number, number, number | null][];
|
||||||
|
lt_factor_tiers: [number, number, number | null][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdCoinAudit {
|
||||||
|
date: string;
|
||||||
|
formula: AdCoinFormula;
|
||||||
|
total: number;
|
||||||
|
mismatch_count: number;
|
||||||
|
items: AdCoinAuditRow[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardOverview {
|
export interface DashboardOverview {
|
||||||
users: {
|
users: {
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user