Compare commits

...

2 Commits

Author SHA1 Message Date
marco c699ad0b8e docs: README 更新功能模块与部署说明
- 功能从 P0 概述扩写为 12 个模块详述(数据大盘/用户/设备/提现/上报审核/比价记录/反馈/广告/CPS分发/系统配置/管理员/审计)
- 补充按角色(finance/operator/super)操作权限说明
- 部署注意:NEXT_PUBLIC_MEDIA_BASE 必须指向 App 后端域名,否则 /media 图片 404

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 04:08:08 +08:00
zhuzihao c488513d24 feat(ad-revenue): 报表主表改逐条广告事件 + 用户列显示手机号 (#17)
对齐后端 /admin/api/ad-revenue-report 的逐条事件结构(每次广告一行,不再按用户折叠):
- 主表列:时间/用户/类型/应用/广告位/eCPM/收益/发奖状态/应发/实发/一致/底层ADN;
  点行展开看该条金币复算因子(因子1/因子2/份数/LT)
- 「用户」列从 user_id 改为显示完整手机号(辅小字 #uid);查不到手机号显示 #uid(无手机号)
- 纯展示行(信息流逐条展示 / 无对应发奖):金币列显「-」、状态显「仅展示」、不计对账
- 统计卡「聚合组数」→「广告事件数」,提示文案 组→条
- rowKey 用后端下发的 event_key

tsc --noEmit 本次两文件零类型错误。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #17
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-06-24 03:54:13 +08:00
3 changed files with 138 additions and 108 deletions
+25 -3
View File
@@ -4,10 +4,31 @@ Next.js 15 + Ant Design 5,对接 `shaguabijia-app-server` 的 admin API(`app/adm
后端是独立 FastAPI app(端口 8771,复用 app-server 的 models/repositories + 同一 PostgreSQL,
鉴权用独立 JWT)。本仓只是前端。
## 功能(P0)
## 功能模块
数据大盘 / 用户管理(列表+360详情+封禁+调金币) / 提现管理(列表+重试+对账) /
反馈工单 / 管理员账号(super) / 审计日志。按角色显示操作:finance=钱、operator=用户+反馈、super=全部。
侧边栏 `MENU`(`src/app/(main)/layout.tsx`)共 12 个模块,均已上线:
- **数据大盘**(`/dashboard`)— 核心经营指标概览。
- **用户管理**(`/users`)— 列表(多条件筛选+服务端排序)+ 360 详情;封禁/解封、调金币、调现金;运营开关:开启新手引导(标记用户重看一次,看完自动恢复)、开/关调试链接(trace 排障)、以及上述操作的批量勾选执行。
- **设备管理**(`/devices`)— 按硬件 ANDROID_ID 聚合走过新手引导的设备;可单设备或全量「重设新手引导」。
- **提现管理**(`/withdraws`)— 提现单审核(通过即发起微信打款 / 拒绝退回余额)、查单刷新、批量通过/拒绝/刷新、批量对账,含微信配置健康检查 + 现金账本校验。
- **上报审核**(`/price-reports`)— 用户上报「某平台更低价」+ 截图,人工核实;通过发放 1000 金币、拒绝需填理由(用户端轮询看到结果)。
- **比价记录**(`/comparison-records`)— 比价过程明细(逐平台结局/卡在哪一步、LLM 调用明细、trace);支持按页面输入的 LLM 单价做 token / 成本估算(纯前端,见 `calcCost` / `pricePerMTok`)。
- **反馈工单**(`/feedbacks`)— 用户反馈审核闭环:采纳发金币 / 拒绝填用户可见原因(`FeedbackHandleDrawer`),并含反馈二维码配置。
- **广告管理**(`/ad-revenue`)— 穿山甲广告配置(应用ID/各场景广告位ID/验签密钥/开关)+ 收益报表(展示/预估收益 与 应发/实发金币逐组对账)。
- **CPS 分发**(`/cps`)— 优惠券分发与对账(美团/淘宝/京东);单页 Tabs:对账统计 / 群管理 / 活动管理 / 订单明细。含群对账详情子页(`/cps/groups/[id]`):点击/复制趋势折线 + 按天明细 + 群内微信用户画像。
- **系统配置**(`/config`)— 业务运行参数(金额/上限等)在线读写,改完即时生效、每次进审计。
- **管理员**(`/admins`)— 管理员账号管理(仅 `super_admin` 可见)。
- **审计日志**(`/audit-logs`)— 所有写操作的审计流水。
### 按角色显示操作
权限以 `src/lib/auth.ts``canDo()` 为准(`super_admin` 恒可全部),各 `page.tsx``canDo([...])` 控制按钮:
- **finance**(钱):提现审核、用户调金币/调现金、CPS 对账(刷新拉美团订单)。
- **operator**(用户+内容):用户封禁/调试链接/新手引导、反馈审核、上报审核、设备重设引导、CPS 群/活动管理。
- **super**(super_admin):以上全部 + 管理员账号管理。
- 数据大盘 / 比价记录 / 广告管理 / 系统配置 / 审计日志:无 `canDo` 门,登录即可查看。
## 本地开发
@@ -52,5 +73,6 @@ ssh server "nginx -t && systemctl reload nginx"
- app-server 的 `.env``ADMIN_JWT_SECRET` 改成 ≥32 字节强随机串(默认 `change-me-admin` 可伪造 token)
- nginx 打开 IP 白名单(运营出口 IP)+ HTTPS 证书就位
- `.env.production``NEXT_PUBLIC_API_BASE` 留空(同域,nginx 反代 /admin/api)
- `.env.production``NEXT_PUBLIC_MEDIA_BASE` 必须指向 **App 后端** `https://app-api.shaguabijia.com`(/media 的真正的家),否则后台所有 /media 图片(上报/反馈截图、二维码、头像)被拼到 admin 域而 404。`NEXT_PUBLIC_*` 是编译期常量,改完必须重新 `npm run build` 才生效(详见 `src/lib/media.ts`)
- 首个 super_admin 已建、能登录
+92 -93
View File
@@ -35,7 +35,6 @@ import { api, errMsg } from '@/lib/api';
import { formatUtcTime } from '@/lib/format';
import type {
AdRevenueDaily,
AdRevenueImpression,
AdRevenueRecord,
AdRevenueReport,
AdRevenueRow,
@@ -94,25 +93,7 @@ const LT_FACTOR_ROWS = [
{ key: '5', lt: '第 11 条及以后', factor: 1.0 },
];
// 展开行 - 展示明细:每条广告展示一行(时间/eCPM/收益/adn/底层位)
const IMPRESSION_COLUMNS: ColumnsType<AdRevenueImpression> = [
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
{ title: 'eCPM(分)', dataIndex: 'ecpm', width: 100 },
{ title: '预估收益(元)', dataIndex: 'revenue_yuan', width: 120, render: (v: number) => v.toFixed(4) },
{
title: 'adn',
dataIndex: 'adn',
width: 90,
render: (v: string | null) => v ?? '-',
},
{
title: '底层代码位ID',
dataIndex: 'slot_id',
render: (v: string | null) => v ?? '-',
},
];
// 展开行 - 发奖明细:该组的逐条发奖复算明细(还原原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
// 展开行(逐条事件展开)- 该条的发奖复算明细(还原金币审计的 eCPM/因子1/份数/LT/因子2 等列)
const DETAIL_COLUMNS: ColumnsType<AdRevenueRecord> = [
{ title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 },
{
@@ -413,24 +394,29 @@ export default function AdRevenuePage() {
const columns: ColumnsType<AdRevenueRow> = [
...(queriedMultiDay
? [{ title: '日期', dataIndex: 'report_date', width: 110, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
: []),
{ title: '用户', dataIndex: 'user_id', width: 80 },
...(queriedGranularity === 'hour'
? [
{
title: '小时',
dataIndex: 'hour',
width: 90,
render: (h: number | null) =>
h == null ? '-' : `${String(h).padStart(2, '0')}:00`,
} as ColumnsType<AdRevenueRow>[number],
]
? [{ title: '日期', dataIndex: 'report_date', width: 105, fixed: 'left' } as ColumnsType<AdRevenueRow>[number]]
: []),
{ title: '时间', dataIndex: 'created_at', width: 165, render: (v: string) => formatUtcTime(v) },
{
title: '用户',
dataIndex: 'user_phone',
width: 150,
render: (phone: string | null, r: AdRevenueRow) =>
phone ? (
<span>
{phone}
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 6 }}>
#{r.user_id}
</Typography.Text>
</span>
) : (
<Typography.Text type="secondary">#{r.user_id}</Typography.Text>
),
},
{
title: '广告类型',
dataIndex: 'ad_type',
width: 120,
width: 110,
render: (s: string) => {
const t = TYPE_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
@@ -439,7 +425,7 @@ export default function AdRevenuePage() {
{
title: '来源应用',
dataIndex: 'app_env',
width: 150,
width: 140,
render: (v: string | null) => {
if (!v) return <Typography.Text type="secondary"></Typography.Text>;
const t = APP_TAG[v] ?? { color: 'default', label: v };
@@ -449,38 +435,68 @@ export default function AdRevenuePage() {
{
title: '广告位ID',
dataIndex: 'our_code_id',
width: 120,
width: 110,
render: (v: string | null) =>
v ? <Typography.Text code>{v}</Typography.Text> : <Typography.Text type="secondary">-</Typography.Text>,
},
{ title: '展示条数', dataIndex: 'impressions', width: 90, align: 'right' },
{
title: 'eCPM(分)',
dataIndex: 'ecpm',
width: 100,
align: 'right',
render: (v: string | null) => v ?? '-',
},
{
title: '预估收益(元)',
dataIndex: 'revenue_yuan',
width: 120,
width: 110,
align: 'right',
render: (v: number) => v.toFixed(4),
render: (v: number, r: AdRevenueRow) => (r.has_impression ? v.toFixed(4) : '-'),
},
{
title: '发奖状态',
dataIndex: 'status',
width: 100,
render: (s: string | null) => {
if (!s) return <Tag></Tag>;
const t = STATUS_TAG[s] ?? { color: 'default', label: s };
return <Tag color={t.color}>{t.label}</Tag>;
},
},
{
title: '应发金币',
dataIndex: 'expected_coin',
width: 100,
width: 90,
align: 'right',
render: (v: number) => <b>{v}</b>,
render: (v: number, r: AdRevenueRow) =>
r.has_reward ? <b>{v}</b> : <Typography.Text type="secondary">-</Typography.Text>,
},
{
title: '实发金币',
dataIndex: 'actual_coin',
width: 90,
align: 'right',
render: (v: number, r: AdRevenueRow) =>
r.has_reward ? v : <Typography.Text type="secondary">-</Typography.Text>,
},
{ title: '实发金币', dataIndex: 'actual_coin', width: 100, align: 'right' },
{
title: '一致',
dataIndex: 'matched',
width: 80,
width: 70,
align: 'center',
render: (m: boolean) => (m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>),
render: (m: boolean, r: AdRevenueRow) =>
r.has_reward ? (
m ? <Tag color="green"></Tag> : <Tag color="red"> </Tag>
) : (
<Typography.Text type="secondary">-</Typography.Text>
),
},
{
title: '底层 ADN',
dataIndex: 'adns',
render: (v: string[]) =>
v.length ? v.map((a) => <Tag key={a}>{a}</Tag>) : <Typography.Text type="secondary">-</Typography.Text>,
dataIndex: 'adn',
width: 100,
render: (v: string | null) =>
v ? <Tag>{v}</Tag> : <Typography.Text type="secondary">-</Typography.Text>,
},
];
@@ -591,7 +607,7 @@ export default function AdRevenuePage() {
value={limit}
onChange={setLimit}
style={{ width: 110 }}
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} ` }))}
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} ` }))}
/>
</Space>
<Button type="primary" onClick={load} loading={loading}>
@@ -618,7 +634,7 @@ export default function AdRevenuePage() {
<Statistic title="预估收益合计(元)" value={data.total_revenue_yuan} precision={4} />
</Col>
<Col span={6}>
<Statistic title="聚合组数" value={data.total} />
<Statistic title="广告事件数" value={data.total} />
</Col>
</Row>
<Divider orientation="left" plain style={{ marginTop: 20, marginBottom: 16 }}>
@@ -659,18 +675,18 @@ export default function AdRevenuePage() {
<Alert
type="error"
showIcon
message={`${data.total} ,其中 ${data.mismatch_count} 应发≠实发(✗);应发−实发 合计 ${
message={`${data.total} 条广告事件,其中 ${data.mismatch_count} 应发≠实发(✗);应发−实发 合计 ${
derived && derived.coinGap >= 0 ? '+' : ''
}${derived?.coinGap ?? 0} 金币(正=少发/负=多发)——公式可能未生效或发奖有问题。定位到具体记录可用后端逐条审计接口。`}
/>
) : (
<Alert type="success" showIcon message={`${data.total} ,应发与实发全部一致。`} />
<Alert type="success" showIcon message={`${data.total} 条广告事件,应发与实发全部一致。`} />
)}
{data.truncated && (
<Alert
type="warning"
showIcon
message={`聚合明细已截断到 ${queriedLimit} ,还有更多未显示——请按用户/类型/日期缩小范围,或调大「数」。合计数字不受影响。`}
message={`明细已截断到 ${queriedLimit} ,还有更多未显示——请按用户/类型/日期缩小范围,或调大「数」。合计数字不受影响。`}
/>
)}
</Space>
@@ -732,53 +748,36 @@ export default function AdRevenuePage() {
)}
<Table
rowKey={(r) => `${r.report_date}-${r.user_id}-${r.ad_type}-${r.app_env ?? ''}-${r.our_code_id ?? ''}-${r.hour ?? ''}`}
rowKey="event_key"
columns={columns}
dataSource={data?.items ?? []}
loading={loading}
pagination={false}
size="small"
scroll={{ x: 1100 }}
rowClassName={(r) => (r.matched ? '' : 'row-mismatch')}
scroll={{ x: 1200 }}
rowClassName={(r) => (r.has_reward && !r.matched ? 'row-mismatch' : '')}
expandable={{
rowExpandable: (r) =>
(r.impression_records?.length ?? 0) > 0 || (r.records?.length ?? 0) > 0,
expandedRowRender: (r) => {
const imps = r.impression_records ?? [];
const recs = r.records ?? [];
return (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div>
<Typography.Text strong></Typography.Text>{' '}
<Typography.Text type="secondary">({imps.length} )</Typography.Text>
<Table<AdRevenueImpression>
style={{ marginTop: 8 }}
rowKey="id"
columns={IMPRESSION_COLUMNS}
dataSource={imps}
pagination={false}
size="small"
scroll={{ x: 700 }}
/>
</div>
<div>
<Typography.Text strong></Typography.Text>{' '}
<Typography.Text type="secondary">({recs.length} )</Typography.Text>
<Table<AdRevenueRecord>
style={{ marginTop: 8 }}
rowKey="record_id"
columns={DETAIL_COLUMNS}
dataSource={recs}
pagination={false}
size="small"
scroll={{ x: 900 }}
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
locale={{ emptyText: '该组无发奖记录(只有展示,未发奖)' }}
/>
</div>
</Space>
);
},
// 只有发了奖的事件可展开,看「这条广告金币怎么算的」;纯展示行无复算、不可展开。
rowExpandable: (r) => r.reward_detail != null,
expandedRowRender: (r) =>
r.reward_detail ? (
<div>
<Typography.Text strong></Typography.Text>{' '}
<Typography.Text type="secondary">
(广 {r.reward_detail.expected_coin} / {r.reward_detail.actual_coin})
</Typography.Text>
<Table<AdRevenueRecord>
style={{ marginTop: 8 }}
rowKey="record_id"
columns={DETAIL_COLUMNS}
dataSource={[r.reward_detail]}
pagination={false}
size="small"
scroll={{ x: 900 }}
rowClassName={(d) => (d.matched ? '' : 'row-mismatch')}
/>
</div>
) : null,
}}
/>
<Modal
+21 -12
View File
@@ -341,35 +341,44 @@ export interface AdRevenueDaily {
actual_coin: number;
}
// 广告收益报表:按 日期 × 用户 × 广告类型 × 应用 × 代码位 聚合
// 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。
export interface AdRevenueRow {
report_date: string; // 该组所属日期 北京时间 YYYY-MM-DD
event_key: string; // 事件稳定唯一键(前端 rowKey)
report_date: string; // 该事件所属日期 北京时间 YYYY-MM-DD
user_id: number;
user_phone: string | null; // 用户手机号(admin 展示;查不到为空)
ad_type: string; // reward_video / feed / draw
app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空
our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空
hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null)
impressions: number; // 展示条数(轮播每条各计一次)
revenue_yuan: number; // 收益(元),预估口径
expected_coin: number; // 应发金币(公式复算,与金币审计同源)
actual_coin: number; // 实发金币(实际入账)
matched: boolean; // 该组应发==实发
adns: string[]; // 底层填充 ADN 子渠道集合
impression_records: AdRevenueImpression[]; // 该组逐条展示明细(展开下钻)
records: AdRevenueRecord[]; // 该组逐条发奖复算明细(展开下钻)
created_at: string; // 事件时间(有展示=展示时间,纯发奖=发奖时间)
// ── 展示侧 ──
has_impression: boolean; // 是否有广告展示(信息流逐条展示=true,纯发奖行=false)
impressions: number; // 本行展示条数 1/0(供日汇总、趋势图复用)
ecpm: string | null; // 分/千次;展示行取展示值,纯发奖行取发奖采用值
revenue_yuan: number; // 本次展示预估收益(元);纯发奖行=0
adn: string | null; // 实际填充 ADN;纯发奖行为空
slot_id: string | null; // 底层 mediation rit;纯发奖行为空
// ── 发奖侧 ──
has_reward: boolean; // 是否有发奖(激励视频合并行/信息流整场发奖行=true;纯展示=false)
status: string | null; // 发奖状态 granted/closed_early/…;纯展示为空
expected_coin: number; // 应发金币;纯展示=0
actual_coin: number; // 实发金币;纯展示=0
matched: boolean; // 本条应发==实发;纯展示恒 true(不计对账)
reward_detail: AdRevenueRecord | null; // 发奖复算明细(点行展开下钻);纯展示为空
}
export interface AdRevenueReport {
date_from: string; // 起始日 北京时间 YYYY-MM-DD
date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同)
daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图)
total: number; // 聚合组总数(全量,不受 limit 影响)
total: number; // 广告事件总数(全量,不受 limit 影响)
truncated: boolean;
total_impressions: number;
total_revenue_yuan: number;
total_expected_coin: number;
total_actual_coin: number;
mismatch_count: number; // 应发≠实发的
mismatch_count: number; // 应发≠实发的发奖条
items: AdRevenueRow[];
}