Compare commits

..

25 Commits

Author SHA1 Message Date
guke eb8641b551 Merge branch 'main' of https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server into feat/inactivity-reset
# Conflicts:
#	docs/superpowers/specs/2026-07-16-inactivity-reset-design.md
2026-07-21 13:50:59 +08:00
guke 41f882098c docs(welfare): 同步 home_visible 事件名到 spec + 索引注释/docstring
- spec:活跃口径/§11 前端依赖等处 show+page=home、home_view 占位统一改 home_visible;
  修正 ACTIVE_EVENTS 说明(home_visible 是纯 event 名、已收进 ACTIVE_EVENTS)。
- analytics_event 索引注释 + 迁移 docstring:改单一 event=home_visible 过滤;
  注明 page 列是 show+home 组合遗留、现冗余(索引靠 event 前缀仍生效),可后续瘦成
  (event,user_id,created_at)。保留一句历史说明。纯注释/文档,无行为变更。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:45:26 +08:00
guke 9b6a4d83b6 fix(welfare): 不活跃验证脚本首页可见事件 show+home → home_visible
seed_inactivity_cases.py 仍发 event=show + page=home,改名 home_visible 后
active_event_condition 不再认它,"活跃兜底"用例会被误清/误警。改用共享常量
activity.HOME_VISIBLE_EVENT(单一 event 名,去掉 page),防止再次漂移。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 13:19:45 +08:00
guke 927f1f92f8 feat(welfare): 确认首页可见事件名 home_visible + 修测试手机号跨文件冲突
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 12:02:15 +08:00
guke 16414f1744 Merge branch 'main' into feat/inactivity-reset
main 已通过 #143 合入本功能的早期版本;拉 main 时功能文件(config / worker / inactivity /
tests / spec)冲突,一律以本分支为准(含 home_view→show+page=home、活跃口径复合索引、
迁移多头 merge、worker 常驻 + 默认 dry-run 等最新改动)。无关代码走 main 的自动合并。
alembic 单头、全量测试无新增失败。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:01:23 +08:00
guke 0cc2e9dec8 feat(welfare): 不活跃清零 worker 常驻 + 默认 dry-run(ENABLED=false 只记审计不清)
改 INACTIVITY_RESET_ENABLED 语义:worker 不再"关=不启动"、改为**常驻**;ENABLED 只控是否**真清**——
false(默认)= 只写审计名单(reason=inactive_Nd_dryrun)、不动钱不写流水、不预警(dry-run 灰度看名单);
true = 真清金币+现金 + 预警。

- clear_user(dry_run):写审计跳出账,按 streak 去重(reset_at>last_active)不重复记
- run_reset_once/run_once:透传 dry_run;dry-run 抑制预警(不通知一个不会发生的清零)
- worker:start 常驻(去掉 ENABLED 启动闸);_run_once_entry dry_run=not ENABLED;启动日志标 mode
- 测试:dry-run 只记审计/不清/不预警/dedup + worker ENABLED=false→dry-run;去掉过时 disabled 用例
- config/worker 注释 + spec §6/§13/误清防护 同步新语义

测试安全:lifespan 在测试里不跑(TestClient 非 with 形式),worker 常驻不影响用例。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 23:39:50 +08:00
guke 5fdf2b1ad3 chore(alembic): 合并 analytics_active_idx + phone_rebind_log 两 head(修复多头)
既有多头——135e79414fd0(不活跃两表)与 phone_rebind_log 同从 comparison_llm_cost 分叉——
导致 `alembic upgrade head` 多头报错。加一个空 merge 修订汇合两头,upgrade head 恢复单头正常。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:55:20 +08:00
guke fd1c1a5abc perf(analytics): analytics_event 活跃口径复合索引 (event,page,user_id,created_at)
active_event_condition 按 (event=show & page=home) ∪ 比价 ∪ 领券 过滤后 group by user_id、
max(created_at),是 worker 清零/预警 + admin 最近活跃的热点聚合;page 原无索引、show 又高频。
加覆盖索引 ix_analytics_event_active 让该聚合走 index-only。

