diff --git a/alembic/versions/ad_revenue_report_cols.py b/alembic/versions/ad_revenue_report_cols.py new file mode 100644 index 0000000..afbc8b4 --- /dev/null +++ b/alembic/versions/ad_revenue_report_cols.py @@ -0,0 +1,39 @@ +"""ad revenue report columns: app_env + our_code_id + +给 ad_ecpm_record / ad_reward_record / ad_feed_reward_record 各加两列: +- app_env:我们的穿山甲应用环境(prod=傻瓜比价正式 / test=测试应用) +- our_code_id:我们在穿山甲后台配置的代码位 ID(104xxx,非底层 mediation rit) + +供「广告收益报表」按 用户/日期/广告类型/应用/代码位 聚合 展示条数/收益/金币。 +旧数据这两列为 NULL(报表里来源列留空),新数据由客户端上报/发奖时回填。 + +Revision ID: ad_revenue_report_cols +Revises: coupon_engage_per_package +Create Date: 2026-06-15 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "ad_revenue_report_cols" +down_revision = "coupon_engage_per_package" +branch_labels = None +depends_on = None + + +_TABLES = ("ad_ecpm_record", "ad_reward_record", "ad_feed_reward_record") + + +def upgrade() -> None: + for table in _TABLES: + op.add_column(table, sa.Column("app_env", sa.String(length=16), nullable=True)) + op.add_column(table, sa.Column("our_code_id", sa.String(length=64), nullable=True)) + + +def downgrade() -> None: + for table in _TABLES: + op.drop_column(table, "our_code_id") + op.drop_column(table, "app_env") diff --git a/alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py b/alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py new file mode 100644 index 0000000..16fc94c --- /dev/null +++ b/alembic/versions/d4e68464761d_merge_ad_revenue_report_cols_and_store_.py @@ -0,0 +1,26 @@ +"""merge ad_revenue_report_cols and store_mapping_tb_dl_invalid heads + +Revision ID: d4e68464761d +Revises: ad_revenue_report_cols, store_mapping_tb_dl_invalid +Create Date: 2026-06-15 21:55:58.115692 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd4e68464761d' +down_revision: Union[str, Sequence[str], None] = ('ad_revenue_report_cols', 'store_mapping_tb_dl_invalid') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/app/admin/main.py b/app/admin/main.py index a422885..c65aa9e 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -14,6 +14,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.admin.routers.ad_audit import router as ad_audit_router +from app.admin.routers.ad_revenue import router as ad_revenue_router from app.admin.routers.admins import router as admins_router from app.admin.routers.audit import router as audit_router from app.admin.routers.auth import router as auth_router @@ -90,3 +91,4 @@ admin_app.include_router(admins_router) admin_app.include_router(audit_router) admin_app.include_router(config_router) admin_app.include_router(ad_audit_router) +admin_app.include_router(ad_revenue_router) diff --git a/app/admin/repositories/ad_audit.py b/app/admin/repositories/ad_audit.py index e08ae68..7cb2941 100644 --- a/app/admin/repositories/ad_audit.py +++ b/app/admin/repositories/ad_audit.py @@ -67,6 +67,8 @@ def _reward_video_rows( "scene": "reward_video", "record_id": rec.id, "user_id": rec.user_id, + "app_env": rec.app_env, + "our_code_id": rec.our_code_id, "created_at": rec.created_at, "status": rec.status, "ecpm": rec.ecpm_raw, @@ -86,6 +88,8 @@ def _reward_video_rows( "scene": "reward_video", "record_id": rec.id, "user_id": rec.user_id, + "app_env": rec.app_env, + "our_code_id": rec.our_code_id, "created_at": rec.created_at, "status": rec.status, "ecpm": rec.ecpm_raw, @@ -150,6 +154,8 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: "scene": "feed", "record_id": rec.id, "user_id": rec.user_id, + "app_env": rec.app_env, + "our_code_id": rec.our_code_id, "created_at": rec.created_at, "status": rec.status, "ecpm": rec.ecpm_raw, @@ -168,6 +174,8 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: "scene": "feed", "record_id": rec.id, "user_id": rec.user_id, + "app_env": rec.app_env, + "our_code_id": rec.our_code_id, "created_at": rec.created_at, "status": rec.status, "ecpm": rec.ecpm_raw, @@ -184,6 +192,22 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: return rows +def audit_rows( + db: Session, *, date: str, user_id: int | None, scene: str | None = None +) -> list[dict]: + """当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed"。 + + 每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账, + 也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。 + """ + rows: list[dict] = [] + if scene in (None, "reward_video"): + rows.extend(_reward_video_rows(db, date=date, user_id=user_id)) + if scene in (None, "feed"): + rows.extend(_feed_rows(db, date=date, user_id=user_id)) + return rows + + def ad_coin_audit( db: Session, *, @@ -200,11 +224,7 @@ def ad_coin_audit( 影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。 份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。 """ - rows: list[dict] = [] - if scene in (None, "reward_video"): - rows.extend(_reward_video_rows(db, date=date, user_id=user_id)) - if scene in (None, "feed"): - rows.extend(_feed_rows(db, date=date, user_id=user_id)) + rows = audit_rows(db, date=date, user_id=user_id, scene=scene) rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True) total = len(rows) diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py new file mode 100644 index 0000000..206726b --- /dev/null +++ b/app/admin/repositories/ad_revenue.py @@ -0,0 +1,239 @@ +"""admin 广告收益报表:按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合(单表含发奖对账)。 + +只读。聚合键 = user_id × ad_type × app_env × our_code_id;每组一行同时给出: +- 展示条数 + 收益:`ad_ecpm_record`(每行 = 客户端一次广告展示;收益 = Σ eCPM元 ÷ 1000)。 + 激励视频每次展示上报一行;信息流轮播每条展示各上报一行(每条独立 id,不复用会话)。 +- 应发金币 / 实发金币:复用金币审计的**逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径, + 不另写公式),把每条发奖记录的 expected/actual 按同维度求和;`matched` = 组内**逐条**全部一致 + (任一条不符该组即不符,不用「应发和==实发和」以免互相抵消掩盖错误)。**不改发奖逻辑**,只读复算。 + +展示与发奖来自不同表,做并集:有展示无发奖(用户中途关 / 未达发奖)、有发奖无展示 +(未上报 eCPM)都各自成行。app_env/our_code_id 旧数据为 NULL → 归到「来源未知」组。 + +⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 `feed`(迁移后 Draw +不再产生新数据)。② 聚合级只能看出「某组应发≠实发」,定位到具体哪条仍需逐条审计接口(ad-coin-audit)。 +""" +from __future__ import annotations + +from datetime import date as _date, datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.admin.repositories import ad_audit +from app.core import rewards +from app.models.ad_ecpm import AdEcpmRecord + + +def _cn_hour(dt: datetime) -> int: + """created_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。""" + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(rewards.CN_TZ).hour + + +def _key( + report_date: str, + user_id: int, + ad_type: str, + app_env: str | None, + our_code_id: str | None, + hour: int | None, +) -> tuple: + return (report_date, user_id, ad_type, app_env or None, our_code_id or None, hour) + + +def _date_range(date_from: str, date_to: str) -> list[str]: + """闭区间内逐日 'YYYY-MM-DD' 串(含首尾)。date_from > date_to 时返回空。""" + d0 = _date.fromisoformat(date_from) + d1 = _date.fromisoformat(date_to) + out: list[str] = [] + d = d0 + while d <= d1: + out.append(d.isoformat()) + d += timedelta(days=1) + return out + + +# 审计行的 scene 与报表 ad_type 一一对应 +_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"} + + +def ad_revenue_report( + db: Session, + *, + date_from: str, + date_to: str, + user_id: int | None = None, + ad_type: str | None = None, + granularity: str = "day", + limit: int = 500, +) -> dict: + """日期区间(北京时间,闭区间)广告收益聚合 + 发奖对账。单日时 date_from==date_to。 + + 聚合键含**日期**:report_date × user × ad_type × app_env × our_code_id(× 北京小时,granularity=hour)。 + ad_type: None=全部 / reward_video / feed / draw。 + granularity: "day"=按天 / "hour"=按小时(聚合键再加北京小时 0–23,每组一行)。 + limit 只截断展示明细,total 与 total_* / daily 在全量上统计(不受 limit 影响),数字始终可信。 + + 返回额外含 `daily`(按日期汇总的展示/收益/应发/实发,供前端按天趋势图;不受 limit 影响)。 + + 注:按小时下,展示按 ecpm 记录的小时、金币按发奖记录的小时各自归桶——S2S 回调可能比展示晚 + 一会儿,故同一次广告的展示与金币偶尔落相邻小时(按天则一致)。 + """ + by_hour = granularity == "hour" + groups: dict[tuple, dict] = {} + + def _grp(key: tuple) -> dict: + g = groups.get(key) + if g is None: + rdate, uid, atype, app_env, code_id, hour = key + g = { + "report_date": rdate, + "user_id": uid, + "ad_type": atype, + "app_env": app_env, + "our_code_id": code_id, + "hour": hour, + "impressions": 0, + "revenue_yuan": 0.0, + "expected_coin": 0, + "actual_coin": 0, + "adns": set(), + "impression_records": [], # 该组逐条展示明细(展开下钻用) + "records": [], # 该组逐条发奖复算明细(展开下钻用) + } + groups[key] = g + return g + + # 1) 展示条数 + 收益 ← ad_ecpm_record(report_date 闭区间;字符串 YYYY-MM-DD 字典序即日期序) + stmt = select(AdEcpmRecord).where( + AdEcpmRecord.report_date >= date_from, + AdEcpmRecord.report_date <= date_to, + ) + if user_id is not None: + stmt = stmt.where(AdEcpmRecord.user_id == user_id) + if ad_type is not None: + stmt = stmt.where(AdEcpmRecord.ad_type == ad_type) + for rec in db.execute(stmt).scalars(): + hour = _cn_hour(rec.created_at) if by_hour else None + g = _grp(_key(rec.report_date, rec.user_id, rec.ad_type, rec.app_env, rec.our_code_id, hour)) + g["impressions"] += 1 + # 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次);用与发奖同源的解析,口径一致。 + rev = rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0 + g["revenue_yuan"] += rev + if rec.adn: + g["adns"].add(rec.adn) + g["impression_records"].append({ + "id": rec.id, + "created_at": rec.created_at, + "ecpm": rec.ecpm_raw, + "revenue_yuan": round(rev, 6), + "adn": rec.adn, + "slot_id": rec.slot_id, + }) + + # 2) 应发 / 实发金币 ← 复用金币审计逐条复算(同一公式口径),按同维度求和。 + # audit_rows 是单日的,区间逐日调用,每天的行归到当天 report_date(语义与单日报表完全一致)。 + # ad_type=draw 时审计无对应记录(scene 只有 reward_video/feed),金币侧自然为空。 + audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None + if ad_type is None or audit_scene is not None: + for d in _date_range(date_from, date_to): + for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene): + atype = _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"]) + hour = _cn_hour(row["created_at"]) if by_hour else None + g = _grp(_key(d, row["user_id"], atype, row.get("app_env"), row.get("our_code_id"), hour)) + g["expected_coin"] += int(row["expected_coin"]) + g["actual_coin"] += int(row["actual_coin"]) + # 逐条明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致)——前端展开该组时下钻展示。 + g["records"].append({ + "record_id": row["record_id"], + "created_at": row["created_at"], + "status": row["status"], + "ecpm": row["ecpm"], + "ecpm_factor": row["ecpm_factor"], + "units": row["units"], + "lt_index_start": row["lt_index_start"], + "lt_index_end": row["lt_index_end"], + "lt_factor_start": row["lt_factor_start"], + "lt_factor_end": row["lt_factor_end"], + "expected_coin": row["expected_coin"], + "actual_coin": row["actual_coin"], + "matched": row["matched"], + }) + + rows = list(groups.values()) + rows.sort( + key=lambda r: ( + r["report_date"], + r["user_id"], + r["hour"] if r["hour"] is not None else -1, + r["ad_type"] or "", + r["our_code_id"] or "", + ) + ) + + total_impressions = sum(r["impressions"] for r in rows) + total_expected_coin = sum(r["expected_coin"] for r in rows) + total_actual_coin = sum(r["actual_coin"] for r in rows) + total_revenue_yuan = round(sum(r["revenue_yuan"] for r in rows), 6) + + # 按日期汇总(全量,不受 limit):供前端按天趋势图。 + daily_map: dict[str, dict] = {} + for r in rows: + d = daily_map.get(r["report_date"]) + if d is None: + d = { + "date": r["report_date"], + "impressions": 0, + "revenue_yuan": 0.0, + "expected_coin": 0, + "actual_coin": 0, + } + daily_map[r["report_date"]] = d + d["impressions"] += r["impressions"] + d["revenue_yuan"] += r["revenue_yuan"] + d["expected_coin"] += r["expected_coin"] + d["actual_coin"] += r["actual_coin"] + daily = [ + {**d, "revenue_yuan": round(d["revenue_yuan"], 6)} + for d in sorted(daily_map.values(), key=lambda x: x["date"]) + ] + + items = [ + { + "report_date": r["report_date"], + "user_id": r["user_id"], + "ad_type": r["ad_type"], + "app_env": r["app_env"], + "our_code_id": r["our_code_id"], + "hour": r["hour"], + "impressions": r["impressions"], + "revenue_yuan": round(r["revenue_yuan"], 6), + "expected_coin": r["expected_coin"], + "actual_coin": r["actual_coin"], + # 组内**逐条**全部一致才记一致——不能用「应发和==实发和」,否则一条多发+一条少发会互相 + # 抵消、求和相等被误判为 ✓,掩盖真实发奖错误。纯展示无发奖记录的组 all([]) → True。 + "matched": all(rec["matched"] for rec in r["records"]), + "adns": sorted(r["adns"]), + "impression_records": sorted( + r["impression_records"], key=lambda x: (x["created_at"], x["id"]) + ), + "records": sorted(r["records"], key=lambda x: (x["created_at"], x["record_id"])), + } + for r in rows[:limit] + ] + + return { + "total": len(rows), + "truncated": len(rows) > limit, + "total_impressions": total_impressions, + "total_revenue_yuan": total_revenue_yuan, + "total_expected_coin": total_expected_coin, + "total_actual_coin": total_actual_coin, + "mismatch_count": sum( + 1 for r in rows if not all(rec["matched"] for rec in r["records"]) + ), + "daily": daily, + "items": items, + } diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index a4721e0..6e39192 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -286,10 +286,11 @@ def list_feedbacks( sort_order: str = "desc", limit: int = 20, cursor: int | None = None, -) -> tuple[list[Feedback], int | None]: +) -> tuple[list[Feedback], int | None, int]: """反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。 **offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间 - 数据变动可能错位一条——admin 低频场景可接受。created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。""" + 数据变动可能错位一条——admin 低频场景可接受。返回 (items, next_cursor, total),total 供页码分页。 + created_at 为 timestamptz,日期入参统一转 tz-aware UTC 比较。""" stmt = select(Feedback) if status: stmt = stmt.where(Feedback.status == status) @@ -306,14 +307,7 @@ def list_feedbacks( sort_col = sort_cols.get(sort_by, Feedback.id) order_fn = asc if sort_order == "asc" else desc id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id) - stmt = stmt.order_by(order_fn(sort_col), id_order) - - offset = max(cursor or 0, 0) - rows = list(db.execute(stmt.offset(offset).limit(limit + 1)).scalars().all()) - has_more = len(rows) > limit - items = rows[:limit] - next_cursor = offset + limit if has_more else None - return items, next_cursor + return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor) def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None: diff --git a/app/admin/routers/ad_revenue.py b/app/admin/routers/ad_revenue.py new file mode 100644 index 0000000..1a67270 --- /dev/null +++ b/app/admin/routers/ad_revenue.py @@ -0,0 +1,75 @@ +"""admin 广告收益报表:按 用户/日期/广告类型/应用/代码位 聚合 展示条数 / 收益 / 金币。 + +任意已登录 admin 可看(只读,不涉及资金操作)。聚合逻辑在 app/admin/repositories/ad_revenue.py。 +""" +from __future__ import annotations + +from datetime import date as _date +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.admin.deps import AdminDb, get_current_admin +from app.admin.repositories import ad_revenue +from app.admin.schemas.ad_revenue import AdRevenueDaily, AdRevenueReportOut, AdRevenueRow +from app.core.rewards import cn_today + +router = APIRouter( + prefix="/admin/api/ad-revenue-report", + tags=["admin-ad-revenue-report"], + dependencies=[Depends(get_current_admin)], +) + +# 区间最大跨度(天);超出拒绝,避免审计页一次拉过多天(逐日审计 + 大查询)拖垮接口。 +_MAX_RANGE_DAYS = 92 + + +def _parse_day(value: str | None, *, field: str, default: _date) -> _date: + if value is None: + return default + try: + return _date.fromisoformat(value) + except ValueError as e: + raise HTTPException(status_code=422, detail=f"{field} 需为 YYYY-MM-DD") from e + + +@router.get("", response_model=AdRevenueReportOut, summary="广告收益报表(按 日期区间/用户/类型/应用/代码位 聚合)") +def get_ad_revenue_report( + db: AdminDb, + date_from: Annotated[str | None, Query(description="起始日 北京时间 YYYY-MM-DD,默认今天")] = None, + date_to: Annotated[str | None, Query(description="结束日 北京时间 YYYY-MM-DD,闭区间,默认=date_from")] = None, + user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None, + ad_type: Annotated[ + str | None, + Query(description="reward_video / feed / draw;不传=全部类型"), + ] = None, + granularity: Annotated[ + str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day") + ] = "day", + limit: Annotated[int, Query(ge=1, le=1000)] = 500, +) -> AdRevenueReportOut: + today = cn_today() + d_from = _parse_day(date_from, field="date_from", default=today) + d_to = _parse_day(date_to, field="date_to", default=d_from) + if d_to < d_from: + raise HTTPException(status_code=422, detail="date_to 不能早于 date_from") + if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS: + raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS} 天") + + result = ad_revenue.ad_revenue_report( + db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), + user_id=user_id, ad_type=ad_type, granularity=granularity, limit=limit, + ) + return AdRevenueReportOut( + date_from=d_from.isoformat(), + date_to=d_to.isoformat(), + daily=[AdRevenueDaily(**d) for d in result["daily"]], + total=result["total"], + truncated=result["truncated"], + total_impressions=result["total_impressions"], + total_revenue_yuan=result["total_revenue_yuan"], + total_expected_coin=result["total_expected_coin"], + total_actual_coin=result["total_actual_coin"], + mismatch_count=result["mismatch_count"], + items=[AdRevenueRow(**r) for r in result["items"]], + ) diff --git a/app/admin/routers/feedback.py b/app/admin/routers/feedback.py index 51a2a29..249378d 100644 --- a/app/admin/routers/feedback.py +++ b/app/admin/routers/feedback.py @@ -34,7 +34,7 @@ def list_feedbacks( limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[FeedbackOut]: - items, next_cursor = queries.list_feedbacks( + items, next_cursor, total = queries.list_feedbacks( db, status=status, user_id=user_id, @@ -47,7 +47,9 @@ def list_feedbacks( cursor=cursor, ) return CursorPage( - items=[FeedbackOut.model_validate(f) for f in items], next_cursor=next_cursor, + items=[FeedbackOut.model_validate(f) for f in items], + next_cursor=next_cursor, + total=total, ) diff --git a/app/admin/schemas/ad_revenue.py b/app/admin/schemas/ad_revenue.py new file mode 100644 index 0000000..beeb132 --- /dev/null +++ b/app/admin/schemas/ad_revenue.py @@ -0,0 +1,90 @@ +"""广告收益报表 schemas。 + +按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合的只读报表:展示条数、收益(元)、金币、来源。 +字段 snake_case;收益按元(float),金币按整数。 +""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class AdRevenueImpression(BaseModel): + """聚合行下钻的单条**展示**明细(每次广告展示一条,展开该组时展示)。""" + + id: int = Field(..., description="ad_ecpm_record 主键") + created_at: datetime + ecpm: str = Field(..., description="本次展示 eCPM 原始值(分/千次展示)") + revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000") + adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…)") + slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID)") + + +class AdRevenueRecord(BaseModel): + """聚合行下钻的单条发奖复算明细(与金币审计同源,展开该组时展示)。""" + + record_id: int + created_at: datetime + status: str = Field(..., description="granted / capped / ecpm_missing") + ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示)") + ecpm_factor: float | None = Field(None, description="因子1(eCPM 档);非 granted 为空") + units: int = Field(..., description="折算份数:激励视频恒 1;信息流 = 满 10 秒份数") + lt_index_start: int | None = Field(None, description="本条占用「账号累计第几份」的起") + lt_index_end: int | None = Field(None, description="本条占用「账号累计第几份」的止;激励视频 = 起") + lt_factor_start: float | None = Field(None, description="因子2(LT)起值") + lt_factor_end: float | None = Field(None, description="因子2(LT)止值;激励视频 = 起") + expected_coin: int = Field(..., description="按公式复算应发金币") + actual_coin: int = Field(..., description="实际入账金币") + matched: bool = Field(..., description="复算与实发是否一致") + + +class AdRevenueDaily(BaseModel): + """按日期汇总的一天(供前端按天趋势图;全量,不受 limit 影响)。""" + + date: str = Field(..., description="北京时间 YYYY-MM-DD") + impressions: int = Field(..., description="当天展示条数合计") + revenue_yuan: float = Field(..., description="当天预估收益合计(元)") + expected_coin: int = Field(..., description="当天应发金币合计") + actual_coin: int = Field(..., description="当天实发金币合计") + + +class AdRevenueRow(BaseModel): + """一个聚合组(report_date × user × ad_type × app_env × our_code_id)的汇总。""" + + report_date: str = Field(..., description="该组所属日期(北京时间 YYYY-MM-DD)") + user_id: int + ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)") + app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空") + our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空") + hour: int | None = Field(None, description="北京时间小时 0–23(granularity=hour 时有值;按天为 null)") + impressions: int = Field(..., description="展示条数(每条广告展示一条;轮播每条各计一次)") + revenue_yuan: float = Field(..., description="收益(元)= Σ(eCPM元 ÷ 1000);测试应用多为 0") + expected_coin: int = Field(..., description="应发金币(按公式复算,与金币审计同源)") + actual_coin: int = Field(..., description="实发金币(实际入账,按现发奖算法)") + matched: bool = Field(..., description="该组应发==实发(组内任一条不符则 false)") + adns: list[str] = Field(default_factory=list, description="实际填充的底层 ADN 子渠道集合(如 pangle/gdt)") + impression_records: list[AdRevenueImpression] = Field( + default_factory=list, + description="该组逐条展示明细(时间/eCPM/收益/adn);展开下钻用,无发奖也有(只要有展示)", + ) + records: list[AdRevenueRecord] = Field( + default_factory=list, + description="该组逐条发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);展开下钻用,纯展示无发奖记录的组为空", + ) + + +class AdRevenueReportOut(BaseModel): + """报表响应:全量统计 + 按天趋势 + 聚合明细。""" + + date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)") + date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)") + daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)") + total: int = Field(..., description="聚合组总数(全量,不受 limit 影响)") + truncated: bool = Field(..., description="明细是否被 limit 截断") + total_impressions: int = Field(..., description="全量展示条数合计") + total_revenue_yuan: float = Field(..., description="全量收益合计(元)") + total_expected_coin: int = Field(..., description="全量应发金币合计") + total_actual_coin: int = Field(..., description="全量实发金币合计") + mismatch_count: int = Field(..., description="应发≠实发的组数(=0 说明全部按公式发放)") + items: list[AdRevenueRow] = Field(..., description="聚合明细(按 用户→类型→代码位 排序)") diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 0c7e351..9508061 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -32,6 +32,8 @@ from app.schemas.ad import ( FeedRewardIn, FeedRewardOut, PangleCallbackOut, + RewardNoShowIn, + RewardNoShowOut, TestGrantIn, TestGrantOut, WatchReportIn, @@ -242,10 +244,12 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm ad_type=payload.ad_type, ecpm_raw=payload.ecpm, ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, + app_env=payload.app_env, our_code_id=payload.our_code_id, ) logger.info( - "ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s", + "ad ecpm report user_id=%d type=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s", user.id, payload.ad_type, payload.ad_session_id, payload.ecpm, payload.adn, payload.slot_id, + payload.app_env, payload.our_code_id, ) return EcpmReportOut(ok=True) @@ -359,6 +363,9 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, + app_env=payload.app_env, + our_code_id=payload.our_code_id, + aborted=payload.aborted, ) logger.info( "feed ad reward user_id=%d event=%s status=%s units=%d coin=%d", @@ -371,3 +378,30 @@ def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> Feed unit_count=rec.unit_count, daily_limit=rewards.get_ad_daily_limit(db), ) + + +@router.post( + "/reward-noshow", + response_model=RewardNoShowOut, + summary="激励视频提前关闭/未发奖留痕", + dependencies=[Depends(rate_limit(120, 60, "ad-reward-noshow"))], +) +def reward_noshow(payload: RewardNoShowIn, user: CurrentUser, db: DbSession) -> RewardNoShowOut: + """激励视频展示了但用户提前关/跳过、未触发 S2S 发奖时,客户端 best-effort 上报一条留痕, + 让广告收益报表能呈现「有展示、没发金币」的原因。不发金币;同一 session 已发奖则跳过。 + """ + rec = crud_ad.record_reward_noshow( + db, + user.id, + ad_session_id=payload.ad_session_id, + ecpm=payload.ecpm, + adn=payload.adn, + slot_id=payload.slot_id, + app_env=payload.app_env, + our_code_id=payload.our_code_id, + ) + logger.info( + "ad reward noshow user_id=%d session=%s watched=%ds -> status=%s", + user.id, payload.ad_session_id, payload.watched_seconds, rec.status, + ) + return RewardNoShowOut(ok=True, status=rec.status) diff --git a/app/api/v1/feedback.py b/app/api/v1/feedback.py index c085780..e59fb54 100644 --- a/app/api/v1/feedback.py +++ b/app/api/v1/feedback.py @@ -21,7 +21,7 @@ logger = logging.getLogger("shagua.feedback") router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"]) _MAX_IMAGES = 6 -_CONTENT_MAX = 2000 +_CONTENT_MAX = 200 _CONTACT_MAX = 128 diff --git a/app/models/ad_ecpm.py b/app/models/ad_ecpm.py index 0f46b51..c1aa029 100644 --- a/app/models/ad_ecpm.py +++ b/app/models/ad_ecpm.py @@ -35,6 +35,11 @@ class AdEcpmRecord(Base): adn: Mapped[str | None] = mapped_column(String(32), nullable=True) # 实际展示用的代码位(底层 mediation rit,非客户端配置位) slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 我们的穿山甲应用环境:prod(傻瓜比价正式应用) / test(测试应用)。客户端按 AdConfig.useProductionApp 上报。 + # 与底层 adn 不同:这是「我们用的是哪个 App」,adn 是「聚合后实际填充的子渠道」。旧数据为 NULL。 + app_env: Mapped[str | None] = mapped_column(String(16), nullable=True) + # 我们在穿山甲后台配置的代码位 ID(AdConfig.feedCodeId/rewardCodeId 返回的 104xxx,**非** slot_id 的底层 rit)。旧数据为 NULL。 + our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True) # 客户端上报的 eCPM 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存) ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) # 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较) diff --git a/app/models/ad_feed_reward.py b/app/models/ad_feed_reward.py index 99f261e..fb1ff82 100644 --- a/app/models/ad_feed_reward.py +++ b/app/models/ad_feed_reward.py @@ -30,6 +30,9 @@ class AdFeedRewardRecord(Base): ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) adn: Mapped[str | None] = mapped_column(String(32), nullable=True) slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx,由客户端 feed-reward 上报带上。旧数据为 NULL。 + app_env: Mapped[str | None] = mapped_column(String(16), nullable=True) + our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True) coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0) status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted") diff --git a/app/models/ad_reward.py b/app/models/ad_reward.py index c9e8e9f..d45be76 100644 --- a/app/models/ad_reward.py +++ b/app/models/ad_reward.py @@ -33,6 +33,10 @@ class AdRewardRecord(Base): ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报) ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True) + # 来源(广告收益报表用):我们的应用环境 prod/test + 我们配置的代码位 104xxx。 + # S2S 回调本身不带这俩,发奖时按 ad_session_id 匹配 ad_ecpm_record 回填(查不到为 NULL)。 + app_env: Mapped[str | None] = mapped_column(String(16), nullable=True) + our_code_id: Mapped[str | None] = mapped_column(String(64), nullable=True) # 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数 reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) # 穿山甲上报的奖励名(参考,不作发奖依据) diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py index 9841ae9..adfc7fd 100644 --- a/app/repositories/ad_ecpm.py +++ b/app/repositories/ad_ecpm.py @@ -23,8 +23,14 @@ def create_ecpm_record( ad_session_id: str | None = None, adn: str | None = None, slot_id: str | None = None, + app_env: str | None = None, + our_code_id: str | None = None, ) -> AdEcpmRecord: - """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。""" + """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。 + + app_env(prod/test)与 our_code_id(我们后台配置的 104xxx 代码位)供广告收益报表按 + 应用/代码位聚合;与 adn(实际填充子渠道)/slot_id(底层 rit)是两组不同口径。 + """ if ad_session_id: existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id) if existing is not None: @@ -35,6 +41,8 @@ def create_ecpm_record( ad_session_id=ad_session_id, adn=adn, slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, ecpm_raw=ecpm_raw, report_date=cn_today().isoformat(), ) diff --git a/app/repositories/ad_feed_reward.py b/app/repositories/ad_feed_reward.py index c2e367c..64fe277 100644 --- a/app/repositories/ad_feed_reward.py +++ b/app/repositories/ad_feed_reward.py @@ -69,12 +69,19 @@ def grant_feed_reward( ad_session_id: str | None = None, adn: str | None = None, slot_id: str | None = None, + app_env: str | None = None, + our_code_id: str | None = None, + aborted: bool = False, ) -> AdFeedRewardRecord: - """完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。 + """比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。 - 一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS - 限单事件份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额; - 叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。 + 发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份)。 + - aborted=True(用户中途 ✕ 关闭):整场不发,记 status='closed_early' 留痕(原因可查)。 + - 总时长不足 10 秒(unit_count==0):记 status='too_short' 不发。 + - 命中当日条数上限:记 status='capped' 不发。 + duration_seconds 是整场累计秒数。一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 + FEED_MAX_DURATION_SECONDS 限单场份数,eCPM 在 rewards.calculate_ad_reward_coin 内钳到 + AD_ECPM_MAX_FEN 限单份金额;叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间。 """ existing = _find_by_event(db, client_event_id) if existing is not None: @@ -85,6 +92,25 @@ def grant_feed_reward( safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS)) unit_count = safe_duration // FEED_REWARD_UNIT_SECONDS + # 用户中途关闭广告:整场不发(全程不关才发),留一条 closed_early 记录原因。优先级最高。 + if aborted: + rec = AdFeedRewardRecord( + client_event_id=client_event_id, + user_id=user_id, + reward_date=today, + duration_seconds=safe_duration, + unit_count=unit_count, + ad_session_id=ad_session_id, + ecpm_raw=ecpm, + adn=adn, + slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, + coin=0, + status="closed_early", + ) + return _commit_record(db, rec, client_event_id) + if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db): rec = AdFeedRewardRecord( client_event_id=client_event_id, @@ -96,11 +122,32 @@ def grant_feed_reward( ecpm_raw=ecpm, adn=adn, slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, coin=0, status="capped", ) return _commit_record(db, rec, client_event_id) + # 整场总时长不足 10 秒,凑不满一份 → 不发,记 too_short 留痕。 + if unit_count == 0: + rec = AdFeedRewardRecord( + client_event_id=client_event_id, + user_id=user_id, + reward_date=today, + duration_seconds=safe_duration, + unit_count=0, + ad_session_id=ad_session_id, + ecpm_raw=ecpm, + adn=adn, + slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, + coin=0, + status="too_short", + ) + return _commit_record(db, rec, client_event_id) + coin = _unit_reward_total(db, user_id, ecpm, unit_count) if coin > 0: crud_wallet.grant_coins( @@ -118,6 +165,8 @@ def grant_feed_reward( ecpm_raw=ecpm, adn=adn, slot_id=slot_id, + app_env=app_env, + our_code_id=our_code_id, coin=coin, status="granted", ) diff --git a/app/repositories/ad_reward.py b/app/repositories/ad_reward.py index 7c433d5..f9fb16f 100644 --- a/app/repositories/ad_reward.py +++ b/app/repositories/ad_reward.py @@ -90,6 +90,16 @@ def grant_ad_reward( today = cn_today().isoformat() + # 按 ad_session_id 匹配客户端 eCPM 上报:既用于缺 eCPM 时回退取值,也把「来源」 + # (我们的应用 app_env + 我们配置的代码位 our_code_id)回填到发奖记录,供广告收益报表聚合。 + # S2S 回调本身不带这俩;查不到(未上报 eCPM)则留空。 + ecpm_rec = ( + crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id) + if ad_session_id else None + ) + src_app_env = ecpm_rec.app_env if ecpm_rec is not None else None + src_code_id = ecpm_rec.our_code_id if ecpm_rec is not None else None + # #3 每日上限:当前产品只保留发奖次数上限(默认 500 次)。旧的观看时长闸保留字段, # 但 DAILY_AD_WATCH_SECONDS_LIMIT=0 时视为停用,不能命中 capped。 over_time = ( @@ -102,19 +112,18 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=0, status="capped", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, + app_env=src_app_env, our_code_id=src_code_id, ) return _commit_record(db, rec, trans_id) - ecpm_raw = ecpm - if not ecpm_raw and ad_session_id: - ecpm_rec = crud_ecpm.find_by_session(db, user_id=user_id, ad_session_id=ad_session_id) - ecpm_raw = ecpm_rec.ecpm_raw if ecpm_rec is not None else None + ecpm_raw = ecpm or (ecpm_rec.ecpm_raw if ecpm_rec is not None else None) if not ecpm_raw: rec = AdRewardRecord( trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None, + app_env=src_app_env, our_code_id=src_code_id, ) return _commit_record(db, rec, trans_id) @@ -131,6 +140,55 @@ def grant_ad_reward( trans_id=trans_id, user_id=user_id, coin=coin, status="granted", reward_date=today, reward_name=reward_name, raw=raw, reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_raw, + app_env=src_app_env, our_code_id=src_code_id, + ) + return _commit_record(db, rec, trans_id) + + +def record_reward_noshow( + db: Session, + user_id: int, + *, + ad_session_id: str, + ecpm: str | None = None, + adn: str | None = None, + slot_id: str | None = None, + app_env: str | None = None, + our_code_id: str | None = None, + reward_scene: str = "reward_video", +) -> AdRewardRecord: + """客户端上报「激励视频展示了但用户提前关/跳过、未触发发奖」,落一条 coin=0 status='closed_early' + 记录,供广告收益报表把「不发金币的原因」也呈现出来(只留痕,不发币)。 + + 幂等键 trans_id = 'noreward:{ad_session_id}'(每次展示唯一)。若同一 ad_session_id 已有 granted + 记录(S2S 已发奖,正常路径),说明用户其实看完了 → 跳过不写、原样返回那条,避免与正常发奖重复。 + app_env/our_code_id 由客户端直接带上(它本就持有);查不到 user 抛 UnknownUserError。 + """ + if db.get(User, user_id) is None: + raise UnknownUserError + # 同一次展示已正常发奖 → 不再记 closed_early(防与 S2S granted 重复) + granted = db.execute( + select(AdRewardRecord) + .where( + AdRewardRecord.user_id == user_id, + AdRewardRecord.ad_session_id == ad_session_id, + AdRewardRecord.status == "granted", + ) + .limit(1) + ).scalar_one_or_none() + if granted is not None: + return granted + + trans_id = f"noreward:{ad_session_id}" + existing = _find_by_trans(db, trans_id) + if existing is not None: + return existing + + rec = AdRewardRecord( + trans_id=trans_id, user_id=user_id, coin=0, status="closed_early", + reward_date=cn_today().isoformat(), reward_name=None, raw=None, + reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, + app_env=app_env, our_code_id=our_code_id, ) return _commit_record(db, rec, trans_id) diff --git a/app/schemas/ad.py b/app/schemas/ad.py index a1cb7e0..ae56aab 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -57,6 +57,12 @@ class EcpmReportIn(BaseModel): ) adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle") slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)") + app_env: str | None = Field( + None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" + ) + our_code_id: str | None = Field( + None, max_length=64, description="我们后台配置的代码位 ID(AdConfig 的 104xxx,非底层 rit)" + ) class EcpmReportOut(BaseModel): @@ -115,24 +121,62 @@ class TestGrantOut(BaseModel): class FeedRewardIn(BaseModel): - """信息流广告完成后结算奖励。 + """比价/领券一整场信息流(轮播多条)结束后结算奖励。 - 每展示满 10 秒累计一份奖励,视频完成后一次性入账。client_event_id 用于客户端超时重试幂等。 + 规则:全程不关广告才发,金额按整场**总观看时长**折份(每 10 秒 1 份)。client_event_id 用于 + 客户端超时重试幂等。中途被用户关闭时传 aborted=True,整场不发(只记 closed_early)。 """ client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id") ad_session_id: str | None = Field( None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id" ) - ecpm: str = Field(..., description="本条信息流广告 eCPM,按分/千次展示处理(SDK getEcpm 原值,非元)") - duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数") + ecpm: str = Field(..., description="本场信息流 eCPM(代表值,按分/千次展示处理;SDK getEcpm 原值,非元)") + duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)") adn: str | None = Field(None, description="实际投放 ADN") slot_id: str | None = Field(None, description="实际展示代码位") + app_env: str | None = Field( + None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" + ) + our_code_id: str | None = Field( + None, max_length=64, description="我们后台配置的代码位 ID(AdConfig 的 104xxx,非底层 rit)" + ) + aborted: bool = Field( + False, description="用户中途 ✕ 关闭广告(未走完比价):整场不发,记 closed_early" + ) class FeedRewardOut(BaseModel): granted: bool = Field(..., description="本次是否入账。达上限时 false") - status: str = Field(..., description="granted / capped") + status: str = Field(..., description="granted / capped / too_short / closed_early") coin: int = Field(..., description="本次发放金币") unit_count: int = Field(..., description="按 10 秒折算出的奖励份数") daily_limit: int = Field(..., description="每日信息流展示次数上限") + + +class RewardNoShowIn(BaseModel): + """激励视频展示了但用户提前关闭/跳过、未触发发奖——上报留痕(不发金币)。 + + 供广告收益报表把「有展示、没发金币」的原因也记录下来。user_id 由 JWT 取,不在 body。 + """ + + ad_session_id: str = Field( + ..., min_length=8, max_length=64, description="本次展示会话 id(与 ecpm 上报、S2S extra 一致)" + ) + watched_seconds: int = Field(0, ge=0, description="关闭前已观看秒数(仅留痕参考,不入库)") + ecpm: str | None = Field(None, description="本次展示 eCPM(分/千次,SDK getEcpm 原值);可空") + adn: str | None = Field(None, description="实际投放 ADN") + slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)") + app_env: str | None = Field( + None, max_length=16, description="我们的穿山甲应用环境:prod / test" + ) + our_code_id: str | None = Field( + None, max_length=64, description="我们后台配置的代码位 ID(104xxx)" + ) + + +class RewardNoShowOut(BaseModel): + ok: bool = Field(..., description="是否处理成功(落库或幂等命中)") + status: str = Field( + ..., description="closed_early(已留痕) / granted(该次其实已发奖,跳过未写)" + ) diff --git a/docs/api/README.md b/docs/api/README.md index 79bea57..25e8ec6 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -63,6 +63,7 @@ | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) | | 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.md) | | 35a | `POST /api/v1/ad/feed-reward` | Bearer | [详情](./ad-feed-reward.md) | +| 35b | `POST /api/v1/ad/reward-noshow` | Bearer | [详情](./ad-reward-noshow.md)(激励视频提前关闭/未发奖留痕,只记原因不发币) | | **用户资料**(前缀 `/api/v1/user`) ||| | 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) | | 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.md) | @@ -104,6 +105,7 @@ | A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) | | A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./admin-marquee-seeds.md) | | A28 | `GET /admin/api/ad-coin-audit` | admin | [详情](./admin-ad-coin-audit.md)(看广告金币公式复算对账,只读) | +| A29 | `GET /admin/api/ad-revenue-report` | admin | [详情](./admin-ad-revenue-report.md)(广告收益报表:按用户/日期/类型/应用/代码位 聚合 条数/收益/金币,只读) | | - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) | > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link` 的 `sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 diff --git a/docs/api/ad-ecpm-report.md b/docs/api/ad-ecpm-report.md index b29fefb..6359b00 100644 --- a/docs/api/ad-ecpm-report.md +++ b/docs/api/ad-ecpm-report.md @@ -7,11 +7,13 @@ | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| -| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 | -| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 | +| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `feed`(信息流) / `draw`(历史 Draw 信息流) 等 | +| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;激励视频与穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配。**信息流轮播每条展示用各自独立 id**(不复用比价会话 id,否则 `uq_ad_ecpm_record_session` 去重只留一条) | | `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 | -| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` | +| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle`(聚合后实际填充的子渠道) | | `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) | +| `app_env` | str\|null | 否 | **我们的**穿山甲应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) | +| `our_code_id` | str\|null | 否 | **我们后台配置的**代码位 ID(`AdConfig` 的 104xxx,**非** `slot_id` 的底层 rit) | `user_id` 不在 body 里——由 JWT 取(Bearer),防伪造。 @@ -23,7 +25,7 @@ | `ok` | bool | 落库即 `true` | ## 说明 -客户端在广告**展示后**(`onAdShow` 读 `getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。 +客户端在广告**展示后**(`onAdShow` 读 `getShowEcpm()`)调用,把本次展示的 eCPM 落库做**内部收益统计/对账**。激励视频每次展示上报一条;**信息流轮播每条展示各上报一条**(每条独立 `ad_session_id`),作为「广告收益报表」展示条数/收益的数据源(见 [admin-ad-revenue-report](./admin-ad-revenue-report.md))。`app_env` + `our_code_id` 供报表按「我们的应用 / 我们配置的代码位」聚合,与 `adn`/`slot_id`(底层填充渠道/rit)是两组不同口径。 - 普通激励视频发奖会先用 S2S 回调自带 `ecpm`;若缺失,再按 `ad_session_id` 读取本接口上报的 eCPM;两边都没有则不发并记录异常。 - **best-effort**:客户端 fire-and-forget,但普通激励视频若 S2S 缺 eCPM,这条上报会成为发奖依据。 diff --git a/docs/api/ad-feed-reward.md b/docs/api/ad-feed-reward.md index 0510a0f..23516a7 100644 --- a/docs/api/ad-feed-reward.md +++ b/docs/api/ad-feed-reward.md @@ -1,6 +1,6 @@ # POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币 -点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账。 +点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发。 ## 鉴权 @@ -12,24 +12,27 @@ |---|---|---:|---| | `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | | `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 | -| `ecpm` | string | 是 | 本条信息流广告 eCPM(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | -| `duration_seconds` | int | 是 | 实际展示/播放秒数 | -| `adn` | string\|null | 否 | 实际投放 ADN | -| `slot_id` | string\|null | 否 | 实际展示代码位 | +| `ecpm` | string | 是 | 本场信息流 eCPM 代表值(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | +| `duration_seconds` | int | 是 | **整场累计观看秒数**(轮播各条相加) | +| `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充的子渠道) | +| `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) | +| `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) | +| `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit);供广告收益报表按代码位聚合金币 | +| `aborted` | bool | 否 | 用户中途 ✕ 关闭广告(未走完比价):整场不发,仅记 `closed_early`。默认 `false` | ## 响应 | 字段 | 类型 | 说明 | |---|---|---| -| `granted` | bool | 本次是否入账;达上限时为 `false` | -| `status` | string | `granted` / `capped` | -| `coin` | int | 本次发放金币 | +| `granted` | bool | 本次是否入账;未发(任一非 granted 状态)时为 `false` | +| `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途关闭) | +| `coin` | int | 本次发放金币;非 granted 为 0 | | `unit_count` | int | 按 10 秒折算出的奖励份数 | | `daily_limit` | int | 每日信息流展示次数上限,默认 500 | ## 计算口径 -- 奖励份数:`duration_seconds // 10`。 +- 奖励份数:`整场总时长 // 10`。 - 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币。 - eCPM 档:`0-100=0.1`,`101-200=0.3`,`201-400=0.4`,`>400=0.6`。 - LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`。 diff --git a/docs/api/ad-reward-noshow.md b/docs/api/ad-reward-noshow.md new file mode 100644 index 0000000..a36b69b --- /dev/null +++ b/docs/api/ad-reward-noshow.md @@ -0,0 +1,41 @@ +# POST /api/v1/ad/reward-noshow — 激励视频提前关闭/未发奖留痕 + +激励视频**展示了但用户提前关闭/跳过、未触发 S2S 发奖**时,客户端 best-effort 上报一条留痕记录, +让运营后台「广告数据」能呈现「有展示、没发金币」的原因。**不发金币**。 + +## 鉴权 + +需要 Bearer token。`user_id` 由 JWT 取,不在 body。 + +## 请求体 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---:|---| +| `ad_session_id` | string | 是 | 本次展示会话 id(与 eCPM 上报、S2S `extra.ad_session_id` 一致),8-64 字符 | +| `watched_seconds` | int | 否 | 关闭前已观看秒数(仅留痕参考,**不入库**)。默认 0 | +| `ecpm` | string\|null | 否 | 本次展示 eCPM(穿山甲 getEcpm 原值,分/千次展示) | +| `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充子渠道) | +| `slot_id` | string\|null | 否 | 实际展示代码位(底层 mediation rit) | +| `app_env` | string\|null | 否 | **我们的**应用环境:`prod`(傻瓜比价正式) / `test`(测试应用) | +| `our_code_id` | string\|null | 否 | **我们后台配置的**代码位 ID(104xxx,非底层 rit) | + +## 响应 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `ok` | bool | 是否处理成功(落库或幂等命中) | +| `status` | string | `closed_early`(已留痕) / `granted`(该次其实已发奖,跳过未写) | + +## 说明 + +- 在 `onAdClose` 时若本次**未触发发奖**(无 `onRewardVerify`/`onRewardArrived`)调用。 +- **幂等**:写入 `ad_reward_record`,`trans_id = noreward:{ad_session_id}`(每次展示唯一),重试不重复。 +- **防与正常发奖重复**:若同一 `ad_session_id` 已有 `status='granted'` 记录(S2S 已发,说明用户其实看完了), + 则跳过不写,返回 `status='granted'`。 +- best-effort:客户端 fire-and-forget,失败只 log,不影响主流程。 +- 信息流的「用户中途关闭」走 [ad-feed-reward](./ad-feed-reward.md) 的 `aborted=true`(记 `closed_early`),不走本接口。 + +## 数据写入 + +- `ad_reward_record` 新增一行(`coin=0`、`status='closed_early'`、`reward_scene='reward_video'`)。 +- **不**写 `coin_account` / `coin_transaction`(不发币)。 diff --git a/docs/api/admin-ad-revenue-report.md b/docs/api/admin-ad-revenue-report.md new file mode 100644 index 0000000..bccfd8f --- /dev/null +++ b/docs/api/admin-ad-revenue-report.md @@ -0,0 +1,108 @@ +# Admin 广告收益报表 + +> 所属:Admin 组(前缀 `/admin/api/ad-revenue-report`) | 鉴权:Admin Bearer(任意已登录 admin,只读) | [← 返回 API 索引](./README.md) + +按 **用户 × 日期 × 广告类型 × 我们的应用 × 我们的代码位** 聚合,回答「每个用户某天、每类广告(激励视频 / 信息流 / 历史 Draw)分别**看了多少条**、**收益多少**、按现算法**发了多少金币**、广告来自**哪个应用的哪个代码位**」。**纯只读**,不发币、不改数据,也**不改发奖逻辑**。 + +相关表:[ad_ecpm_record](../database/ad_ecpm_record.md)、[ad_reward_record](../database/ad_reward_record.md)、[ad_feed_reward_record](../database/ad_feed_reward_record.md)。 + +## 数据来源(三流合并,聚合键 = user × ad_type × app_env × our_code_id) + +| 指标 | 来源表 | 口径 | +|---|---|---| +| 展示条数 `impressions` | `ad_ecpm_record` | 每行 = 客户端一次广告展示。激励视频每次展示上报一条;**信息流轮播每条展示各上报一条**(每条独立 `ad_session_id`) | +| 收益 `revenue_yuan` | `ad_ecpm_record` | `Σ(eCPM元 ÷ 1000)`,即每条展示预估收益累加(eCPM 原值是分,÷100 转元;÷1000 是每千次→单次)。**预估口径,非结算;测试应用多为 0** | +| 应发/实发金币 `expected_coin`/`actual_coin` | `ad_reward_record`(reward_video)+ `ad_feed_reward_record`(feed) | **复用金币审计逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,不另写公式),按同维度求和;`matched = 应发==实发`。**只读复算,不改发奖** | +| 来源应用/代码位 `app_env`/`our_code_id` | 上述各表回填 | `prod`(傻瓜比价)/`test`(测试);代码位是**我们后台配的 104xxx**,非底层 rit | +| 底层渠道 `adns` | `ad_ecpm_record` | 实际填充的 ADN 子渠道集合(pangle/gdt/...),附加参考 | + +展示与金币来自不同表,做**并集**:有展示无金币(用户中途关、未达发奖)、有金币无展示(未上报 eCPM)各自成行。 + +## GET /admin/api/ad-revenue-report — 聚合报表 + +- 入参(均 query,可选): + | 参数 | 类型 | 默认 | 说明 | + |---|---|---|---| + | `date_from` | string | 今天 | 起始日 北京时间 `YYYY-MM-DD` | + | `date_to` | string | =`date_from` | 结束日 北京时间 `YYYY-MM-DD`,**闭区间**;单日时与 `date_from` 相同 | + | `user_id` | int | 全部 | 只看某用户;不传=所有用户 | + | `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 | + | `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** | + | `limit` | int(1~1000) | 500 | **展示**明细组数(截断;`total`/`total_*`/`daily` 按全量统计不受影响) | + + 约束:`date_to` 不早于 `date_from`、区间最长 **92 天**、日期须 `YYYY-MM-DD`,否则 `422`。 + +- 出参 `200`:`AdRevenueReportOut` + | 字段 | 类型 | 说明 | + |---|---|---| + | `date_from` / `date_to` | string | 报表起止日期(闭区间) | + | `daily` | `AdRevenueDaily[]` | 按日期汇总序列(全量,供按天趋势图;不受 `limit` 影响) | + | `total` | int | 聚合组**总数**(全量,不受 `limit` 影响) | + | `truncated` | bool | 明细是否被 `limit` 截断 | + | `total_impressions` | int | 全量展示条数合计 | + | `total_revenue_yuan` | float | 全量收益合计(元) | + | `total_expected_coin` | int | 全量应发金币合计 | + | `total_actual_coin` | int | 全量实发金币合计 | + | `mismatch_count` | int | 应发≠实发的组数(=0 说明全部按公式发放) | + | `items` | `AdRevenueRow[]` | 聚合明细(按 日期→用户→类型→代码位 排序) | + +### AdRevenueDaily(`daily[]` — 按天趋势) +| 字段 | 类型 | 说明 | +|---|---|---| +| `date` | string | 北京时间 `YYYY-MM-DD` | +| `impressions` | int | 当天展示条数合计 | +| `revenue_yuan` | float | 当天预估收益合计(元) | +| `expected_coin` | int | 当天应发金币合计 | +| `actual_coin` | int | 当天实发金币合计 | + +### AdRevenueRow(`items[]`) +| 字段 | 类型 | 说明 | +|---|---|---| +| `report_date` | string | 该组所属日期 北京时间 `YYYY-MM-DD` | +| `user_id` | int | | +| `ad_type` | string | `reward_video` / `feed` / `draw` | +| `app_env` | string \| null | 我们的应用:`prod`(傻瓜比价)/`test`(测试);旧数据为空 | +| `our_code_id` | string \| null | 我们配置的代码位 104xxx;旧数据为空 | +| `hour` | int \| null | 北京时间小时 0–23(`granularity=hour` 时有值;按天为 null) | +| `impressions` | int | 展示条数 | +| `revenue_yuan` | float | 收益(元),预估口径 | +| `expected_coin` | int | 应发金币(公式复算,与金币审计同源) | +| `actual_coin` | int | 实发金币(实际入账) | +| `matched` | bool | 该组应发==实发(组内任一条不符则 false) | +| `adns` | string[] | 底层填充 ADN 子渠道集合 | +| `impression_records` | `AdRevenueImpression[]` | 该组**逐条展示明细**(前端展开下钻);只要有展示就非空 | +| `records` | `AdRevenueRecord[]` | 该组**逐条发奖复算明细**(前端展开下钻);纯展示无发奖的组为空 | + +### AdRevenueImpression(`items[].impression_records[]` — 展开「展示明细」) +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | ad_ecpm_record 主键 | +| `created_at` | datetime | | +| `ecpm` | string | 本次展示 eCPM 原始值(分/千次展示) | +| `revenue_yuan` | float | 本次展示预估收益(元)= eCPM元 ÷ 1000 | +| `adn` | string \| null | 实际填充 ADN 子渠道 | +| `slot_id` | string \| null | 底层 mediation rit(非我们配置的广告位 ID) | + +### AdRevenueRecord(`items[].records[]` — 展开「发奖明细」) +还原金币审计的逐条列,与发奖同一复算口径。 +| 字段 | 类型 | 说明 | +|---|---|---| +| `record_id` | int | 发奖记录主键 | +| `created_at` | datetime | | +| `status` | string | `granted` / `capped` / `ecpm_missing` | +| `ecpm` | string \| null | 本次采用的 eCPM 原始值 | +| `ecpm_factor` | float \| null | 因子1(eCPM 档);非 granted 为空 | +| `units` | int | 折算份数:激励视频恒 1;信息流 = 满 10 秒份数 | +| `lt_index_start` / `lt_index_end` | int \| null | 占用「账号累计第几份」的起止 | +| `lt_factor_start` / `lt_factor_end` | float \| null | 因子2(LT)起止值 | +| `expected_coin` | int | 应发金币 | +| `actual_coin` | int | 实发金币 | +| `matched` | bool | 该条复算与实发是否一致 | + +## 说明与局限 + +- **展示 vs 发奖分离**:信息流轮播一会话可展示多条(都计入 `impressions`),但发奖仍按现规则(一会话发一次),`coin` 不因展示条数变化——这是有意设计(用户中途关只记展示不发奖)。 +- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。 +- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。 +- **收益是预估**:基于客户端上报的 eCPM,非穿山甲后台结算值;以后台报表为结算权威。 +- **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。 diff --git a/docs/api/feedback.md b/docs/api/feedback.md index 259df11..e59e157 100644 --- a/docs/api/feedback.md +++ b/docs/api/feedback.md @@ -6,7 +6,7 @@ **multipart/form-data**: | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| -| `content` | string | ✓ | 反馈正文,**1-2000 字**(strip 后) | +| `content` | string | ✓ | 反馈正文,**1-200 字**(strip 后) | | `contact` | string | ✗ | 联系方式(微信/QQ/手机号),**≤128 字**。原型改版后客户端已不再采集、不传该字段(后端默认空串);保留字段兼容旧端 | | `images` | file[] | ✗ | 截图,**最多 6 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) | @@ -29,7 +29,7 @@ > 不返回上传的 image URL——这是给运营后台看的,客户端通常不需要。 ## 错误码 -- `400` 内容为空 / 内容超 2000 字 / 联系方式超 128 字 / 图片超 6 张 / 单图非法(空/过大/格式不对) +- `400` 内容为空 / 内容超 200 字 / 联系方式超 128 字 / 图片超 6 张 / 单图非法(空/过大/格式不对) - `401` 未带 token / token 无效或过期 / 用户被禁用 - `422` 缺 `content` 字段 diff --git a/docs/database/ad_ecpm_record.md b/docs/database/ad_ecpm_record.md index 127636f..61def90 100644 --- a/docs/database/ad_ecpm_record.md +++ b/docs/database/ad_ecpm_record.md @@ -7,17 +7,19 @@ ## 用在哪 / 增删改查 - **C(插入)**:`POST /ad/ecpm-report`(`create_ecpm_record`)。每次广告展示上报一条;best-effort,丢一两条不影响业务。鉴权接口已确保 user 存在。 - **U / D**:无。 -- **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助。当前无面向 C 端用户的读接口。 +- **R**:内部收益统计/对账(按 `(user_id, report_date)` 聚合);`count_today` 排查辅助;广告收益报表 [admin-ad-revenue-report](../api/admin-ad-revenue-report.md) 的展示条数/收益数据源(按 `app_env`/`our_code_id` 聚合)。当前无面向 C 端用户的读接口。 ## 字段 | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | -| `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `draw`(Draw 信息流);各类型各自上报,不强行统一代码位 | -| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频在 S2S 缺 `ecpm` 时匹配发奖 | -| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) | +| `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `feed`(信息流)/ `draw`(历史 Draw 信息流);各类型各自上报,不强行统一代码位 | +| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;激励视频用于 S2S 缺 `ecpm` 时匹配发奖。**信息流轮播每条展示用各自独立 id**(不复用比价会话 id,否则 UNIQUE 去重只留一条) | +| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`;聚合后实际填充的子渠道) | | `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) | +| `app_env` | String(16) | nullable | **我们的**穿山甲应用环境:`prod`(傻瓜比价正式)/`test`(测试应用);旧数据 NULL。广告收益报表按它聚合「来源应用」 | +| `our_code_id` | String(64) | nullable | **我们后台配置的**代码位 ID(`AdConfig` 的 104xxx,**非** `slot_id` 底层 rit);旧数据 NULL | | `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(穿山甲 getEcpm 原值,单位**分/千次展示**);后端 ÷100 转元参与金币公式 | | `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 | diff --git a/docs/database/ad_feed_reward_record.md b/docs/database/ad_feed_reward_record.md index 5ab9e94..99cccb4 100644 --- a/docs/database/ad_feed_reward_record.md +++ b/docs/database/ad_feed_reward_record.md @@ -11,13 +11,15 @@ | `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id | | `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` | -| `duration_seconds` | Integer | NOT NULL | 实际展示/播放秒数 | +| `duration_seconds` | Integer | NOT NULL | 整场比价累计观看秒数(轮播各条相加) | | `unit_count` | Integer | NOT NULL | `duration_seconds // 10` 得到的奖励份数 | | `ecpm_raw` | String(32) | NOT NULL | 客户端上报 eCPM 原始值 | -| `adn` | String(32) | nullable | 实际投放 ADN | -| `slot_id` | String(64) | nullable | 实际展示代码位 | -| `coin` | Integer | NOT NULL | 实发金币,`capped` 时为 0 | -| `status` | String(16) | NOT NULL | `granted` / `capped` | +| `adn` | String(32) | nullable | 实际投放 ADN(聚合后实际填充子渠道) | +| `slot_id` | String(64) | nullable | 实际展示代码位(底层 mediation rit) | +| `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试),客户端 feed-reward 上报;旧数据 NULL。广告收益报表金币侧按它聚合 | +| `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx,客户端上报;旧数据 NULL | +| `coin` | Integer | NOT NULL | 实发金币,非 `granted` 时为 0 | +| `status` | String(16) | NOT NULL | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途 ✕ 关闭,全程未看完) | | `created_at` | DateTime | index, NOT NULL | 创建时间 | ## 约束 diff --git a/docs/database/ad_reward_record.md b/docs/database/ad_reward_record.md index 380967c..07c08be 100644 --- a/docs/database/ad_reward_record.md +++ b/docs/database/ad_reward_record.md @@ -5,7 +5,7 @@ 每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、签到膨胀等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。 ## 用在哪 / 增删改查 -- **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward` 或场景业务处理)或 `POST /ad/test-grant`(本地联调)。普通激励视频三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'`、`coin=0`、不发币。否则按 eCPM 公式发币。 +- **C(插入)**:`POST /ad/pangle-callback`(穿山甲 S2S,经 SHA256 验签;`grant_ad_reward` 或场景业务处理)或 `POST /ad/test-grant`(本地联调)。普通激励视频三道闸:① 验签不过 → API 层 403,不进库;② `trans_id` 已存在 → 原样返回不重复发;③ **当日发奖次数(`DAILY_AD_REWARD_LIMIT`,默认 500)到顶** → 记一行 `status='capped'`、`coin=0`、不发币。否则按 eCPM 公式发币。另:`POST /ad/reward-noshow`(`record_reward_noshow`,Bearer)在用户提前关/未发奖时记一行 `status='closed_early'`、`coin=0` 留痕(同 session 已 granted 则跳过)。 - **U / D**:无。 - **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。 @@ -13,13 +13,15 @@ | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | |---|---|---|---| | `id` | Integer | PK, autoincrement | | -| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等) | +| `trans_id` | String(64) | UNIQUE, index, NOT NULL | 穿山甲交易号(幂等键)。**被 `coin_transaction.ref_id` 引用**(biz_type=reward_video/signin_boost 等)。`closed_early` 留痕记录无 S2S 交易号,用合成键 `noreward:{ad_session_id}` | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户(回调 media_extra 带回;不存在抛 UnknownUserError) | | `reward_scene` | String(32) | NOT NULL, default `reward_video` | 奖励场景:`reward_video` 普通激励视频;`signin_boost` 签到膨胀 | | `ad_session_id` | String(64) | index, nullable | 客户端广告会话 ID,来自 `extra.ad_session_id`;用于匹配 `ad_ecpm_record` | | `ecpm_raw` | String(32) | nullable | 本次发奖采用的 eCPM 原始值;可来自 S2S `ecpm` 或客户端上报 | -| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/业务不满足时为 0 | -| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `not_signed`/`already_boosted`/`last_day` | +| `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试);S2S 不带,发奖时按 `ad_session_id` 匹配 `ad_ecpm_record` 回填,查不到 NULL。广告收益报表金币侧按它聚合 | +| `our_code_id` | String(64) | nullable | 我们配置的代码位 104xxx(同上回填) | +| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/`closed_early`/业务不满足时为 0 | +| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `closed_early`(展示了但用户提前关/跳过,未发奖,客户端 reward-noshow 留痕)/ `not_signed`/`already_boosted`/`last_day` | | `reward_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) |