Compare commits

...

3 Commits

Author SHA1 Message Date
marco f97048ff56 fix(alembic): 合并多 head (store_mapping_jd_dl_invalid + ad_revenue) 修复 0.1.2 部署
PR #56 的 jd_deeplink_invalid 迁移与 ad_revenue 报表迁移从同一 branchpoint
分叉成双 head, 部署时 alembic upgrade head 报 "Multiple head revisions"。
加一个 no-op merge 节点统一 DAG, 两支迁移列不冲突。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 01:39:05 +08:00
marco f15ca74a22 feat(store-mapping): JD 缓存 deeplink 失效标记(对标淘宝) (#56)
Reviewed-on: #56
2026-06-16 01:25:57 +08:00
ouzhou f7d86011c1 feat(ad-revenue): admin 广告收益报表(按 用户/日期/类型/应用/代码位 聚合) (#54)
- 新增 GET /admin/api/ad-revenue-report:展示条数/收益 + 复用金币审计逐条复算做发奖对账
- ad_ecpm/ad_reward/ad_feed_reward 各加 app_env + our_code_id 两列(alembic 迁移)
- ecpm-report / feed-reward 接收并落库 app_env/our_code_id;激励发奖按 ad_session_id 回填
- ad_audit 抽出 audit_rows,报表与逐条审计复用同一复算口径
- 组级 matched 改「组内逐条全一致」,避免应发和==实发和的互相抵消掩盖错误
- list_feedbacks 改 offset 分页并返回 total(配合 admin 页码分页)
- 反馈正文上限 _CONTENT_MAX 2000→200
- 文档:新增 admin-ad-revenue-report,更新 ecpm/feed-reward/feedback 及对应 db docs

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

---------

Co-authored-by: OuYingJun1024 <1034284404@qq.com>
Reviewed-on: #54
Co-authored-by: ouzhou <ouzhou@wonderable.ai>
Co-committed-by: ouzhou <ouzhou@wonderable.ai>
2026-06-15 23:13:14 +08:00
36 changed files with 1093 additions and 69 deletions
@@ -0,0 +1,26 @@
"""merge store_mapping_jd_dl_invalid and ad_revenue heads (0.1.2)
Revision ID: 45047b5a884c
Revises: d4e68464761d, store_mapping_jd_dl_invalid
Create Date: 2026-06-16 01:38:26.273226
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '45047b5a884c'
down_revision: Union[str, Sequence[str], None] = ('d4e68464761d', 'store_mapping_jd_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
@@ -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")
@@ -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
@@ -0,0 +1,32 @@
"""store_mapping 加京东 deeplink 失效标记列 jd_deeplink_invalid_at
Revision ID: store_mapping_jd_dl_invalid
Revises: store_mapping_tb_dl_invalid
Create Date: 2026-06-15 00:00:00.000000
京东同淘宝: 缓存的店内搜索 deeplink 会失效(打开是"当前门店超出配送范围"页)。pricebot 比价撞到
失效页时回退正常搜店, 并 server→server 通知把该 storeId 的 deeplink 标记失效。本列记失效时刻
(NULL=有效); lookup 反查时过滤掉已失效的京东候选, 不再返回坏 deeplink。
与淘宝 taobao_deeplink_invalid_at 对称; 用时间戳而非布尔: 留痕可审计、可统计失效率, 不销毁原 deeplink。
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'store_mapping_jd_dl_invalid'
down_revision: Union[str, Sequence[str], None] = 'store_mapping_tb_dl_invalid'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.add_column(sa.Column('jd_deeplink_invalid_at', sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('store_mapping', schema=None) as batch_op:
batch_op.drop_column('jd_deeplink_invalid_at')
+2
View File
@@ -14,6 +14,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.admin.routers.ad_audit import router as ad_audit_router 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.admins import router as admins_router
from app.admin.routers.audit import router as audit_router from app.admin.routers.audit import router as audit_router
from app.admin.routers.auth import router as auth_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(audit_router)
admin_app.include_router(config_router) admin_app.include_router(config_router)
admin_app.include_router(ad_audit_router) admin_app.include_router(ad_audit_router)
admin_app.include_router(ad_revenue_router)
+25 -5
View File
@@ -67,6 +67,8 @@ def _reward_video_rows(
"scene": "reward_video", "scene": "reward_video",
"record_id": rec.id, "record_id": rec.id,
"user_id": rec.user_id, "user_id": rec.user_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at, "created_at": rec.created_at,
"status": rec.status, "status": rec.status,
"ecpm": rec.ecpm_raw, "ecpm": rec.ecpm_raw,
@@ -86,6 +88,8 @@ def _reward_video_rows(
"scene": "reward_video", "scene": "reward_video",
"record_id": rec.id, "record_id": rec.id,
"user_id": rec.user_id, "user_id": rec.user_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at, "created_at": rec.created_at,
"status": rec.status, "status": rec.status,
"ecpm": rec.ecpm_raw, "ecpm": rec.ecpm_raw,
@@ -150,6 +154,8 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"scene": "feed", "scene": "feed",
"record_id": rec.id, "record_id": rec.id,
"user_id": rec.user_id, "user_id": rec.user_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at, "created_at": rec.created_at,
"status": rec.status, "status": rec.status,
"ecpm": rec.ecpm_raw, "ecpm": rec.ecpm_raw,
@@ -168,6 +174,8 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
"scene": "feed", "scene": "feed",
"record_id": rec.id, "record_id": rec.id,
"user_id": rec.user_id, "user_id": rec.user_id,
"app_env": rec.app_env,
"our_code_id": rec.our_code_id,
"created_at": rec.created_at, "created_at": rec.created_at,
"status": rec.status, "status": rec.status,
"ecpm": rec.ecpm_raw, "ecpm": rec.ecpm_raw,
@@ -184,6 +192,22 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
return rows 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( def ad_coin_audit(
db: Session, db: Session,
*, *,
@@ -200,11 +224,7 @@ def ad_coin_audit(
影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。 影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。
份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。 份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。
""" """
rows: list[dict] = [] rows = audit_rows(db, date=date, user_id=user_id, scene=scene)
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.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True) rows.sort(key=lambda r: (r["created_at"], r["record_id"]), reverse=True)
total = len(rows) total = len(rows)
+239
View File
@@ -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 口径)→ 北京时间小时(023)。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,
}
+4 -10
View File
@@ -286,10 +286,11 @@ def list_feedbacks(
sort_order: str = "desc", sort_order: str = "desc",
limit: int = 20, limit: int = 20,
cursor: int | None = None, cursor: int | None = None,
) -> tuple[list[Feedback], int | None]: ) -> tuple[list[Feedback], int | None, int]:
"""反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。 """反馈工单列表。支持 状态 / 用户ID / 内容模糊 / 提交时间范围 筛选,按 id·提交时间排序。
**offset 分页**(cursor=offset):任意列排序下游标语义统一(同 [list_users]),代价是翻页期间 **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) stmt = select(Feedback)
if status: if status:
stmt = stmt.where(Feedback.status == status) stmt = stmt.where(Feedback.status == status)
@@ -306,14 +307,7 @@ def list_feedbacks(
sort_col = sort_cols.get(sort_by, Feedback.id) sort_col = sort_cols.get(sort_by, Feedback.id)
order_fn = asc if sort_order == "asc" else desc order_fn = asc if sort_order == "asc" else desc
id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id) id_order = asc(Feedback.id) if sort_order == "asc" else desc(Feedback.id)
stmt = stmt.order_by(order_fn(sort_col), id_order) return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
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
def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None: def get_withdraw_by_out_bill_no(db: Session, out_bill_no: str) -> WithdrawOrder | None:
+75
View File
@@ -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"]],
)
+4 -2
View File
@@ -34,7 +34,7 @@ def list_feedbacks(
limit: Annotated[int, Query(ge=1, le=100)] = 20, limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None, cursor: Annotated[int | None, Query()] = None,
) -> CursorPage[FeedbackOut]: ) -> CursorPage[FeedbackOut]:
items, next_cursor = queries.list_feedbacks( items, next_cursor, total = queries.list_feedbacks(
db, db,
status=status, status=status,
user_id=user_id, user_id=user_id,
@@ -47,7 +47,9 @@ def list_feedbacks(
cursor=cursor, cursor=cursor,
) )
return CursorPage( 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,
) )
+90
View File
@@ -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="北京时间小时 023(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="聚合明细(按 用户→类型→代码位 排序)")
+6 -3
View File
@@ -95,11 +95,14 @@ def invalidate_store_mapping(
x_internal_secret: Annotated[str | None, Header()] = None, x_internal_secret: Annotated[str | None, Header()] = None,
) -> StoreMappingInvalidateOut: ) -> StoreMappingInvalidateOut:
_check_secret(x_internal_secret) _check_secret(x_internal_secret)
if payload.platform != "taobao": if payload.platform == "taobao":
# 当前只接淘宝; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。 affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id)
elif payload.platform == "jd":
affected = repo.mark_jd_deeplink_invalid(db, payload.shop_id)
else:
# 当前只接淘宝/京东; 其它平台先 no-op(affected=0), 不报错 — 向后兼容 pricebot 将来扩展。
logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform) logger.info("store_mapping invalidate 跳过: platform=%s 暂不支持", payload.platform)
return StoreMappingInvalidateOut(ok=True, affected=0) return StoreMappingInvalidateOut(ok=True, affected=0)
affected = repo.mark_taobao_deeplink_invalid(db, payload.shop_id)
logger.info( logger.info(
"store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d", "store_mapping invalidate platform=%s shop_id=%s → 标记失效 %d",
payload.platform, payload.shop_id, affected, payload.platform, payload.shop_id, affected,
+35 -1
View File
@@ -32,6 +32,8 @@ from app.schemas.ad import (
FeedRewardIn, FeedRewardIn,
FeedRewardOut, FeedRewardOut,
PangleCallbackOut, PangleCallbackOut,
RewardNoShowIn,
RewardNoShowOut,
TestGrantIn, TestGrantIn,
TestGrantOut, TestGrantOut,
WatchReportIn, 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_type=payload.ad_type, ecpm_raw=payload.ecpm,
ad_session_id=payload.ad_session_id, ad_session_id=payload.ad_session_id,
adn=payload.adn, slot_id=payload.slot_id, adn=payload.adn, slot_id=payload.slot_id,
app_env=payload.app_env, our_code_id=payload.our_code_id,
) )
logger.info( 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, 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) 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, ad_session_id=payload.ad_session_id,
adn=payload.adn, adn=payload.adn,
slot_id=payload.slot_id, slot_id=payload.slot_id,
app_env=payload.app_env,
our_code_id=payload.our_code_id,
aborted=payload.aborted,
) )
logger.info( logger.info(
"feed ad reward user_id=%d event=%s status=%s units=%d coin=%d", "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, unit_count=rec.unit_count,
daily_limit=rewards.get_ad_daily_limit(db), 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)
+14 -1
View File
@@ -18,7 +18,7 @@ import httpx
from fastapi import APIRouter, HTTPException, Request, status from fastapi import APIRouter, HTTPException, Request, status
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from app.api.deps import DbSession from app.api.deps import CurrentUser, DbSession
from app.core.config import settings from app.core.config import settings
from app.core.pricebot_router import pick_pricebot from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal from app.db.session import SessionLocal
@@ -28,6 +28,7 @@ from app.schemas.coupon_state import (
CouponPromptDismissIn, CouponPromptDismissIn,
CouponPromptShouldShowOut, CouponPromptShouldShowOut,
CouponPromptShownIn, CouponPromptShownIn,
CouponStatsOut,
) )
logger = logging.getLogger("shagua.coupon") logger = logging.getLogger("shagua.coupon")
@@ -268,3 +269,15 @@ def coupon_completed_today_reset(
/prompt/reset 配套:开发设置重置今日领券弹窗状态一键把今日状态全清MVP 不鉴权""" /prompt/reset 配套:开发设置重置今日领券弹窗状态一键把今日状态全清MVP 不鉴权"""
coupon_repo.reset_today_completion(db, payload.device_id) coupon_repo.reset_today_completion(db, payload.device_id)
return {"ok": True} return {"ok": True}
@router.get(
"/stats",
response_model=CouponStatsOut,
summary="累计领券数(「我的」页战绩卡「领取优惠券 X 张」)",
)
def coupon_stats(user: CurrentUser, db: DbSession) -> CouponStatsOut:
"""该登录用户累计领到的券数(SUM(claimed_count),口径见 coupon_repo.sum_claimed_count)。
**鉴权(CurrentUser)**区别于同文件不鉴权的 /step 透传与 /prompt 频控(那些按 device_id):
个人战绩按 user_id 聚合,必须有登录态"""
return CouponStatsOut(coupon_count=coupon_repo.sum_claimed_count(db, user.id))
+1 -1
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger("shagua.feedback")
router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"]) router = APIRouter(prefix="/api/v1/feedback", tags=["feedback"])
_MAX_IMAGES = 6 _MAX_IMAGES = 6
_CONTENT_MAX = 2000 _CONTENT_MAX = 200
_CONTACT_MAX = 128 _CONTACT_MAX = 128
+5
View File
@@ -35,6 +35,11 @@ class AdEcpmRecord(Base):
adn: Mapped[str | None] = mapped_column(String(32), nullable=True) adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 实际展示用的代码位(底层 mediation rit,非客户端配置位) # 实际展示用的代码位(底层 mediation rit,非客户端配置位)
slot_id: Mapped[str | None] = mapped_column(String(64), nullable=True) 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 原始字符串(单位:分/千次展示,SDK getEcpm 原值,原样存)
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
# 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较) # 北京时间日期串 'YYYY-MM-DD',按它等值做"按天聚合"(不在 SQL 里做跨时区 date 比较)
+3
View File
@@ -30,6 +30,9 @@ class AdFeedRewardRecord(Base):
ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False) ecpm_raw: Mapped[str] = mapped_column(String(32), nullable=False)
adn: Mapped[str | None] = mapped_column(String(32), nullable=True) adn: Mapped[str | None] = mapped_column(String(32), nullable=True)
slot_id: Mapped[str | None] = mapped_column(String(64), 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) coin: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted") status: Mapped[str] = mapped_column(String(16), nullable=False, default="granted")
+4
View File
@@ -33,6 +33,10 @@ class AdRewardRecord(Base):
ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报) # 本次发奖采用的 eCPM 原始值(回调自带或按 ad_session_id 匹配的客户端上报)
ecpm_raw: Mapped[str | None] = mapped_column(String(32), nullable=True) 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',按它等值统计当日发奖次数 # 北京时间日期串 'YYYY-MM-DD',按它等值统计当日发奖次数
reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False) reward_date: Mapped[str] = mapped_column(String(10), index=True, nullable=False)
# 穿山甲上报的奖励名(参考,不作发奖依据) # 穿山甲上报的奖励名(参考,不作发奖依据)
+2
View File
@@ -105,6 +105,8 @@ class StoreMapping(Base):
jd_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # 3.cn 短链 jd_share_url: Mapped[str | None] = mapped_column(String(256), nullable=True) # 3.cn 短链
jd_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 反查出的目标 openapp.jdmobile:// deeplink jd_resolved_url: Mapped[str | None] = mapped_column(Text, nullable=True) # 反查出的目标 openapp.jdmobile:// deeplink
jd_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 pages/search 店内搜索 deeplink jd_deeplink: Mapped[str | None] = mapped_column(Text, nullable=True) # 拼好的 pages/search 店内搜索 deeplink
# 京东 deeplink 失效标记(同 taobao_deeplink_invalid_at):撞"当前门店超出配送范围"页时被置,NULL=有效。
jd_deeplink_invalid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# 灵活字段兜底(免得加字段就迁移) # 灵活字段兜底(免得加字段就迁移)
attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True) attrs: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
+9 -1
View File
@@ -23,8 +23,14 @@ def create_ecpm_record(
ad_session_id: str | None = None, ad_session_id: str | None = None,
adn: str | None = None, adn: str | None = None,
slot_id: str | None = None, slot_id: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
) -> AdEcpmRecord: ) -> AdEcpmRecord:
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。""" """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。
app_env(prod/test) our_code_id(我们后台配置的 104xxx 代码位)供广告收益报表按
应用/代码位聚合; adn(实际填充子渠道)/slot_id(底层 rit)是两组不同口径
"""
if ad_session_id: if ad_session_id:
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id) existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
if existing is not None: if existing is not None:
@@ -35,6 +41,8 @@ def create_ecpm_record(
ad_session_id=ad_session_id, ad_session_id=ad_session_id,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
app_env=app_env,
our_code_id=our_code_id,
ecpm_raw=ecpm_raw, ecpm_raw=ecpm_raw,
report_date=cn_today().isoformat(), report_date=cn_today().isoformat(),
) )
+53 -4
View File
@@ -69,12 +69,19 @@ def grant_feed_reward(
ad_session_id: str | None = None, ad_session_id: str | None = None,
adn: str | None = None, adn: str | None = None,
slot_id: str | None = None, slot_id: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
aborted: bool = False,
) -> AdFeedRewardRecord: ) -> AdFeedRewardRecord:
"""完成一条信息流广告后结算奖励。client_event_id 幂等,同号重试不重复发。 """比价/领券一整场信息流(轮播多条)结束后结算奖励。client_event_id 幂等,同号重试不重复发。
一期 eCPM/时长均由客户端上报,故服务端两道硬闸防刷:时长钳到 FEED_MAX_DURATION_SECONDS 发奖规则:**比价全程不关广告才发**,金额按整场**总观看时长**折份( 10 1 )
限单事件份数,eCPM rewards.calculate_ad_reward_coin 内钳到 AD_ECPM_MAX_FEN 限单份金额; - aborted=True(用户中途 关闭):整场不发, status='closed_early' 留痕(原因可查)
叠加每日 get_ad_daily_limit 条数上限,把单用户日产出锁进有限区间 - 总时长不足 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) existing = _find_by_event(db, client_event_id)
if existing is not None: if existing is not None:
@@ -85,6 +92,25 @@ def grant_feed_reward(
safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS)) safe_duration = max(0, min(duration_seconds, FEED_MAX_DURATION_SECONDS))
unit_count = safe_duration // FEED_REWARD_UNIT_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): if _granted_today(db, user_id, today) >= rewards.get_ad_daily_limit(db):
rec = AdFeedRewardRecord( rec = AdFeedRewardRecord(
client_event_id=client_event_id, client_event_id=client_event_id,
@@ -96,11 +122,32 @@ def grant_feed_reward(
ecpm_raw=ecpm, ecpm_raw=ecpm,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
app_env=app_env,
our_code_id=our_code_id,
coin=0, coin=0,
status="capped", status="capped",
) )
return _commit_record(db, rec, client_event_id) 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) coin = _unit_reward_total(db, user_id, ecpm, unit_count)
if coin > 0: if coin > 0:
crud_wallet.grant_coins( crud_wallet.grant_coins(
@@ -118,6 +165,8 @@ def grant_feed_reward(
ecpm_raw=ecpm, ecpm_raw=ecpm,
adn=adn, adn=adn,
slot_id=slot_id, slot_id=slot_id,
app_env=app_env,
our_code_id=our_code_id,
coin=coin, coin=coin,
status="granted", status="granted",
) )
+62 -4
View File
@@ -90,6 +90,16 @@ def grant_ad_reward(
today = cn_today().isoformat() 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 次)。旧的观看时长闸保留字段, # #3 每日上限:当前产品只保留发奖次数上限(默认 500 次)。旧的观看时长闸保留字段,
# 但 DAILY_AD_WATCH_SECONDS_LIMIT=0 时视为停用,不能命中 capped。 # 但 DAILY_AD_WATCH_SECONDS_LIMIT=0 时视为停用,不能命中 capped。
over_time = ( over_time = (
@@ -102,19 +112,18 @@ def grant_ad_reward(
trans_id=trans_id, user_id=user_id, coin=0, status="capped", trans_id=trans_id, user_id=user_id, coin=0, status="capped",
reward_date=today, reward_name=reward_name, raw=raw, reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm, 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) return _commit_record(db, rec, trans_id)
ecpm_raw = ecpm ecpm_raw = ecpm or (ecpm_rec.ecpm_raw if ecpm_rec is not None else None)
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
if not ecpm_raw: if not ecpm_raw:
rec = AdRewardRecord( rec = AdRewardRecord(
trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing", trans_id=trans_id, user_id=user_id, coin=0, status="ecpm_missing",
reward_date=today, reward_name=reward_name, raw=raw, reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=None, 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) 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", trans_id=trans_id, user_id=user_id, coin=coin, status="granted",
reward_date=today, reward_name=reward_name, raw=raw, reward_date=today, reward_name=reward_name, raw=raw,
reward_scene=reward_scene, ad_session_id=ad_session_id, ecpm_raw=ecpm_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) return _commit_record(db, rec, trans_id)
+27 -1
View File
@@ -9,7 +9,7 @@ import logging
from datetime import date, datetime from datetime import date, datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from sqlalchemy import delete, select from sqlalchemy import delete, func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -206,3 +206,29 @@ def record_claims(
) )
return 0 return 0
return written return written
# ===== 累计领券数(「我的」页战绩卡「领取优惠券 X 张」)=====
def sum_claimed_count(db: Session, user_id: int) -> int:
"""该用户累计领到的优惠券张数。口径(2026-06-15 用户定):SUM(claimed_count) ——
各成功领券记录的 pricebot 展示张数(claimed_count ,存的是 display_count)之和,
与领券完成时给用户看的本次领了 N 同源
- 只算 status {success, already_claimed}:already_claimed=今日已领过,协议里算已领到;
failed / skipped 不计
- claimed_count 0 的保持 0:那是同 count_group 合并去重项(pricebot 只让一条出数),不重复计;
NULL 的兜底成 1(成功领到至少 1 ;实际 pricebot to_dict 恒下发 display_count,NULL 基本不出现)
- 维度 user_id:登录态领的券才归入登录前匿名领的(user_id 为空)不算(产品可接受)
"""
total = db.execute(
select(
func.coalesce(
func.sum(func.coalesce(CouponClaimRecord.claimed_count, 1)), 0
)
).where(
CouponClaimRecord.user_id == user_id,
CouponClaimRecord.status.in_(("success", "already_claimed")),
)
).scalar_one()
return int(total or 0)
+20
View File
@@ -124,6 +124,23 @@ def mark_taobao_deeplink_invalid(db: Session, shop_id: str) -> int:
return result.rowcount or 0 return result.rowcount or 0
def mark_jd_deeplink_invalid(db: Session, store_id: str) -> int:
"""把所有 id_jd=store_id 的行标记京东 deeplink 失效(置 invalid_at=now)。返回本次新标记的行数。
mark_taobao_deeplink_invalid: storeId 标记**所有**(撞京东"当前门店超出配送范围"),
全标掉才能让后续 lookup 不再返回它幂等:已标记的行(invalid_at NULL)跳过"""
result = db.execute(
update(StoreMapping)
.where(
StoreMapping.id_jd == store_id,
StoreMapping.jd_deeplink_invalid_at.is_(None),
)
.values(jd_deeplink_invalid_at=func.now())
)
db.commit()
return result.rowcount or 0
# ============================================================ # ============================================================
# 缓存查询: 比价前按"源平台店名"反查已沉淀的各目标平台店铺 id, 命中就让 pricebot 直接 # 缓存查询: 比价前按"源平台店名"反查已沉淀的各目标平台店铺 id, 命中就让 pricebot 直接
# deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。 # deeplink 跳店内搜索, 省掉"开平台→进店→分享反查"整段。
@@ -181,6 +198,9 @@ def lookup_nearest(
if tgt == "taobao": if tgt == "taobao":
# 失效的淘宝 deeplink(撞过错误页被 invalidate)整条排除 = 当没缓存, pricebot 走正常搜店。 # 失效的淘宝 deeplink(撞过错误页被 invalidate)整条排除 = 当没缓存, pricebot 走正常搜店。
cands = [r for r in cands if r.taobao_deeplink_invalid_at is None] cands = [r for r in cands if r.taobao_deeplink_invalid_at is None]
elif tgt == "jd":
# 同上:失效的京东 deeplink(撞"当前门店超出配送范围"被 invalidate)整条排除。
cands = [r for r in cands if r.jd_deeplink_invalid_at is None]
if not cands: if not cands:
continue continue
best = _pick_best(cands, lat, lng) best = _pick_best(cands, lat, lng)
+49 -5
View File
@@ -57,6 +57,12 @@ class EcpmReportIn(BaseModel):
) )
adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle") adn: str | None = Field(None, description="实际投放 ADN(getSdkName),如 pangle")
slot_id: str | None = Field(None, description="实际展示代码位(底层 mediation rit)") 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): class EcpmReportOut(BaseModel):
@@ -115,24 +121,62 @@ class TestGrantOut(BaseModel):
class FeedRewardIn(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") client_event_id: str = Field(..., min_length=8, max_length=64, description="客户端生成的幂等事件 id")
ad_session_id: str | None = Field( ad_session_id: str | None = Field(
None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id" None, min_length=8, max_length=64, description="客户端生成的一次信息流广告会话 id"
) )
ecpm: str = Field(..., description="信息流广告 eCPM,按分/千次展示处理(SDK getEcpm 原值,非元)") ecpm: str = Field(..., description="信息流 eCPM(代表值,按分/千次展示处理;SDK getEcpm 原值,非元)")
duration_seconds: int = Field(..., ge=0, description="本条广告实际展示/播放秒数") duration_seconds: int = Field(..., ge=0, description="整场累计观看秒数(轮播各条相加)")
adn: str | None = Field(None, description="实际投放 ADN") adn: str | None = Field(None, description="实际投放 ADN")
slot_id: str | None = Field(None, description="实际展示代码位") 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): class FeedRewardOut(BaseModel):
granted: bool = Field(..., description="本次是否入账。达上限时 false") 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="本次发放金币") coin: int = Field(..., description="本次发放金币")
unit_count: int = Field(..., description="按 10 秒折算出的奖励份数") unit_count: int = Field(..., description="按 10 秒折算出的奖励份数")
daily_limit: int = Field(..., description="每日信息流展示次数上限") 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(该次其实已发奖,跳过未写)"
)
+10
View File
@@ -39,3 +39,13 @@ class CouponCompletedTodayOut(BaseModel):
"""这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。""" """这台设备今天是否已跑完整轮领券(到 done 帧)。完成 → 首页「去领取」卡置灰。"""
completed: bool completed: bool
class CouponStatsOut(BaseModel):
"""「我的」页战绩卡「领取优惠券 X 张」数据源:该登录用户累计领到的券数。
口径(2026-06-15 用户定):SUM(claimed_count) 各成功领券记录的 pricebot 展示张数之和,
与领券完成时给用户看的本次领了 N 同源详见 repositories.coupon_state.sum_claimed_count
"""
coupon_count: int
+2
View File
@@ -63,6 +63,7 @@
| 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) | | 34 | `POST /api/v1/ad/test-grant` | Bearer | [详情](./ad-test-grant.md) |
| 35 | `POST /api/v1/ad/ecpm-report` | Bearer | [详情](./ad-ecpm-report.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) | | 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` ||| | **用户资料**(前缀 `/api/v1/user` |||
| 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) | | 35 | `PATCH /api/v1/user/profile` | Bearer | [详情](./user-profile.md) |
| 36 | `POST /api/v1/user/avatar` | Bearer | [详情](./user-avatar.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) | | A26 | `POST /admin/api/marquee-seeds/bulk` | operator | [详情](./admin-marquee-seeds.md) |
| A27 | `GET /admin/api/marquee-seeds/preview` | admin | [详情](./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)(看广告金币公式复算对账,只读) | | 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 健康检查(无单独文档) | | - | `GET /admin/api/health` | 无 | admin 健康检查(无单独文档) |
> ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。 > ⚠️ 美团三个接口当前**无鉴权**,且 `referral-link``sid` 允许客户端传值覆盖默认渠道——见各接口"备注"。
+6 -4
View File
@@ -7,11 +7,13 @@
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |---|---|---|---|
| `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `draw`(Draw 信息流) 等 | | `ad_type` | str | 是 | 广告类型:`reward_video`(激励视频) / `feed`(信息流) / `draw`(历史 Draw 信息流) 等 |
| `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;需和穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配 | | `ad_session_id` | str\|null | 否 | 客户端生成的广告会话 ID;激励视频与穿山甲 `extra.ad_session_id` 一致,用于 S2S 缺 eCPM 时匹配。**信息流轮播每条展示用各自独立 id**(不复用比价会话 id,否则 `uq_ad_ecpm_record_session` 去重只留一条) |
| `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 | | `ecpm` | str | 是 | 穿山甲 `getShowEcpm().getEcpm()` 原始字符串,单位是**分/千次展示**(非元),后端 ÷100 转元参与金币公式 |
| `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle` | | `adn` | str\|null | 否 | 实际投放 ADN(`getSdkName`),如 `pangle`(聚合后实际填充的子渠道) |
| `slot_id` | str\|null | 否 | 实际展示代码位(底层 mediation rit,非客户端配置位) | | `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),防伪造。 `user_id` 不在 body 里——由 JWT 取(Bearer),防伪造。
@@ -23,7 +25,7 @@
| `ok` | bool | 落库即 `true` | | `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;两边都没有则不发并记录异常。 - 普通激励视频发奖会先用 S2S 回调自带 `ecpm`;若缺失,再按 `ad_session_id` 读取本接口上报的 eCPM;两边都没有则不发并记录异常。
- **best-effort**:客户端 fire-and-forget,但普通激励视频若 S2S 缺 eCPM,这条上报会成为发奖依据。 - **best-effort**:客户端 fire-and-forget,但普通激励视频若 S2S 缺 eCPM,这条上报会成为发奖依据。
+12 -9
View File
@@ -1,6 +1,6 @@
# POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币 # POST /api/v1/ad/feed-reward — 信息流广告完成后结算金币
点位 2:比价等待 / 领券信息流广告。每展示满 10 秒累计一份奖励,视频完成后一次性入账 点位 2:比价等待 / 领券信息流广告(轮播多条)。**整场比价全程不关广告才发**,金额按整场**总观看时长**折份(每 10 秒 1 份),结束时一次性入账。用户中途 ✕ 关闭则整场不发
## 鉴权 ## 鉴权
@@ -12,24 +12,27 @@
|---|---|---:|---| |---|---|---:|---|
| `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 | | `client_event_id` | string | 是 | 客户端生成的幂等事件 id,8-64 字符 |
| `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 | | `ad_session_id` | string\|null | 否 | 客户端生成的一次信息流广告会话 id,用于对账/排查 |
| `ecpm` | string | 是 | 本信息流广告 eCPM(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) | | `ecpm` | string | 是 | 本信息流 eCPM 代表值(穿山甲 getEcpm 原值),按“分/千次展示”处理(非元) |
| `duration_seconds` | int | 是 | 实际展示/播放秒数 | | `duration_seconds` | int | 是 | **整场累计观看秒数**(轮播各条相加) |
| `adn` | string\|null | 否 | 实际投放 ADN | | `adn` | string\|null | 否 | 实际投放 ADN(聚合后实际填充的子渠道) |
| `slot_id` | string\|null | 否 | 实际展示代码位 | | `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` | | `granted` | bool | 本次是否入账;未发(任一非 granted 状态)时为 `false` |
| `status` | string | `granted` / `capped` | | `status` | string | `granted`(已发) / `capped`(当日次数超限) / `too_short`(整场总时长<10s 凑不满一份) / `closed_early`(用户中途关闭) |
| `coin` | int | 本次发放金币 | | `coin` | int | 本次发放金币;非 granted 为 0 |
| `unit_count` | int | 按 10 秒折算出的奖励份数 | | `unit_count` | int | 按 10 秒折算出的奖励份数 |
| `daily_limit` | int | 每日信息流展示次数上限,默认 500 | | `daily_limit` | int | 每日信息流展示次数上限,默认 500 |
## 计算口径 ## 计算口径
- 奖励份数:`duration_seconds // 10` - 奖励份数:`整场总时长 // 10`
- 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币。 - 单份奖励:`eCPM / 1000 × 因子1(eCPM 档) × 因子2(当天累计份序号) × 10000`,四舍五入为整数金币。
- eCPM 档:`0-100=0.1``101-200=0.3``201-400=0.4``>400=0.6` - 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` - LT 档:第 1 份 `2.0`,第 2 份 `1.5`,第 3 份 `1.3`,第 4-10 份 `1.1`,第 11 份及以后 `1.0`
+41
View File
@@ -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`(不发币)。
+108
View File
@@ -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 | 北京时间小时 023(`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) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
+2 -2
View File
@@ -6,7 +6,7 @@
**multipart/form-data**: **multipart/form-data**:
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |---|---|---|---|
| `content` | string | ✓ | 反馈正文,**1-2000 字**(strip 后) | | `content` | string | ✓ | 反馈正文,**1-200 字**(strip 后) |
| `contact` | string | ✗ | 联系方式(微信/QQ/手机号),**≤128 字**。原型改版后客户端已不再采集、不传该字段(后端默认空串);保留字段兼容旧端 | | `contact` | string | ✗ | 联系方式(微信/QQ/手机号),**≤128 字**。原型改版后客户端已不再采集、不传该字段(后端默认空串);保留字段兼容旧端 |
| `images` | file[] | ✗ | 截图,**最多 6 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) | | `images` | file[] | ✗ | 截图,**最多 6 张**,每张走头像同款校验(JPEG/PNG/WebP,≤ 5 MB,魔数嗅探) |
@@ -29,7 +29,7 @@
> 不返回上传的 image URL——这是给运营后台看的,客户端通常不需要。 > 不返回上传的 image URL——这是给运营后台看的,客户端通常不需要。
## 错误码 ## 错误码
- `400` 内容为空 / 内容超 2000 字 / 联系方式超 128 字 / 图片超 6 张 / 单图非法(空/过大/格式不对) - `400` 内容为空 / 内容超 200 字 / 联系方式超 128 字 / 图片超 6 张 / 单图非法(空/过大/格式不对)
- `401` 未带 token / token 无效或过期 / 用户被禁用 - `401` 未带 token / token 无效或过期 / 用户被禁用
- `422``content` 字段 - `422``content` 字段
+6 -4
View File
@@ -7,17 +7,19 @@
## 用在哪 / 增删改查 ## 用在哪 / 增删改查
- **C(插入)**:`POST /ad/ecpm-report`(`create_ecpm_record`)。每次广告展示上报一条;best-effort,丢一两条不影响业务。鉴权接口已确保 user 存在。 - **C(插入)**:`POST /ad/ecpm-report`(`create_ecpm_record`)。每次广告展示上报一条;best-effort,丢一两条不影响业务。鉴权接口已确保 user 存在。
- **U / D**:无。 - **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) | | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---| |---|---|---|---|
| `id` | Integer | PK, autoincrement | | | `id` | Integer | PK, autoincrement | |
| `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 | | `user_id` | Integer | FK→user.id, index, NOT NULL | 归属用户 |
| `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `draw`(Draw 信息流);各类型各自上报,不强行统一代码位 | | `ad_type` | String(32) | NOT NULL | 广告类型,取值如 `reward_video`(激励视频)/ `feed`(信息流)/ `draw`(历史 Draw 信息流);各类型各自上报,不强行统一代码位 |
| `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;用于普通激励视频 S2S 缺 `ecpm` 时匹配发奖 | | `ad_session_id` | String(64) | UNIQUE, index, nullable | 客户端广告会话 ID;激励视频用于 S2S 缺 `ecpm` 时匹配发奖。**信息流轮播每条展示用各自独立 id**(不复用比价会话 id,否则 UNIQUE 去重只留一条) |
| `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`) | | `adn` | String(32) | nullable | 实际投放 ADN(`getShowEcpm().getSdkName()`,如 `pangle`/`gdt`;聚合后实际填充的子渠道) |
| `slot_id` | String(64) | nullable | 实际展示用代码位(底层 mediation rit,非客户端配置位) | | `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 转元参与金币公式 | | `ecpm_raw` | String(32) | NOT NULL | 客户端上报的 eCPM **原始串**(穿山甲 getEcpm 原值,单位**分/千次展示**);后端 ÷100 转元参与金币公式 |
| `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 | | `report_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它做按天聚合 |
| `created_at` | DateTime(tz) | server_default now(), index | 时间 | | `created_at` | DateTime(tz) | server_default now(), index | 时间 |
+7 -5
View File
@@ -11,13 +11,15 @@
| `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id | | `ad_session_id` | String(64) | index, nullable | 客户端生成的一次信息流广告会话 id |
| `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 | | `user_id` | Integer | FK → `user.id`, index, NOT NULL | 用户 |
| `reward_date` | String(10) | index, NOT NULL | 北京时间日期 `YYYY-MM-DD` | | `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` 得到的奖励份数 | | `unit_count` | Integer | NOT NULL | `duration_seconds // 10` 得到的奖励份数 |
| `ecpm_raw` | String(32) | NOT NULL | 客户端上报 eCPM 原始值 | | `ecpm_raw` | String(32) | NOT NULL | 客户端上报 eCPM 原始值 |
| `adn` | String(32) | nullable | 实际投放 ADN | | `adn` | String(32) | nullable | 实际投放 ADN(聚合后实际填充子渠道) |
| `slot_id` | String(64) | nullable | 实际展示代码位 | | `slot_id` | String(64) | nullable | 实际展示代码位(底层 mediation rit) |
| `coin` | Integer | NOT NULL | 实发金币,`capped` 时为 0 | | `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试),客户端 feed-reward 上报;旧数据 NULL。广告收益报表金币侧按它聚合 |
| `status` | String(16) | NOT NULL | `granted` / `capped` | | `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 | 创建时间 | | `created_at` | DateTime | index, NOT NULL | 创建时间 |
## 约束 ## 约束
+6 -4
View File
@@ -5,7 +5,7 @@
每条 = 穿山甲一次**服务端激励回调**。`trans_id` 唯一做幂等键(穿山甲会重试,同号只处理一次)。`reward_scene` 区分普通激励视频、签到膨胀等场景;`reward_date`(北京时间日期串)给普通激励视频"每日上限"计数用。 每条 = 穿山甲一次**服务端激励回调**。`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**:无。 - **U / D**:无。
- **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。 - **R**:`GET /ad/reward-status`(看广告页:今日已发次数/上限、单次金币、本轮已看/冷却结束、今日已看时长/上限);审计/对账整表回溯。
@@ -13,13 +13,15 @@
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) | | 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
|---|---|---|---| |---|---|---|---|
| `id` | Integer | PK, autoincrement | | | `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) | | `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` 签到膨胀 | | `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` | | `ad_session_id` | String(64) | index, nullable | 客户端广告会话 ID,来自 `extra.ad_session_id`;用于匹配 `ad_ecpm_record` |
| `ecpm_raw` | String(32) | nullable | 本次发奖采用的 eCPM 原始值;可来自 S2S `ecpm` 或客户端上报 | | `ecpm_raw` | String(32) | nullable | 本次发奖采用的 eCPM 原始值;可来自 S2S `ecpm` 或客户端上报 |
| `coin` | Integer | NOT NULL, default 0 | 实发金币;`capped`/`ecpm_missing`/业务不满足时为 0 | | `app_env` | String(16) | nullable | 来源应用 `prod`(傻瓜比价)/`test`(测试);S2S 不带,发奖时按 `ad_session_id` 匹配 `ad_ecpm_record` 回填,查不到 NULL。广告收益报表金币侧按它聚合 |
| `status` | String(16) | NOT NULL, default `granted` | 取值:`granted`(已发)/ `capped`(当日次数超限)/ `ecpm_missing`(缺 eCPM)/ `not_signed`/`already_boosted`/`last_day` | | `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_date` | String(10) | index, NOT NULL | 北京时间日期串 `YYYY-MM-DD`,按它等值统计当日发奖次数 |
| `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) | | `reward_name` | String(64) | nullable | 穿山甲上报奖励名(参考,不作发奖依据) |
| `raw` | String(1024) | nullable | 回调原始参数(审计排查) | | `raw` | String(1024) | nullable | 回调原始参数(审计排查) |
+41 -3
View File
@@ -16,11 +16,13 @@ _SECRET = "test-internal-secret-only-for-pytest"
def _mk_row(db, *, trace_id, name_meituan=None, name_taobao=None, def _mk_row(db, *, trace_id, name_meituan=None, name_taobao=None,
id_taobao=None, deeplink=None, lat=None, lng=None): id_taobao=None, deeplink=None, lat=None, lng=None,
name_jd=None, id_jd=None, jd_deeplink=None):
row = StoreMapping( row = StoreMapping(
trace_id=trace_id, business_type="food", trace_id=trace_id, business_type="food",
name_meituan=name_meituan, name_taobao=name_taobao, name_meituan=name_meituan, name_taobao=name_taobao,
id_taobao=id_taobao, taobao_deeplink=deeplink, lat=lat, lng=lng, id_taobao=id_taobao, taobao_deeplink=deeplink, lat=lat, lng=lng,
name_jd=name_jd, id_jd=id_jd, jd_deeplink=jd_deeplink,
) )
db.add(row) db.add(row)
db.commit() db.commit()
@@ -110,9 +112,45 @@ def test_invalidate_endpoint_marks_and_lookup_miss(client, db, monkeypatch):
assert "taobao" not in lk.json() assert "taobao" not in lk.json()
def test_invalidate_endpoint_non_taobao_noop(client, monkeypatch): def test_invalidate_endpoint_unsupported_platform_noop(client, monkeypatch):
# 当前支持 taobao/jd;其它平台(如 meituan)no-op 返 affected=0
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET) monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
r = client.post("/internal/store-mapping/invalidate", r = client.post("/internal/store-mapping/invalidate",
json={"platform": "jd", "shop_id": "X"}, json={"platform": "meituan", "shop_id": "X"},
headers={"X-Internal-Secret": _SECRET}) headers={"X-Internal-Secret": _SECRET})
assert r.status_code == 200 and r.json()["affected"] == 0 assert r.status_code == 200 and r.json()["affected"] == 0
# ---------- 京东(对称淘宝)----------
def test_mark_jd_invalid_marks_all_rows_with_store_id(db):
_mk_row(db, trace_id="j1", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl_a")
_mk_row(db, trace_id="j2", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl_b")
_mk_row(db, trace_id="j3", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JGOOD", jd_deeplink="dl_g")
assert repo.mark_jd_deeplink_invalid(db, "JBAD") == 2
assert repo.mark_jd_deeplink_invalid(db, "JBAD") == 0 # 幂等
rows = {r.trace_id: r for r in db.query(StoreMapping).all()}
assert rows["j1"].jd_deeplink_invalid_at is not None
assert rows["j2"].jd_deeplink_invalid_at is not None
assert rows["j3"].jd_deeplink_invalid_at is None # 不同 storeId 不动
def test_lookup_filters_invalid_jd(db):
_mk_row(db, trace_id="j1", name_meituan="兰州拉面", name_jd="兰州拉面", id_jd="JBAD", jd_deeplink="dl")
assert repo.lookup_nearest(db, "meituan", "兰州拉面")["jd"]["store_id"] == "JBAD"
repo.mark_jd_deeplink_invalid(db, "JBAD")
assert "jd" not in repo.lookup_nearest(db, "meituan", "兰州拉面")
def test_invalidate_endpoint_jd_marks_and_lookup_miss(client, db, monkeypatch):
monkeypatch.setattr(settings, "INTERNAL_API_SECRET", _SECRET)
_mk_row(db, trace_id="j1", name_meituan="店J", name_jd="店J", id_jd="JBADX", jd_deeplink="dl")
_mk_row(db, trace_id="j2", name_meituan="店J", name_jd="店J", id_jd="JBADX", jd_deeplink="dl2")
r = client.post("/internal/store-mapping/invalidate",
json={"platform": "jd", "shop_id": "JBADX"},
headers={"X-Internal-Secret": _SECRET})
assert r.status_code == 200 and r.json()["affected"] == 2
lk = client.get("/internal/store-mapping/lookup",
params={"source_platform": "meituan", "name": "店J"},
headers={"X-Internal-Secret": _SECRET})
assert "jd" not in lk.json()