⚠️ 分支迁移树既有多头(135e79414fd0 与 phone_rebind_log 同从 comparison_llm_cost 分叉,
`alembic upgrade head` 会多头报错)——既有问题、与本次无关。本迁移挂在 135e79414fd0 一侧,
dev 用 `alembic upgrade analytics_active_idx` 应用;集成 main 时需 alembic merge 合并 phone_rebind_log 头。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:31:15 +08:00
guke b4fe20aaca feat(welfare): 首页可见活跃信号定名 event=show + page=home(替换 home_view 占位)
前端确定"首页可见"埋点为 event=show + page=home(组合,非单一 event 名),故活跃口径
不能只改一个常量:

- activity.active_event_condition():首页可见(event=show & page=home)∪ 比价 ∪ 领券,
  worker 子查询(last_active_subqueries)与 admin _attach_last_active 共用(单一真源);
  ACTIVE_EVENTS 收窄为仅两个纯 event 名,HOME_VIEW_EVENT="show" + 新增 HOME_VIEW_PAGE="home"
- 测试:新增 show/home 算活跃、show/其他页不算的用例;_add_event 支持 page;口径常量断言更新
- seed 脚本 / spec / plan 同步 home_view → show+page=home

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:04:29 +08:00
guke 69a79f9852 Merge branch 'main' of https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server into feat/inactivity-reset 2026-07-18 17:34:36 +08:00
guke aeaf4b1fd1 chore(scripts): 不活跃清零 人工验证造号脚本(排列组合 + 审计核对)
按金币/现金/邀请排列组合 + 活跃/新用户对照造号;--check 核对 worker 清零/预警结果、
--clean 清理。仅本地验证用,不参与生产逻辑。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:33:14 +08:00
guke ece498876f docs(welfare): 不活跃清零 设计/计划对齐"邀请金不清" + inactivity 两表字典
- spec/plan:清零范围改两桶、run_warn 逐用户隔离不阻塞清零、stats 增 warn_failed
- docs/database:新增 inactivity_reset_log / inactivity_notification_log 条目 + README 索引

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:33:04 +08:00
guke 40c99afd92 feat(welfare): 完成不活跃清零-邀请金不清 + 预警编排 + worker + admin口径统一
- 清零改两桶(金币 + 折算现金);邀请奖励金物理隔离、不清(产品红线),仅快照入审计
- 预警编排 select_warn_candidates / run_warn_once / run_once(分档 + streak 去重);
  notifier.warn 去掉 invite 参数(邀请金不清、不预警)
- 预警逐用户 try/except 隔离 + run_once 保证预警故障绝不阻塞清零(接真推送前必备)
- 每日 worker inactivity_reset_worker(文件锁 + 北京日守卫 + RUN_HOUR 门槛 + 总闸默认关)
  + main.py lifespan 接线
