From ee9e75d7029a3b162a6b3d189e903440fcce7e44 Mon Sep 17 00:00:00 2001 From: linkeyu <798648091@qq.com> Date: Sun, 26 Jul 2026 19:25:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=B9=BF=E5=91=8A=E6=94=B6=E7=9B=8A=E7=9C=8B=E6=9D=BF=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=E4=B8=8E=E5=AF=BC=E8=88=AA=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/ad-revenue-report/page.tsx | 232 ++++++++++++++-------- src/lib/types.ts | 28 ++- 2 files changed, 166 insertions(+), 94 deletions(-) diff --git a/src/app/(main)/ad-revenue-report/page.tsx b/src/app/(main)/ad-revenue-report/page.tsx index 189f373..02091b2 100644 --- a/src/app/(main)/ad-revenue-report/page.tsx +++ b/src/app/(main)/ad-revenue-report/page.tsx @@ -9,7 +9,6 @@ import { Card, Col, DatePicker, - Descriptions, Divider, InputNumber, Modal, @@ -43,6 +42,8 @@ import type { import UserAdRevenueDrawer from './UserAdRevenueDrawer'; const { RangePicker } = DatePicker; +const DEFAULT_PAGE_SIZE = 50; +const ALL_FILTER_VALUE = '__all__'; // 广告类型标签 const TYPE_TAG: Record = { @@ -81,20 +82,12 @@ const PLAYBACK_STATUS_TAG: Record = { unknown: { color: 'default', label: '未知' }, }; -const ZERO_REVENUE_REWARD_VIDEO_STATUSES = new Set(['closed_early', 'too_short']); - function rewardStatuses(row: AdRevenueRow): string[] { if (row.sub_rewards?.length) return row.sub_rewards.map((reward) => reward.status); return row.status ? [row.status] : []; } function effectiveRevenueYuan(row: AdRevenueRow): number { - if ( - row.ad_type === 'reward_video' - && rewardStatuses(row).some((status) => ZERO_REVENUE_REWARD_VIDEO_STATUSES.has(status)) - ) { - return 0; - } return row.row_revenue_yuan ?? row.revenue_yuan; } @@ -190,11 +183,59 @@ const LT_FACTOR_ROWS = [ { key: '5', lt: '第 11 条及以后', factor: 1.0 }, ]; -type AdRevenueDetailRow = AdRevenueRecord & { source_adn?: string | null }; +type AdRevenueDetailRow = AdRevenueRecord & { + source_adn?: string | null; + is_impression_only?: boolean; +}; + +// 未发奖记录保留「-」以避免把潜在奖励误读为实际计算结果;悬停时说明为何没有因子。 +function renderFormulaValue(value: string | number, row: AdRevenueDetailRow) { + if (value !== '-' || row.status === 'granted') return value; + const reason = row.is_impression_only + ? '该记录只有广告展示,没有对应发奖记录' + : REWARD_STATUS_HINT[row.status] ?? `发奖状态为 ${row.status}`; + return ( + + - + + ); +} + +function impressionOnlyDetail(row: AdRevenueRow): AdRevenueDetailRow { + return { + // 每个展开表只有这一条纯展示明细,使用稳定的本行唯一键即可。 + record_id: 0, + created_at: row.created_at, + status: 'impression_only', + ecpm: row.ecpm, + ecpm_factor: null, + units: 0, + lt_index_start: null, + lt_index_end: null, + lt_factor_start: null, + lt_factor_end: null, + expected_coin: 0, + actual_coin: 0, + matched: true, + adn: row.adn, + slot_id: row.slot_id, + source_adn: row.adn, + is_impression_only: true, + }; +} // 展开行(逐条事件展开)- 该条的发奖复算明细(还原金币审计的 eCPM/因子1/份数/LT/因子2 等列) const DETAIL_COLUMNS: ColumnsType = [ - { title: '来源广告网络', dataIndex: 'source_adn', width: 110, render: (value: string | null) => value || '-' }, + { + title: '来源广告网络', + dataIndex: 'source_adn', + width: 110, + render: (value: string | null) => value?.trim() || ( + + 未上报 + + ), + }, { title: 'eCPM(元)', dataIndex: 'ecpm', @@ -208,33 +249,40 @@ const DETAIL_COLUMNS: ColumnsType = [ render: (v: string | null) => (v == null || Number.isNaN(Number(v)) ? '-' : (Number(v) / 100 / 1000).toFixed(4)), }, { title: '实发金币数', dataIndex: 'actual_coin', width: 100 }, - { title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' }, + { + title: '因子1', + dataIndex: 'ecpm_factor', + width: 70, + render: (v: number | null, r: AdRevenueDetailRow) => renderFormulaValue(v ?? '-', r), + }, { title: '因子2', key: 'lt_factor', width: 90, - render: (_: unknown, r: AdRevenueDetailRow) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end), + render: (_: unknown, r: AdRevenueDetailRow) => + renderFormulaValue(fmtFactorRange(r.lt_factor_start, r.lt_factor_end), r), }, { title: 'LT累计条数', key: 'lt_index', width: 100, - render: (_: unknown, r: AdRevenueDetailRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end), + render: (_: unknown, r: AdRevenueDetailRow) => + renderFormulaValue(fmtIndexRange(r.lt_index_start, r.lt_index_end), r), }, ]; // 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),橙线=客户端预估收益元(右轴), -// 绿线=穿山甲后台预估收益元(右轴,仅按天且有数据时出现)。x 轴按传入点序。 +// 绿线=GroMore 排序价预估元(右轴,仅按天且有数据时出现)。x 轴按传入点序。 const CHART_BAR = '#69b1ff'; const CHART_LINE = '#fa8c16'; -const CHART_LINE2 = '#52c41a'; // 穿山甲后台收益线 +const CHART_LINE2 = '#52c41a'; // GroMore 排序价预估线 interface TrendPoint { label: string; // x 轴刻度文案(小时数 / MM-DD) tip: string; // hover 原生 tooltip 全文 impressions: number; revenue: number; - pangleRevenue: number | null; // 穿山甲后台预估收益(元);无则 null(不画绿线点) + pangleRevenue: number | null; // GroMore 排序价预估(元);无则 null(不画绿线点) } // 按小时聚合(0–23),数据源是 hourly(后端全量聚合,不受分页影响)。穿山甲为天级,小时视图无该线。 @@ -255,7 +303,7 @@ function aggregateHourly(rows: AdRevenueHourly[]): TrendPoint[] { } // 按天聚合,数据源是 daily(全量,不受 limit 影响);用 from..to 补齐空缺日为 0,轴连续。 -// 穿山甲收益(pangle_revenue_yuan)为 null 时该日不画绿线点(T+1 未出/非全量视图)。 +// GroMore 排序价预估(pangle_revenue_yuan)为 null 时该日不画绿线点(T+1 未出/非全量视图)。 function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[]): TrendPoint[] { const map = new Map(daily.map((d) => [d.date, d])); const out: TrendPoint[] = []; @@ -344,7 +392,7 @@ function TrendChart({ points }: { points: TrendPoint[] }) { {p.tip} ))} - {/* 穿山甲后台收益线(绿,仅非 null 的天画;按天且已同步 T+1 数据时出现) */} + {/* GroMore 排序价预估线(绿,仅非 null 的天画;按天且已同步 T+1 数据时出现) */} {(() => { const pp = points .map((p, i) => ({ i, v: p.pangleRevenue, tip: p.tip })) @@ -387,13 +435,13 @@ export default function AdRevenueReportPage() { const [scene, setScene] = useState(); const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); const [sortBy, setSortBy] = useState<'time' | 'ecpm'>('time'); // 明细排序:time=时间倒序 / ecpm=eCPM 倒序 - const [limit, setLimit] = useState(1000); // 默认拉满接口单页上限,供单次流程广告数分位统计 + const [limit, setLimit] = useState(DEFAULT_PAGE_SIZE); const [page, setPage] = useState(1); // 当前页码(后端分页;1 起) const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列 const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图 const [data, setData] = useState(null); const [allFlowItems, setAllFlowItems] = useState([]); - const [queriedLimit, setQueriedLimit] = useState(1000); + const [queriedLimit, setQueriedLimit] = useState(DEFAULT_PAGE_SIZE); const [loading, setLoading] = useState(false); const [formulaOpen, setFormulaOpen] = useState(false); // 点用户手机号弹出的「用户广告收益详情」半屏抽屉(userId + 手机号;null=关闭) @@ -558,7 +606,6 @@ export default function AdRevenueReportPage() { width: 110, align: 'right', // 一次比价/领券聚合行用 row_revenue_yuan(该次发奖广告 eCPM 折算之和),其它行用展示侧 revenue_yuan - // 激励视频提前关闭/时长不足时防御性显示 0,与后端有效收益口径一致。 render: (_v: number, r: AdRevenueRow) => effectiveRevenueYuan(r).toFixed(4), }, { @@ -637,6 +684,8 @@ export default function AdRevenueReportPage() { // 应发实发差额(金币)= 应发−实发(正=少发/负=多发);ARPU = 区间预估收益÷区间活跃用户(见 dau)。 const derived = data ? { + // ADN Reporting API 只覆盖已配置回传的广告源,不能据此推导全量 ARPU / 毛利。 + // 在接入最终结算单前,核心经营指标始终使用 SDK 展示预估,并明确标注其性质。 payoutYuan: data.total_actual_coin / COIN_PER_YUAN, grossProfit: data.total_revenue_yuan - data.total_actual_coin / COIN_PER_YUAN, payoutRatioPct: @@ -644,7 +693,6 @@ export default function AdRevenueReportPage() { ? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100 : null, coinGap: data.total_expected_coin - data.total_actual_coin, - // ARPU = 区间预估广告收益 ÷ 区间去重活跃用户(dau,口径同数据大盘);dau 为 0 时不可算 → null,显示「-」。 arpu: data.dau && data.dau > 0 ? data.total_revenue_yuan / data.dau : null, } : null; @@ -661,11 +709,15 @@ export default function AdRevenueReportPage() { : data.date_from : `${data.date_from} ~ ${data.date_to}`; - // 第二行大盘「分广告类型」:draw / 激励视频 各自 收益 / eCPM / 展示条数;eCPM = 收益÷展示×1000。 - const drawStat = data?.type_stats?.draw; - const rvStat = data?.type_stats?.reward_video; - const ecpmOf = (s?: AdRevenueTypeStat) => - s && s.impressions > 0 ? (s.revenue_yuan / s.impressions) * 1000 : 0; + // 经营分类由后端统一:Draw = draw + 历史 feed;看视频 = 福利视频 + 提现视频。 + // eCPM 同时按每次展示的 SDK 原始值加权,不能从前端漏项后再临时平均。 + const drawStat = data?.category_stats?.draw; + const videoStat = data?.category_stats?.video; + const videoLabel = adType === 'withdrawal_video' + ? '提现看视频' + : adType === 'reward_video' + ? '福利看视频' + : '看视频(福利+提现)'; // 明细直接用后端返回的当前页 items(「场景」已由后端 feed_scene 全局过滤,前端不再二次筛)。 const items = data?.items ?? []; @@ -704,14 +756,19 @@ export default function AdRevenueReportPage() { 逐条展示不单独占行(其展示数 / eCPM / 预估收益已计入上方合计与大盘),点行左侧「+」展开看该次金币复算。

- 「预估收益」为客户端在广告展示(onAdShow)时上报 eCPM 折算的预估值(每千次展示 ÷1000 - 累加);激励视频提前关闭或播放时长不足时按 0 计,其他有效展示按 eCPM 折算。穿山甲仍可能过滤无效曝光, - 实际收益一律以穿山甲后台结算为准。测试应用多为 0。「广告位ID / 来源应用」为本期新增,历史记录留空。 + 「客户端 SDK 展示预估」按广告展示(onAdShow)后端留存的 eCPM 折算(每千次展示 ÷1000 + 累加;不足 1 秒的异常曝光沿用采集规则记 0)。是否看满只影响金币发放;发奖防作弊的 eCPM 上限不参与收入计算。 + 最终收入仍以各 ADN 结算单为准

- 核心指标里的「穿山甲后台收益(T+1)」来自穿山甲 GroMore 数据 API(后台结算口径,次日出数): - 穿山甲预估收益=接口 revenue、收益API=各 ADN 回传更接近结算。穿山甲不提供分用户/类型/场景维度, - 故仅在可对账视图(未按用户/类型/场景筛选)展示;环境和数据范围会同时作用于客户端与穿山甲汇总。 + GroMore 排序价预估来自接口 revenue,只是排序价/竞价实时价估算,不是结算收入; + ADN Reporting API 收益来自已配置广告源的 api_revenue 回传;每天 14:00 后仅代表同步窗口完成, + 不代表覆盖全部广告源或最终结算,因此不用于自动计算全量毛利和 ARPU。 +
+
+ 分类卡口径:Draw 信息流 = draw + 历史 feed看视频 = 福利视频 + 提现视频。 + 两者的 eCPM 都由后端按每次展示的 SDK eCPM 加权计算(eCPM 总和 ÷ 展示次数),不从分页明细或单一子类型取值。 + GroMore 不提供分用户/类型/场景维度,故仅在可对账视图(未按用户/类型/场景筛选)展示。 默认“正式 + 业务代码位”用于同口径对账;切到“全部代码位”会包含广告测试等非业务曝光。 } @@ -775,12 +832,11 @@ export default function AdRevenueReportPage() { 类型 setScene(value === ALL_FILTER_VALUE ? undefined : value)} style={{ width: 130 }} options={[ + { value: ALL_FILTER_VALUE, label: '全部' }, { value: 'comparison', label: '比价' }, { value: 'coupon', label: '领券' }, { value: 'welfare', label: '福利' }, @@ -858,16 +913,16 @@ export default function AdRevenueReportPage() { - + - {dauRangeIsToday ? 'ARPU(今日)' : 'ARPU'} + SDK 预估 ARPU @@ -896,7 +951,7 @@ export default function AdRevenueReportPage() { = 0 ? : } @@ -909,9 +964,9 @@ export default function AdRevenueReportPage() { - + - 穿山甲后台收益(T+1) + GroMore / ADN 收益对账(T+1) @@ -920,29 +975,41 @@ export default function AdRevenueReportPage() { - {data.total_pangle_api_revenue_yuan == null && ( + {data.total_pangle_api_revenue_yuan == null ? ( - 未配 Reporting / 当天无 + 未配置 Reporting、当天未出数或所选维度不可对账 + + ) : ( + + {data.pangle_api_revenue_complete + ? '已完成 API 同步(不代表全量结算)' + : '暂估,等待 14:30 API 同步'} + {data.pangle_latest_synced_at + ? ` · 同步于 ${formatUtcTime(data.pangle_latest_synced_at)}` + : ''} )} + - 客户端预估偏差 + SDK / GroMore 预估差 @@ -967,8 +1034,7 @@ export default function AdRevenueReportPage() { ) : ( - 本视图无穿山甲后台收益:仅「全量视图」(未按用户/类型/场景筛选)且对应日期已同步到数据时展示。 - 穿山甲 T+1 出数,可由 scripts/sync_pangle_revenue 每日拉取入库。 + 本视图无 GroMore/ADN 对账收益:仅未按用户、类型、场景筛选且对应日期已同步时展示。 )} @@ -981,19 +1047,19 @@ export default function AdRevenueReportPage() { - + - + - + - + @@ -1058,7 +1124,7 @@ export default function AdRevenueReportPage() { verticalAlign: 'middle', }} /> - 穿山甲后台收益(元) + GroMore 排序价预估(元) )} @@ -1102,7 +1168,11 @@ export default function AdRevenueReportPage() { style={{ marginTop: 8 }} rowKey="record_id" columns={DETAIL_COLUMNS} - dataSource={r.sub_rewards.map((detail) => ({ ...detail, source_adn: r.adn }))} + dataSource={r.sub_rewards.map((detail) => ({ + ...detail, + // 一次比价/领券可能由不同 ADN 连续填充,必须优先显示本条发奖记录的来源。 + source_adn: detail.adn ?? r.adn, + }))} pagination={false} size="small" scroll={{ x: 900 }} @@ -1110,15 +1180,18 @@ export default function AdRevenueReportPage() { ) : r.reward_detail ? (
- 金币复算明细{' '} + 本次广告逐条明细{' '} - (本条广告 应发 {r.reward_detail.expected_coin} / 实发 {r.reward_detail.actual_coin}) + (共 1 条 · 应发 {r.reward_detail.expected_coin} / 实发 {r.reward_detail.actual_coin}) style={{ marginTop: 8 }} rowKey="record_id" columns={DETAIL_COLUMNS} - dataSource={[{ ...r.reward_detail, source_adn: r.adn }]} + dataSource={[{ + ...r.reward_detail, + source_adn: r.reward_detail.adn ?? r.adn, + }]} pagination={false} size="small" scroll={{ x: 900 }} @@ -1126,24 +1199,17 @@ export default function AdRevenueReportPage() {
) : (
- 展示明细{' '} - (纯展示事件,未单独发奖,无金币复算) - - {formatUtcTime(r.created_at)} - {r.ecpm ?? '-'} - - {r.ecpm != null && !Number.isNaN(Number(r.ecpm)) - ? (Number(r.ecpm) / 100).toFixed(2) - : '-'} - - - {r.has_impression ? r.revenue_yuan.toFixed(4) : '-'} - - {r.adn ?? '-'} - {r.slot_id ?? '-'} - {r.our_code_id ?? '-'} - {r.app_env ?? '-'} - + 本次广告逐条明细{' '} + (共 1 条 · 应发 0 / 实发 0;仅展示,未单独发奖) + + style={{ marginTop: 8 }} + rowKey="record_id" + columns={DETAIL_COLUMNS} + dataSource={[impressionOnlyDetail(r)]} + pagination={false} + size="small" + scroll={{ x: 900 }} + />
), }} diff --git a/src/lib/types.ts b/src/lib/types.ts index 26d8d95..429c99c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -542,6 +542,8 @@ export interface AdRevenueRecord { expected_coin: number; actual_coin: number; matched: boolean; + adn: string | null; // 本条发奖的实际填充 ADN(不能只取整场聚合父行) + slot_id: string | null; // 本条发奖的底层 mediation rit } // 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受分页影响) @@ -549,8 +551,8 @@ export interface AdRevenueDaily { date: string; // 北京时间 YYYY-MM-DD impressions: number; revenue_yuan: number; // 客户端预估收益(eCPM 折算) - pangle_revenue_yuan: number | null; // 穿山甲后台预估收益(GroMore revenue);非全量视图/无数据为 null - pangle_api_revenue_yuan: number | null; // 穿山甲收益API(更接近结算);未配/当天/无数据为 null + pangle_revenue_yuan: number | null; // GroMore 排序价预估(revenue);非全量视图/无数据为 null + pangle_api_revenue_yuan: number | null; // ADN Reporting API 收益;未配/当天/无数据为 null expected_coin: number; actual_coin: number; } @@ -564,10 +566,11 @@ export interface AdRevenueHourly { actual_coin: number; } -// 广告收益报表:按广告类型(ad_type)的小计(展示条数 + 预估收益;eCPM 前端用 收益÷展示×1000 算) +// 广告收益报表:展示条数、SDK 展示预估收益与按展示次数加权的 SDK eCPM。 export interface AdRevenueTypeStat { impressions: number; // 该类型展示条数合计 revenue_yuan: number; // 该类型预估收益合计(元) + ecpm_yuan: number; // 加权 SDK eCPM(元/千次) } // 广告收益报表:一次广告事件(逐条一行)。激励视频展示+发奖按 ad_session_id 合并;信息流展示/发奖各自成行。 @@ -586,10 +589,10 @@ export interface AdRevenueRow { has_impression: boolean; // 是否有广告展示(信息流逐条展示=true,纯发奖行=false) impressions: number; // 本行展示条数 1/0(供日汇总、趋势图复用) ecpm: string | null; // 分/千次;展示行取展示值,纯发奖行取发奖采用值 - revenue_yuan: number; // 本次有效展示预估收益(元);纯发奖、激励视频提前关闭/时长不足=0 + revenue_yuan: number; // 本次 SDK 展示预估收益(元);是否满足发奖条件不改变已产生的展示收入预估 row_revenue_yuan?: number | null; // 主表逐行显示用:一次比价/领券聚合行=该次发奖广告 eCPM 折算之和;其它行空(回退 revenue_yuan) - adn: string | null; // 实际填充 ADN;纯发奖行为空 - slot_id: string | null; // 底层 mediation rit;纯发奖行为空 + adn: string | null; // 实际填充 ADN;历史或未上报展示来源为空 + slot_id: string | null; // 底层 mediation rit;历史或未上报展示来源为空 // ── 发奖侧 ── has_reward: boolean; // 是否有发奖(激励视频合并行/信息流整场发奖行=true;纯展示=false) status: string | null; // 发奖状态 granted/closed_early/…;纯展示为空 @@ -606,7 +609,8 @@ export interface AdRevenueReport { date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同) daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图) hourly: AdRevenueHourly[]; // 按小时汇总序列(全量,供按小时趋势图;按天查询时为空) - type_stats: Record; // 按广告类型小计;前端取 draw / reward_video + type_stats: Record; // 按原始广告类型小计 + category_stats?: Record; // draw=draw+历史feed;video=福利视频+提现视频(兼容后端滚动发布) // 按信息流场景小计(comparison/coupon/welfare;全量,不受分页截断)。数据大盘「领券广告/比价广告」卡 // 优先读它:2026-07-02 后端起 items 不再含信息流逐条展示行(唯一带收益+场景的行),按 items 现算恒 0。 // 可选(`?.` 探测)以兼容未升级后端,缺失时回退 items 现算。 @@ -621,10 +625,12 @@ export interface AdRevenueReport { total: number; // 当前筛选下的分页总条数(全量,不受分页影响) truncated: boolean; // 当前页之后是否还有更多事件(分页后前端不再据此报警) total_impressions: number; - total_revenue_yuan: number; // 客户端预估收益合计(eCPM 折算) - total_pangle_revenue_yuan: number | null; // 穿山甲后台预估收益合计(GroMore revenue);非全量视图/无数据为 null - total_pangle_api_revenue_yuan: number | null; // 穿山甲收益API合计(更接近结算);未配/当天/非全量视图为 null - pangle_revenue_available: boolean; // 本次结果是否带穿山甲后台收益(全量视图且已同步到数据) + total_revenue_yuan: number; // 客户端 SDK 展示预估合计(后端留存 eCPM 折算) + total_pangle_revenue_yuan: number | null; // GroMore 排序价预估合计(revenue);非全量视图/无数据为 null + total_pangle_api_revenue_yuan: number | null; // ADN Reporting API 收益合计;未配/当天/非全量视图为 null + pangle_api_revenue_complete: boolean; // 每一天均在 D+1 14:00 后完成 API 同步窗口,不代表全量结算 + pangle_latest_synced_at: string | null; // GroMore 日报最近同步时间 + pangle_revenue_available: boolean; // 本次结果是否带 GroMore/ADN 收益(全量视图且已同步到数据) total_expected_coin: number; total_actual_coin: number; mismatch_count: number; // 应发≠实发的发奖条数