feat(ad): eCPM 单位统一为分 + 新增看广告金币审计接口 (#32)
- eCPM 口径修正:getEcpm 原值是分/千次,新增 parse_ecpm_fen;parse_ecpm_yuan 改为 ÷100 转元,因子1 阈值按元判档(100/200/400),收益换算用元 - test-grant 用客户端按 ad_session_id 上报的真实 eCPM 发奖(取不到/≤0 兜底 200) - 新增 GET /admin/api/ad-coin-audit 只读复算对账(router/repo/schema),复用发奖公式 - 同步更新 docs/api、docs/database 的分/元口径 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: #32 Co-authored-by: ouzhou <ouzhou@wonderable.ai> Co-committed-by: ouzhou <ouzhou@wonderable.ai>
This commit was merged in pull request #32.
This commit is contained in:
@@ -13,6 +13,7 @@ from contextlib import asynccontextmanager
|
||||
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.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
|
||||
@@ -86,3 +87,4 @@ admin_app.include_router(feedback_router)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""看广告金币审计:复算 expected_coin 并与实发对比。
|
||||
|
||||
只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移):
|
||||
- 看视频:每条 granted = 1 份,第 N 份 = 当日该用户 granted 的 reward_video 顺序号
|
||||
(与 ad_reward.grant_ad_reward 里 `_granted_today + 1` 一致)。
|
||||
- 信息流:每条按 unit_count 份逐份累加,LT 序号 = 当日该用户已 granted 份数累计
|
||||
(与 ad_feed_reward._unit_reward_total 的 existing_units 一致)。
|
||||
|
||||
非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import rewards
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
from app.models.ad_reward import AdRewardRecord
|
||||
from app.repositories.ad_feed_reward import FEED_REWARD_UNIT_SECONDS
|
||||
|
||||
|
||||
def _reward_video_rows(
|
||||
db: Session, *, date: str, user_id: int | None
|
||||
) -> list[dict]:
|
||||
"""看视频记录复算。按 (user_id, created_at) 升序还原当日第 N 份。"""
|
||||
stmt = (
|
||||
select(AdRewardRecord)
|
||||
.where(
|
||||
AdRewardRecord.reward_date == date,
|
||||
AdRewardRecord.reward_scene == "reward_video",
|
||||
)
|
||||
.order_by(AdRewardRecord.user_id, AdRewardRecord.created_at)
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdRewardRecord.user_id == user_id)
|
||||
|
||||
granted_n: dict[int, int] = {} # user_id -> 已 granted 份数
|
||||
rows: list[dict] = []
|
||||
for rec in db.execute(stmt).scalars():
|
||||
if rec.status == "granted":
|
||||
nth = granted_n.get(rec.user_id, 0) + 1
|
||||
granted_n[rec.user_id] = nth
|
||||
expected = rewards.calculate_ad_reward_coin(rec.ecpm_raw, nth)
|
||||
rows.append({
|
||||
"scene": "reward_video",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
||||
"units": 1,
|
||||
"lt_index_start": nth,
|
||||
"lt_index_end": nth,
|
||||
"lt_factor_start": rewards.ad_lt_factor(nth),
|
||||
"lt_factor_end": rewards.ad_lt_factor(nth),
|
||||
"expected_coin": expected,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": expected == rec.coin,
|
||||
})
|
||||
else:
|
||||
# capped / ecpm_missing:不发金币,校验实发确为 0
|
||||
rows.append({
|
||||
"scene": "reward_video",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": None,
|
||||
"units": 1,
|
||||
"lt_index_start": None,
|
||||
"lt_index_end": None,
|
||||
"lt_factor_start": None,
|
||||
"lt_factor_end": None,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": rec.coin == 0,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]:
|
||||
"""信息流记录复算。granted 记录逐份累加,LT 序号沿用当日累计份数。"""
|
||||
stmt = (
|
||||
select(AdFeedRewardRecord)
|
||||
.where(AdFeedRewardRecord.reward_date == date)
|
||||
.order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at)
|
||||
)
|
||||
if user_id is not None:
|
||||
stmt = stmt.where(AdFeedRewardRecord.user_id == user_id)
|
||||
|
||||
granted_units: dict[int, int] = {} # user_id -> 已 granted 份数累计
|
||||
rows: list[dict] = []
|
||||
for rec in db.execute(stmt).scalars():
|
||||
if rec.status == "granted":
|
||||
existing = granted_units.get(rec.user_id, 0)
|
||||
units = rec.unit_count
|
||||
expected = sum(
|
||||
rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset)
|
||||
for offset in range(1, units + 1)
|
||||
)
|
||||
granted_units[rec.user_id] = existing + units
|
||||
start = existing + 1 if units > 0 else None
|
||||
end = existing + units if units > 0 else None
|
||||
rows.append({
|
||||
"scene": "feed",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": rewards.ad_ecpm_factor(rewards.parse_ecpm_yuan(rec.ecpm_raw)),
|
||||
"units": units,
|
||||
"lt_index_start": start,
|
||||
"lt_index_end": end,
|
||||
"lt_factor_start": rewards.ad_lt_factor(start) if start else None,
|
||||
"lt_factor_end": rewards.ad_lt_factor(end) if end else None,
|
||||
"expected_coin": expected,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": expected == rec.coin,
|
||||
})
|
||||
else:
|
||||
rows.append({
|
||||
"scene": "feed",
|
||||
"record_id": rec.id,
|
||||
"user_id": rec.user_id,
|
||||
"created_at": rec.created_at,
|
||||
"status": rec.status,
|
||||
"ecpm": rec.ecpm_raw,
|
||||
"ecpm_factor": None,
|
||||
"units": rec.unit_count,
|
||||
"lt_index_start": None,
|
||||
"lt_index_end": None,
|
||||
"lt_factor_start": None,
|
||||
"lt_factor_end": None,
|
||||
"expected_coin": 0,
|
||||
"actual_coin": rec.coin,
|
||||
"matched": rec.coin == 0,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def ad_coin_audit(
|
||||
db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int
|
||||
) -> list[dict]:
|
||||
"""返回当日发奖复算明细,按 created_at 倒序(最新在前)截断到 limit。
|
||||
|
||||
scene: None=两类都要 / "reward_video" / "feed"。
|
||||
份序号在截断前已基于全天数据算好,故 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.sort(key=lambda r: r["created_at"], reverse=True)
|
||||
return rows[:limit]
|
||||
|
||||
|
||||
def formula_snapshot() -> dict:
|
||||
"""当前公式参数快照(给前端展示规则参照)。直接读 rewards 常量,与发奖同源。"""
|
||||
return {
|
||||
"coin_per_yuan": rewards.COIN_PER_YUAN,
|
||||
"feed_unit_seconds": FEED_REWARD_UNIT_SECONDS,
|
||||
"ecpm_factor_tiers": [list(t) for t in rewards.AD_ECPM_FACTOR_TABLE],
|
||||
"lt_factor_tiers": [list(t) for t in rewards.AD_LT_FACTOR_TABLE],
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""admin 看广告金币审计:只读对账,核对发奖金币是否按公式计算。
|
||||
|
||||
任意已登录 admin 可看(只读,不涉及资金操作)。复算逻辑在 app/admin/repositories/ad_audit.py。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.admin.deps import AdminDb, get_current_admin
|
||||
from app.admin.repositories import ad_audit
|
||||
from app.admin.schemas.ad_audit import AdCoinAuditOut, AdCoinAuditRow, AdCoinFormulaOut
|
||||
from app.core.rewards import cn_today
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin/api/ad-coin-audit",
|
||||
tags=["admin-ad-coin-audit"],
|
||||
dependencies=[Depends(get_current_admin)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=AdCoinAuditOut, summary="看广告金币公式审计(复算对比)")
|
||||
def get_ad_coin_audit(
|
||||
db: AdminDb,
|
||||
date: Annotated[str | None, Query(description="北京时间 YYYY-MM-DD,默认今天")] = None,
|
||||
user_id: Annotated[int | None, Query(description="只看某用户;不传=全部用户")] = None,
|
||||
scene: Annotated[
|
||||
str | None, Query(description="reward_video / feed;不传=两类都要")
|
||||
] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=500)] = 100,
|
||||
) -> AdCoinAuditOut:
|
||||
audit_date = date or cn_today().isoformat()
|
||||
rows = ad_audit.ad_coin_audit(
|
||||
db, date=audit_date, user_id=user_id, scene=scene, limit=limit,
|
||||
)
|
||||
items = [AdCoinAuditRow(**r) for r in rows]
|
||||
return AdCoinAuditOut(
|
||||
date=audit_date,
|
||||
formula=AdCoinFormulaOut(**ad_audit.formula_snapshot()),
|
||||
total=len(items),
|
||||
mismatch_count=sum(1 for it in items if not it.matched),
|
||||
items=items,
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""看广告金币审计 schemas。
|
||||
|
||||
只读对账视图:把"看视频"(ad_reward_record)和"信息流"(ad_feed_reward_record)两类发奖记录,
|
||||
用与正式发奖相同的公式 [app.core.rewards.calculate_ad_reward_coin] 复算一遍 expected_coin,
|
||||
和实际入账的 actual_coin 对比,核对金币公式是否生效。字段 snake_case、金额按金币整数。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AdCoinAuditRow(BaseModel):
|
||||
"""单条发奖记录的复算明细。"""
|
||||
|
||||
scene: str = Field(..., description="reward_video(看视频) / feed(信息流)")
|
||||
record_id: int = Field(..., description="对应记录表主键")
|
||||
user_id: int
|
||||
created_at: datetime
|
||||
status: str = Field(..., description="granted / capped / ecpm_missing")
|
||||
ecpm: str | None = Field(None, description="本次采用的 eCPM 原始值(分/千次展示,SDK getEcpm 原值)")
|
||||
ecpm_factor: float | None = Field(None, description="因子1(eCPM 档)")
|
||||
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="复算与实发是否一致(capped/ecpm_missing 校验是否确为 0)")
|
||||
|
||||
|
||||
class AdCoinFormulaOut(BaseModel):
|
||||
"""当前金币公式参数(给前端展示规则参照)。"""
|
||||
|
||||
description: str = Field(
|
||||
"eCPM元 = getEcpm分 ÷ 100;单份金币 = round(eCPM元 ÷ 1000 × 因子1 × 因子2 × coin_per_yuan);"
|
||||
"因子1 按 eCPM元 判档(阈值 100/200/400 元)",
|
||||
description="公式说明",
|
||||
)
|
||||
coin_per_yuan: int = Field(..., description="金币:元 汇率")
|
||||
ecpm_unit: str = Field("分/千次展示(SDK getEcpm 原值)", description="eCPM 口径")
|
||||
feed_unit_seconds: int = Field(..., description="信息流每多少秒折 1 份")
|
||||
# [(因子值, 区间下限, 区间上限或 null)]
|
||||
ecpm_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子1 档位表")
|
||||
lt_factor_tiers: list[tuple[float, int, int | None]] = Field(..., description="因子2 LT 档位表")
|
||||
|
||||
|
||||
class AdCoinAuditOut(BaseModel):
|
||||
"""审计响应:公式参照 + 命中条数 + 明细。"""
|
||||
|
||||
date: str = Field(..., description="审计日期(北京时间 YYYY-MM-DD)")
|
||||
formula: AdCoinFormulaOut
|
||||
total: int = Field(..., description="返回的明细条数")
|
||||
mismatch_count: int = Field(..., description="其中 matched=false 的条数(=0 说明公式全部生效)")
|
||||
items: list[AdCoinAuditRow]
|
||||
Reference in New Issue
Block a user