- admin 最近活跃口径改用共享 activity(移除 last_login_at、纳入 home_view 占位)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:32:52 +08:00
guke eb15018322 test(welfare): 不活跃测试加 autouse 隔离夹具(清零余额+清活跃事件/审计),去掉临时 band-aid
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 19:13:25 +08:00
guke a6cfef64a8 feat(welfare): 不活跃清零-选取与逐用户清零(三桶归零+审计+流水) 2026-07-16 18:54:40 +08:00
guke 7802aeb163 feat(welfare): 可插拔不活跃预警通知器 + LogNotifier 占位
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 18:47:50 +08:00
guke f843e68d0f feat(welfare): INACTIVITY_* 配置项 + warn stages 解析
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 18:47:02 +08:00
guke 108c175142 feat(welfare): 活跃口径共享模块-子查询与 last_active_expr 2026-07-16 18:40:36 +08:00
guke e6f467a935 feat(welfare): 活跃口径共享模块-常量与北京0点cutoff助手 2026-07-16 18:39:51 +08:00
guke 708e454959 feat(welfare): 迁移新增 inactivity_reset_log / inactivity_notification_log 两表
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-16 18:34:07 +08:00
guke 0ec6b2bc56 feat(welfare): inactivity_reset_log + inactivity_notification_log 模型 2026-07-16 18:23:20 +08:00
guke 23e4671312 docs(welfare): 15天不活跃清零 实现计划(11 任务, TDD 分解)
从已定稿 spec 拆出可执行计划:模型/迁移/活跃口径共享模块/配置/通知器/清零+预警业务逻辑/
每日 worker/admin 重构/表字典。每任务含失败测试→实现→通过→提交的 bite-sized 步骤,含完整代码。
活跃口径 = created_at 基线 + home_view/比价/领券(不含 last_login_at);home_view 事件名以
HOME_VIEW_EVENT 常量占位(前端明天定名);总闸默认关。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 18:16:47 +08:00
guke b3ed66d4eb docs(welfare): 活跃口径移除 last_login_at 改用 created_at 基线;home_view 事件名待定
按评审结论定稿:
- ① 彻底移除 last_login_at:活跃口径 = max(home_view, 比价, 领券);登录/re-login 不再计为活跃
- 连带:用 User.created_at 接替"恒非空基线"位(注册即第1日活跃,只有真实使用才推进)。
  无任何信号的用户 = 注册满 15 天即清,新用户有 15 天宽限
- ② home_view 事件名待前端明天加埋点时定,后端以 HOME_VIEW_EVENT 常量占位
- 连带影响:共享口径 admin 侧同步(移除 last_login_at + created_at 基线 + 纳入 home_view),
  admin 最近活跃/DAU 口径随之变化,回归测试按新口径更新预期
- 同步 §3/§4/§9/§10/§11/§12/§13/§14

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:58:45 +08:00
guke c9c35a4350 docs(welfare): 明确不活跃清零的时间边界(北京自然日, 第16日0点)
补充清零日界定义:末次活跃日记为第1日,从末次活跃起「第16日0点」(北京)清零
= 北京 00:00 of (last_active_date + 15天),按自然日 0 点对齐,非从末次活跃时刻滚动 15×24h。
- 应清零 ⟺ (cn_today() − last_active_date).days ≥ RESET_DAYS
- SQL cutoff = 北京 00:00 of (cn_today() − (RESET_DAYS−1)),新增 activity.reset_cutoff() 辅助
- 资格边界(第16日0点)与 worker 执行点 RUN_HOUR 解耦;inactive_days 改按北京自然日
- 同步更新 §4/§6/§8/§10

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:50:03 +08:00
guke 5f1d1a5e5f docs(welfare): 15天不活跃清零金币/现金 设计文档(spec)
连续15天不活跃自动清零金币+折算现金+邀请现金,清零前可配置节奏预警,全程留审计以备纠纷排查。

关键决策:
- 活跃口径复用"用户管理"并抽共享模块 activity.py(admin 一并重构);新增 home_view 埋点代表"进首页",last_login_at 仅作灰度期兜底
- 审计双写:inactivity_reset_log 专表(记清零前三桶余额)+ 钱包流水 biz_type=inactivity_reset
- 预警:可插拔通知器 + 日志占位(v1),后续接 JPush/短信
- 触发:进程内每日 worker(仿 daily_exchange_worker),总闸默认关

