"""看广告金币审计:复算 expected_coin 并与实发对比。 只读。复用 [app.core.rewards] 的公式函数(不另写公式,避免与正式发奖口径漂移): - 看视频:每条 granted = 1 份,第 N 份 = 该用户 granted 的 reward_video **账号累计**顺序号 (与 ad_reward.grant_ad_reward 里 `_granted_cumulative + 1` 一致;LT 因子不按天重置, 故复算时要把当日序号叠加上该用户在本日**之前**的累计已发份数)。 - 信息流:每条按 unit_count 份逐份累加,LT 序号 = 该用户 granted 份数**账号累计** (与 ad_feed_reward._unit_reward_total 的 existing_units 一致;同样不按天重置, 复算需叠加本日之前的累计份数)。 非 granted(capped/ecpm_missing)不占用份序号、应发恒 0,据此校验闸口是否确实没发。 """ from __future__ import annotations from sqlalchemy import func, 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 _prior_granted_counts( db: Session, *, date: str, user_id: int | None ) -> dict[int, int]: """各用户在 date **之前**已发奖的 reward_video 累计份数,作为当日复算的 LT 序号起点。 LT 因子改账号累计后,当日第 1 份并非全局第 1 份,需叠加历史累计。""" stmt = ( select(AdRewardRecord.user_id, func.count()) .where( AdRewardRecord.reward_date < date, AdRewardRecord.reward_scene == "reward_video", AdRewardRecord.status == "granted", ) .group_by(AdRewardRecord.user_id) ) if user_id is not None: stmt = stmt.where(AdRewardRecord.user_id == user_id) return {uid: n for uid, n in db.execute(stmt).all()} 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, AdRewardRecord.id) ) if user_id is not None: stmt = stmt.where(AdRewardRecord.user_id == user_id) # 用本日之前的累计份数做起点,当日 granted 在其上继续递增 → 与 _granted_cumulative+1 对齐 granted_n: dict[int, int] = _prior_granted_counts(db, date=date, user_id=user_id) 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, "ad_session_id": rec.ad_session_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, "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, "ad_session_id": rec.ad_session_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, "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_prior_granted_units( db: Session, *, date: str, user_id: int | None ) -> dict[int, int]: """各用户在 date **之前** granted 的信息流份数累计,作为当日复算的 LT 序号起点。""" stmt = ( select( AdFeedRewardRecord.user_id, func.coalesce(func.sum(AdFeedRewardRecord.unit_count), 0), ) .where( AdFeedRewardRecord.reward_date < date, AdFeedRewardRecord.status == "granted", ) .group_by(AdFeedRewardRecord.user_id) ) if user_id is not None: stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) return {uid: int(n) for uid, n in db.execute(stmt).all()} def _feed_scene_matches(rec: AdFeedRewardRecord, scene: str | None) -> bool: """该信息流记录是否落入请求的展示筛选 scene。 - scene=="feed":ad_type in ("feed", NULL)(旧数据 NULL 视为 feed,向后兼容) - scene=="draw":ad_type=="draw" - scene 为 None:不筛(两类都要)。 """ if scene == "feed": return rec.ad_type in (None, "feed") if scene == "draw": return rec.ad_type == "draw" return True def _feed_rows( db: Session, *, date: str, user_id: int | None, scene: str | None = None ) -> list[dict]: """信息流记录复算。granted 记录逐份累加,LT 序号沿用账号累计份数(含本日之前)。 **关键:LT 因子账号累计按全表 unit 累计(feed+draw 共享同一发奖池/上限),不按 ad_type 拆分**—— 故无论 scene 怎么筛展示,这里都遍历当日**全部**信息流记录维持 granted_units 累加;scene 只决定 哪些行被**留下展示**(由 _feed_scene_matches 判断),不影响累计基线,保证复算序号与正式发奖一致。 """ stmt = ( select(AdFeedRewardRecord) .where(AdFeedRewardRecord.reward_date == date) .order_by(AdFeedRewardRecord.user_id, AdFeedRewardRecord.created_at, AdFeedRewardRecord.id) ) if user_id is not None: stmt = stmt.where(AdFeedRewardRecord.user_id == user_id) # 本日之前的累计份数做起点,与 _unit_reward_total 的 existing_units(累计)对齐 granted_units: dict[int, int] = _feed_prior_granted_units(db, date=date, user_id=user_id) rows: list[dict] = [] for rec in db.execute(stmt).scalars(): keep = _feed_scene_matches(rec, scene) # 累计照常推进,这里只决定是否展示本行 if rec.status == "granted": existing = granted_units.get(rec.user_id, 0) units = rec.unit_count granted_units[rec.user_id] = existing + units if not keep: continue expected = sum( rewards.calculate_ad_reward_coin(rec.ecpm_raw, existing + offset) for offset in range(1, units + 1) ) start = existing + 1 if units > 0 else None end = existing + units if units > 0 else None rows.append({ "scene": "feed", "ad_type": rec.ad_type or "feed", "feed_scene": rec.feed_scene, "record_id": rec.id, "user_id": rec.user_id, "ad_session_id": rec.ad_session_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, "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: if not keep: continue rows.append({ "scene": "feed", "ad_type": rec.ad_type or "feed", "feed_scene": rec.feed_scene, "record_id": rec.id, "user_id": rec.user_id, "ad_session_id": rec.ad_session_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, "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 audit_rows( db: Session, *, date: str, user_id: int | None, scene: str | None = None ) -> list[dict]: """当日逐条发奖复算行(未排序)。scene: None=两类 / "reward_video" / "feed" / "draw"。 "feed" 与 "draw" 都查 ad_feed_reward_record(同一发奖表),按 ad_type 区分:feed 含历史 NULL, draw 仅 ad_type=="draw"。信息流行额外带 `ad_type`/`feed_scene`,供收益报表区分比价/领券 Draw 收益。 每行含 `app_env`/`our_code_id`/`expected_coin`/`actual_coin` 等,供金币审计逐条对账, 也供广告收益报表把「应发/实发」按 用户×类型×应用×代码位 聚合(见 ad_revenue,复用同一复算口径)。 **LT 因子账号累计仍按全表 unit 累计(feed+draw 共享),scene 只筛展示,不拆累计。** """ 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", "draw"): rows.extend(_feed_rows(db, date=date, user_id=user_id, scene=scene)) return rows def ad_coin_audit( db: Session, *, date: str, user_id: int | None, scene: str | None, limit: int, only_mismatch: bool = False, ) -> dict: """当日发奖复算。返回 {total, mismatch_count, truncated, items}。 scene: None=两类都要 / "reward_video" / "feed";only_mismatch=True 只展示不一致(✗)行。 关键:`total` 与 `mismatch_count` 在**全量**(截断前)上统计,故对账数字始终可信,不受 limit 影响;`items` 才是展示集(only_mismatch 时只取 ✗ 行)按 created_at 倒序截断到 limit。 份序号在全天数据上已算好,limit 只影响展示条数、不影响 expected 复算正确性。 """ 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) mismatch_count = sum(1 for r in rows if not r["matched"]) display = [r for r in rows if not r["matched"]] if only_mismatch else rows return { "total": total, "mismatch_count": mismatch_count, "truncated": len(display) > limit, "items": display[: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], }