仅设计文档,尚未实现。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 17:39:10 +08:00
9 changed files with 15 additions and 252 deletions
+7 -40
View File
@@ -33,29 +33,7 @@ from app.admin.repositories import stats as admin_stats
from app.core import rewards
from app.models.ad_ecpm import AdEcpmRecord
from app.models.user import User
from app.repositories import ad_pangle_revenue, app_config
# 已上线过的正式业务代码位要永久保留,避免运营切换当前配置后,历史报表把旧业务位误判成测试流量。
_KNOWN_PROD_BUSINESS_CODE_IDS = frozenset({"104098712", "104099389"})
# 测试应用中实际承载业务链路的代码位。广告测试 demo 的插屏/半屏/信息流测试位不在这里,
# 避免“业务口径”把开发诊断曝光混进客户端与穿山甲对账。
_TEST_BUSINESS_CODE_IDS = frozenset({"104127529", "104127626", "104137445"})
def _business_code_ids(db: Session, app_env: str | None) -> set[str]:
"""返回指定应用环境下可用于业务收益对账的 GroMore 聚合代码位。"""
prod_config = app_config.get_ad_config(db)
prod_ids = set(_KNOWN_PROD_BUSINESS_CODE_IDS) | {
str(prod_config.get(key) or "").strip()
for key in ("reward_code_id", "compare_draw_code_id", "coupon_draw_code_id")
}
prod_ids.discard("")
if app_env == "prod":
return prod_ids
if app_env == "test":
return set(_TEST_BUSINESS_CODE_IDS)
return prod_ids | set(_TEST_BUSINESS_CODE_IDS)
from app.repositories import ad_pangle_revenue
def _cn_hour(dt: datetime) -> int:
@@ -104,7 +82,6 @@ def ad_revenue_report(
ad_type: str | None = None,
feed_scene: str | None = None,
app_env: str | None = None,
revenue_scope: str = "all",
granularity: str = "day",
limit: int = 500,
offset: int = 0,
@@ -300,18 +277,14 @@ def ad_revenue_report(
if feed_scene is not None:
events = [e for e in events if e.get("feed_scene") == feed_scene]
# app_env 过滤:显式传 "prod"/"test" 只看该环境;不传=全部。该参数也会传给下方穿山甲聚合,
# 保证客户端预估与 GroMore 汇总使用同一应用环境口径。
# app_env 过滤(2026-06-29 新增能力,修隐患:测试应用上报的假 eCPM 如 ¥678 CPM 会污染正式收益合计/平均):
# 显式传 "prod"/"test" 只看该环境;不传=全部(维持现状)。**不擅自把默认改成排除 test**——本地 dev 库多为
# test 数据、默认排除会使本地报表空,且「正式报表是否含 test」属产品口径。建议前端报表页加 app_env 筛选器
# (默认选 prod),或产品确认后再把默认改成排除 test。注:穿山甲后台收益列(total_pangle_*)暂未联动此过滤
# (它是独立对照列,且 pangle 的 test 是真实小额、非客户端那种假值)。
if app_env is not None:
events = [e for e in events if e.get("app_env") == app_env]
# 业务口径仅保留正式配置/测试业务链路实际使用的代码位。穿山甲“全量”还包含广告测试
# demo、插屏等没有客户端收益上报的曝光,两边直接比较会天然产生假差额。
business_code_ids: set[str] | None = None
if revenue_scope == "business":
business_code_ids = _business_code_ids(db, app_env)
events = [e for e in events if e.get("our_code_id") in business_code_ids]
# 排序:time=按时间倒序(新→旧);ecpm=按 eCPM 数值倒序(eCPM 原值是字符串「分」,转数值排;
# 纯发奖行用其发奖采用的 eCPM,缺失/非法计 0 排末尾)。
if sort == "ecpm":
@@ -363,13 +336,7 @@ def ad_revenue_report(
total_pangle_revenue_yuan: float | None = None
total_pangle_api_revenue_yuan: float | None = None
if pangle_filterable:
pangle_aggs = ad_pangle_revenue.aggregate_by_date(
db,
date_from=date_from,
date_to=date_to,
app_env=app_env,
our_code_ids=business_code_ids,
)
pangle_aggs = ad_pangle_revenue.aggregate_by_date(db, date_from=date_from, date_to=date_to)
if pangle_aggs:
by_date = {a["date"]: a for a in pangle_aggs}
for d in daily:
+1 -9
View File
@@ -5,7 +5,7 @@
from __future__ import annotations
from datetime import date as _date
from typing import Annotated, Literal
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -63,13 +63,6 @@ def get_ad_revenue_report(
"建议正式收益报表选 prod,避免测试应用的假 eCPM 污染收益合计/平均"
),
] = None,
revenue_scope: Annotated[
Literal["business", "all"],
Query(
description="business=仅业务代码位(用于客户端与穿山甲同口径对账)/ "
"all=穿山甲应用全部代码位(包含广告测试等非业务曝光)"
),
] = "all",
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京时间);区间>1 天建议用 day")
] = "day",
@@ -90,7 +83,6 @@ def get_ad_revenue_report(
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, feed_scene=feed_scene, app_env=app_env,
revenue_scope=revenue_scope,
granularity=granularity, limit=limit, offset=offset, sort=sort,
)
return AdRevenueReportOut(
+3 -7
View File
@@ -281,10 +281,7 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。
"""
attributed_trace_id = crud_ecpm.attributable_trace_id(
db,
feed_scene=payload.feed_scene,
trace_id=payload.trace_id,
exposure_ms=payload.exposure_ms,
db, feed_scene=payload.feed_scene, trace_id=payload.trace_id
)
if payload.trace_id and attributed_trace_id is None:
logger.info(
@@ -299,12 +296,11 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm
feed_scene=payload.feed_scene,
trace_id=attributed_trace_id,
app_env=payload.app_env, our_code_id=payload.our_code_id,
exposure_ms=payload.exposure_ms,
)
logger.info(
"ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s exposure_ms=%s adn=%s slot=%s app=%s code=%s",
"ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s adn=%s slot=%s app=%s code=%s",
user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id, payload.ecpm,
payload.exposure_ms, payload.adn, payload.slot_id, payload.app_env, payload.our_code_id,
payload.adn, payload.slot_id, payload.app_env, payload.our_code_id,
)
return EcpmReportOut(ok=True)
+3 -22
View File
@@ -15,22 +15,9 @@ from app.core.rewards import cn_today
from app.models.ad_ecpm import AdEcpmRecord
from app.models.coupon_state import CouponSession
MIN_REVENUE_EXPOSURE_MS = 1000
def effective_ecpm_raw(ecpm_raw: str, exposure_ms: int | None) -> str:
"""曝光不足一秒时保留展示记录,但把该条有效 eCPM 归零。"""
if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS:
return "0"
return ecpm_raw
def attributable_trace_id(
db: Session,
*,
feed_scene: str | None,
trace_id: str | None,
exposure_ms: int | None = None,
db: Session, *, feed_scene: str | None, trace_id: str | None
) -> str | None:
"""返回广告展示允许归属的业务 trace。
@@ -44,12 +31,7 @@ def attributable_trace_id(
session_status = db.execute(
select(CouponSession.status).where(CouponSession.trace_id == trace_id)
).scalar_one_or_none()
if session_status not in {"failed", "abandoned"}:
return trace_id
# 已真实上墙但不足一秒的曝光要在终态明细中明确显示 0,而不是被误判成“未填充”。
if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS:
return trace_id
return None
return None if session_status in {"failed", "abandoned"} else trace_id
def create_ecpm_record(
@@ -65,7 +47,6 @@ def create_ecpm_record(
trace_id: str | None = None,
app_env: str | None = None,
our_code_id: str | None = None,
exposure_ms: int | None = None,
) -> AdEcpmRecord:
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。
@@ -86,7 +67,7 @@ def create_ecpm_record(
trace_id=trace_id,
app_env=app_env,
our_code_id=our_code_id,
ecpm_raw=effective_ecpm_raw(ecpm_raw, exposure_ms),
ecpm_raw=ecpm_raw,
report_date=cn_today().isoformat(),
)
db.add(rec)
-4
View File
@@ -6,7 +6,6 @@
"""
from __future__ import annotations
from collections.abc import Collection
from typing import Any, TypedDict
from sqlalchemy import func, select
@@ -74,7 +73,6 @@ def aggregate_by_date(
date_to: str,
app_env: str | None = None,
our_code_id: str | None = None,
our_code_ids: Collection[str] | None = None,
) -> list[PangleDateAgg]:
"""按日期汇总穿山甲收益(闭区间,北京时间),供报表趋势 + 合计。
@@ -99,8 +97,6 @@ def aggregate_by_date(
stmt = stmt.where(AdPangleDailyRevenue.app_env == app_env)
if our_code_id is not None:
stmt = stmt.where(AdPangleDailyRevenue.our_code_id == our_code_id)
if our_code_ids is not None:
stmt = stmt.where(AdPangleDailyRevenue.our_code_id.in_(our_code_ids))
out: list[PangleDateAgg] = []
for report_date, rev, api_rev, imp in db.execute(stmt).all():
-6
View File
@@ -69,12 +69,6 @@ class EcpmReportIn(BaseModel):
description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券,"
"供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空",
)
exposure_ms: int | None = Field(
None,
ge=0,
le=86_400_000,
description="本条广告真实在屏曝光毫秒数;小于 1000ms 时收益强制按 0 计算。旧客户端不传则保持原口径",
)
app_env: str | None = Field(
None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)"
)
+1 -3
View File
@@ -28,8 +28,6 @@
| `user_id` | int | 全部 | 只看某用户;不传=所有用户 |
| `ad_type` | string | 全部 | `reward_video` / `feed` / `draw`;不传=全部类型 |
| `feed_scene` | string | 全部 | `comparison`(比价)/ `coupon`(领券)/ `welfare`(福利);**全局筛选**,同时作用于明细 / 合计 / `daily`·`hourly` 趋势;不传=全部场景 |
| `app_env` | string | 全部 | `prod`=正式应用 / `test`=测试应用;同时过滤客户端预估与穿山甲汇总 |
| `revenue_scope` | string | `all` | `business`=仅业务代码位,用于同口径对账 / `all`=应用全部代码位,包含广告测试等非业务曝光 |
| `granularity` | string | `day` | `day`=按天 / `hour`=按小时(聚合键再加北京时间小时 0–23);**区间>1 天建议用 day** |
| `limit` | int(1~1000) | 500 | **每页条数**(分页大小);`total`/`total_*`/`daily`/`hourly` 按全量统计不受分页影响 |
| `offset` | int(≥0) | 0 | 分页偏移(已跳过条数)=(页码−1)×`limit` |
@@ -133,5 +131,5 @@
- **历史 Draw 不可拆**:迁移(Draw→普通信息流)前,Draw 发奖混在 `ad_feed_reward_record` 且无类型标记,金币侧统一记 `feed`;迁移后 Draw 不再产生新数据。展示侧 `ad_type` 由客户端上报区分,故 `draw` 桶基本为空。
- **来源字段从上线起齐全**:`app_env`/`our_code_id` 是本期新增列,历史记录为 NULL(报表来源列留空)。
- **逐条/明细的收益是预估**:`items[].revenue_yuan` 基于客户端上报的 eCPM 折算,非穿山甲后台结算值。
- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在未按 user/类型/场景过滤展示。`app_env``revenue_scope` 会同时过滤客户端和穿山甲数据,其中 `business` 排除广告测试等非业务代码位。配置见 `.env``PANGLE_REPORT_*`
- **穿山甲后台收益(汇总/趋势级)**:`total_pangle_revenue_yuan`(预估 `revenue`)与 `total_pangle_api_revenue_yuan`(收益Api `api_revenue`,更接近结算)来自穿山甲 **GroMore 数据 API**(`integrations/pangle_report` + `scripts/sync_pangle_revenue` 按天 T+1 拉取入 [ad_pangle_daily_revenue](../database/ad_pangle_daily_revenue.md))。穿山甲**不提供分用户/设备/类型/场景维度**(官方明确),最细到 日期×应用×代码位,故只用于汇总与按天趋势的对照,**不挂到逐条事件行**;且仅在全量视图(未按 user/类型/场景过滤)展示。配置见 `.env``PANGLE_REPORT_*`
- **对账聚合级 + 逐条下钻**:行级 `matched` 给出该组(用户×类型×应用×代码位)应发是否==实发;**展开 `records` 即可看该组逐条明细**(eCPM/因子1/份数/LT/因子2/应发/实发/一致)定位到具体记录。独立逐条审计接口 [admin-ad-coin-audit](./admin-ad-coin-audit.md) 仍保留(同一复算口径,可全局按场景/只看不符筛选)。
-32
View File
@@ -58,32 +58,6 @@ def test_revenue_yuan_by_trace_empty() -> None:
db.close()
def test_short_exposure_keeps_record_with_zero_revenue() -> None:
"""不足一秒仍落展示记录,以便后台显示 0 而不是未填充。"""
db = SessionLocal()
try:
rec = crud_ecpm.create_ecpm_record(
db, 1, ad_type="draw", ecpm_raw="350",
ad_session_id="sess-short-exposure", feed_scene="coupon",
trace_id="trace-short-exposure", exposure_ms=999,
)
assert rec.ecpm_raw == "0"
assert crud_ecpm.revenue_yuan_by_trace(db, ["trace-short-exposure"]) == {
"trace-short-exposure": 0.0
}
finally:
db.execute(delete(AdEcpmRecord).where(
AdEcpmRecord.ad_session_id == "sess-short-exposure"
))
db.commit()
db.close()
def test_one_second_exposure_keeps_original_ecpm() -> None:
assert crud_ecpm.effective_ecpm_raw("350", 1000) == "350"
assert crud_ecpm.effective_ecpm_raw("350", None) == "350"
def test_terminal_coupon_trace_is_not_attributable_to_late_impression() -> None:
"""领券失败或被放弃后才到达的广告展示保留收益记录,但不再关联死亡 trace。"""
db = SessionLocal()
@@ -106,12 +80,6 @@ def test_terminal_coupon_trace_is_not_attributable_to_late_impression() -> None:
assert crud_ecpm.attributable_trace_id(
db, feed_scene="coupon", trace_id="abandoned-before-ad"
) is None
assert crud_ecpm.attributable_trace_id(
db, feed_scene="coupon", trace_id="failed-before-ad", exposure_ms=999
) == "failed-before-ad"
assert crud_ecpm.attributable_trace_id(
db, feed_scene="coupon", trace_id="abandoned-before-ad", exposure_ms=999
) == "abandoned-before-ad"
assert crud_ecpm.attributable_trace_id(
db, feed_scene="comparison", trace_id="failed-before-ad"
) == "failed-before-ad"
-129
View File
@@ -1,129 +0,0 @@
"""广告收益报表的环境与业务代码位口径。"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import delete
from app.admin.repositories import ad_revenue
from app.db.session import SessionLocal
from app.models.ad_ecpm import AdEcpmRecord
from app.models.ad_pangle_revenue import AdPangleDailyRevenue
from app.models.user import User
REPORT_DATE = "2040-02-03"
def test_business_scope_filters_client_and_pangle_by_env_and_code(monkeypatch) -> None:
db = SessionLocal()
try:
user = User(
phone="18800009991",
username="29999999991",
register_channel="sms",
)
db.add(user)
db.flush()
db.add_all([
AdEcpmRecord(
user_id=user.id, ad_type="reward_video", ad_session_id="scope-prod-business",
app_env="prod", our_code_id="prod-reward", ecpm_raw="10000",
report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC),
),
AdEcpmRecord(
user_id=user.id, ad_type="draw", ad_session_id="scope-prod-demo",
app_env="prod", our_code_id="prod-demo", ecpm_raw="20000",
report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC),
),
AdEcpmRecord(
user_id=user.id, ad_type="draw", ad_session_id="scope-prod-known-business",
app_env="prod", our_code_id="104098712", ecpm_raw="40000",
report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC),
),
AdEcpmRecord(
user_id=user.id, ad_type="reward_video", ad_session_id="scope-test-business",
app_env="test", our_code_id="104127529", ecpm_raw="30000",
report_date=REPORT_DATE, created_at=datetime(2040, 2, 3, tzinfo=UTC),
),
AdPangleDailyRevenue(
report_date=REPORT_DATE, app_env="prod", our_code_id="prod-reward",
adn="", revenue_yuan=1.5, api_revenue_yuan=1.2, impressions=10,
),
AdPangleDailyRevenue(
report_date=REPORT_DATE, app_env="prod", our_code_id="prod-demo",
adn="", revenue_yuan=8.0, api_revenue_yuan=7.0, impressions=40,
),
AdPangleDailyRevenue(
report_date=REPORT_DATE, app_env="prod", our_code_id="104098712",
adn="", revenue_yuan=2.5, api_revenue_yuan=2.0, impressions=20,
),
AdPangleDailyRevenue(
report_date=REPORT_DATE, app_env="test", our_code_id="104127529",
adn="", revenue_yuan=9.0, api_revenue_yuan=8.0, impressions=50,
),
])
db.commit()
monkeypatch.setattr(
ad_revenue.app_config,
"get_ad_config",
lambda _db: {
"reward_code_id": "prod-reward",
"compare_draw_code_id": "prod-draw",
"coupon_draw_code_id": "prod-draw",
},
)
business = ad_revenue.ad_revenue_report(
db,
date_from=REPORT_DATE,
date_to=REPORT_DATE,
app_env="prod",
revenue_scope="business",
)
assert business["total_impressions"] == 2
assert business["total_revenue_yuan"] == 0.5
assert business["total_pangle_revenue_yuan"] == 4.0
assert business["total_pangle_api_revenue_yuan"] == 3.2
all_codes = ad_revenue.ad_revenue_report(
db,
date_from=REPORT_DATE,
date_to=REPORT_DATE,
app_env="prod",
revenue_scope="all",
)
assert all_codes["total_impressions"] == 3
assert all_codes["total_revenue_yuan"] == 0.7
assert all_codes["total_pangle_revenue_yuan"] == 12.0
assert all_codes["total_pangle_api_revenue_yuan"] == 10.2
test_business = ad_revenue.ad_revenue_report(
db,
date_from=REPORT_DATE,
date_to=REPORT_DATE,
app_env="test",
revenue_scope="business",
)
assert test_business["total_impressions"] == 1
assert test_business["total_revenue_yuan"] == 0.3
assert test_business["total_pangle_revenue_yuan"] == 9.0
assert test_business["total_pangle_api_revenue_yuan"] == 8.0
all_env_business = ad_revenue.ad_revenue_report(
db,
date_from=REPORT_DATE,
date_to=REPORT_DATE,
app_env=None,
revenue_scope="business",
)
assert all_env_business["total_impressions"] == 3
assert all_env_business["total_revenue_yuan"] == 0.8
assert all_env_business["total_pangle_revenue_yuan"] == 13.0
assert all_env_business["total_pangle_api_revenue_yuan"] == 11.2
finally:
db.rollback()
db.execute(delete(AdPangleDailyRevenue).where(AdPangleDailyRevenue.report_date == REPORT_DATE))
db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.report_date == REPORT_DATE))
db.execute(delete(User).where(User.phone == "18800009991"))
db.commit()
db.close()