From 80e0fb90dbee3bbcc4c7af58b08f8e9f90d847c9 Mon Sep 17 00:00:00 2001 From: guke Date: Fri, 10 Jul 2026 12:18:20 +0800 Subject: [PATCH 1/6] docs: plan for per-session ad revenue in admin boards Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-10-per-session-ad-revenue.md | 830 ++++++++++++++++++ 1 file changed, 830 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-per-session-ad-revenue.md diff --git a/docs/superpowers/plans/2026-07-10-per-session-ad-revenue.md b/docs/superpowers/plans/2026-07-10-per-session-ad-revenue.md new file mode 100644 index 0000000..2dd363f --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-per-session-ad-revenue.md @@ -0,0 +1,830 @@ +# 逐次比价/领券广告收益 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 让 admin「领券数据」和「比价记录」两个看板的 table 每一行显示这一次领券/比价产生的广告收益(预估元)。 + +**Architecture:** 客户端在信息流(Draw)展示上报 eCPM 时带上本场 `trace_id`,后端落到 `ad_ecpm_record.trace_id`(新列 + 索引)。两个看板在分页后,对**当前页**的 trace_id 批量聚合一次 `ad_ecpm_record` 的展示收益(单条收益 = min(eCPM元,¥500)/1000,与广告收益报表同口径),挂到每行。查询是按 trace_id 索引的单条聚合,与已上线的 `_ad_coins_by_trace`(比价记录页「比价赚N金币」)同一性能剖面。 + +**Tech Stack:** 后端 FastAPI + SQLAlchemy 2.0 + Alembic;客户端 Android(Kotlin/OkHttp);admin 前端 Next.js + React + Ant Design(shaguabijia-admin-web)。 + +--- + +## 背景与约束(执行前必读) + +- **收益 ≠ 金币**。本功能查的是「我们赚的广告收益」(数据源 `ad_ecpm_record`,客户端自报 eCPM 折算的预估),不是发给用户的金币(那是 `ad_feed_reward_record`,已有 `trace_id`)。 +- **只能到「场景 + 单次」粒度**。`trace_id` 由客户端在比价(comparisonTraceId)/领券(sessionTraceId)全流程保持不变。激励视频、福利页、旧客户端不带 trace_id → 该列为 NULL,历史数据无法回填,只对升级后新数据生效。 +- **收益口径**(与 `app/admin/repositories/ad_revenue.py:165-167` 完全一致): + 单条展示收益(元) = `min(parse_ecpm_yuan(ecpm_raw), AD_ECPM_MAX_FEN/100) / 1000` + 其中 `AD_ECPM_MAX_FEN = 50000`(分)= ¥500 CPM 封顶,`parse_ecpm_yuan(x) = parse_ecpm_fen(x)/100`。 +- **性能前提**:`ad_ecpm_record` 是全库写入量最大的表。必须有 `ix_ad_ecpm_record_trace_id` 索引(本计划 Task A2 建),且只对**当前页**的 trace_id 聚合,绝不对整个日期区间聚合。 +- **跨仓库**:本计划涉及三个仓库,路径前缀: + - 后端 `e:\project\shaguabijia-app-server`(相对路径即以此为根) + - 客户端 `E:\project\shaguabijia-app-android` + - admin 前端 `e:\project\shaguabijia-admin-web` + +--- + +## File Structure + +### 后端(shaguabijia-app-server) +- Modify `app/models/ad_ecpm.py` — `AdEcpmRecord` 加 `trace_id` 列(索引) +- Create `alembic/versions/ad_ecpm_trace_id.py` — 加列 + 索引,并收敛当前双 head +- Modify `app/schemas/ad.py` — `EcpmReportIn` 加 `trace_id` 字段 +- Modify `app/repositories/ad_ecpm.py` — `create_ecpm_record` 持久化 `trace_id`;新增 `revenue_yuan_by_trace` 聚合器 +- Modify `app/api/v1/ad.py` — `ecpm_report` 透传 `trace_id` +- Modify `app/admin/schemas/coupon_data.py` — `CouponDataRow` 加 `ad_revenue_yuan` +- Modify `app/admin/repositories/coupon_data.py` — 逐页补 `ad_revenue_yuan` +- Modify `app/admin/schemas/comparison.py` — `AdminComparisonListItem` 加 `ad_revenue_yuan` +- Modify `app/admin/repositories/queries.py` — `list_comparison_records` 逐页补 `ad_revenue_yuan` +- Create `tests/test_ad_ecpm_trace_revenue.py` — 聚合器 + 落库单测 +- Create `tests/test_board_ad_revenue.py` — 两个看板收益列单测 + +> `app/models/__init__.py` **不需改**:`AdEcpmRecord` 已注册,只是加列。 + +### 客户端(shaguabijia-app-android) +- Modify `app/src/main/java/com/jishisongfu/shaguabijia/agent/network/ApiClient.kt` — `reportAdImpression` 加 `traceId` 参数 +- Modify `app/src/main/java/com/jishisongfu/shaguabijia/agent/service/ad/CompareAdController.kt` — 比价展示上报带 `traceId` +- Modify `app/src/main/java/com/jishisongfu/shaguabijia/service/CouponForegroundService.kt` — 领券展示上报带 `traceId` + +### admin 前端(shaguabijia-admin-web) +- Modify `src/lib/types.ts` — `ComparisonRecordListItem` 加 `ad_revenue_yuan` +- Modify `src/app/(main)/comparison-records/page.tsx` — 加「广告收益」列 +- Modify `src/app/(main)/coupon-data/page.tsx` — `CouponDataRow` 加字段 + 加「广告收益」列 + +--- + +## Phase A — 后端数据打通(落 trace_id + 收益聚合器) + +### Task A1: `AdEcpmRecord` 加 `trace_id` 列 + +**Files:** +- Modify: `app/models/ad_ecpm.py` + +- [ ] **Step 1: 加列** + +在 `app/models/ad_ecpm.py` 中,找到 `feed_scene` 这一行: + +```python + feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True) +``` + +在其**下方**插入: + +```python + # 本次比价/领券 trace_id(信息流场景客户端带上):把这条展示收益归属到对应比价/领券记录。 + # 领券数据 / 比价记录看板按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端 = NULL。 + trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) +``` + +- [ ] **Step 2: 提交** + +```bash +git add app/models/ad_ecpm.py +git commit -m "feat(ad-ecpm): add trace_id column to AdEcpmRecord model" +``` + +--- + +### Task A2: 迁移 — 加列 + 索引,并收敛双 head + +**Files:** +- Create: `alembic/versions/ad_ecpm_trace_id.py` + +> ⚠️ 当前 `alembic heads` 有**两个 head**:`11c44afbea58`(selfstat 表)与 `merge_pages_override_coupon_slot`(#126+领券合并)。本迁移用元组 `down_revision` 把二者收敛成单 head,同时加列,让 `alembic upgrade head`(单数,run.sh 用)恢复正常。 + +- [ ] **Step 1: 确认当前 heads 未漂移** + +Run: `alembic heads` +Expected: 恰好两行 — +``` +11c44afbea58 (head) +merge_pages_override_coupon_slot (head) +``` +若不同(他人已合并/新增),把下面 `down_revision` 改成此刻实际的 head 列表。 + +- [ ] **Step 2: 建迁移文件** + +Create `alembic/versions/ad_ecpm_trace_id.py`: + +```python +"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace)+ 收敛双 head + +信息流(Draw)展示 eCPM 上报时带上本场比价/领券 trace_id,落此列;领券数据 / 比价记录看板 +按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端为 NULL。 + +顺带把当前两个 head(11c44afbea58 selfstat 表 + merge_pages_override_coupon_slot)收敛成 +单 head,让 `alembic upgrade head`(单数,部署/run.sh 用)恢复正常。 + +Revision ID: ad_ecpm_trace_id +Revises: 11c44afbea58, merge_pages_override_coupon_slot +Create Date: 2026-07-10 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "ad_ecpm_trace_id" +down_revision: Union[str, Sequence[str], None] = ( + "11c44afbea58", + "merge_pages_override_coupon_slot", +) +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # SQLite 下 ADD COLUMN(可空)与 CREATE INDEX 均原生支持,无需 batch_alter_table + # (同 ad_feed_reward_trace_id 迁移)。 + op.add_column( + "ad_ecpm_record", + sa.Column("trace_id", sa.String(length=64), nullable=True), + ) + op.create_index( + op.f("ix_ad_ecpm_record_trace_id"), + "ad_ecpm_record", + ["trace_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_ad_ecpm_record_trace_id"), table_name="ad_ecpm_record") + op.drop_column("ad_ecpm_record", "trace_id") +``` + +- [ ] **Step 3: 应用迁移** + +Run: `alembic upgrade head` +Expected: 无报错(不再报 "multiple heads")。 + +- [ ] **Step 4: 验证单 head + 列存在** + +Run: `alembic heads` +Expected: 只有一行 `ad_ecpm_trace_id (head)`。 + +Run: `python -c "from sqlalchemy import inspect; from app.db.session import engine; print([c['name'] for c in inspect(engine).get_columns('ad_ecpm_record')])"` +Expected: 输出的列名列表包含 `trace_id`。 + +- [ ] **Step 5: 提交** + +```bash +git add alembic/versions/ad_ecpm_trace_id.py +git commit -m "feat(migration): add ad_ecpm_record.trace_id + index, converge heads" +``` + +--- + +### Task A3: `EcpmReportIn` 加 `trace_id` 字段 + +**Files:** +- Modify: `app/schemas/ad.py` + +- [ ] **Step 1: 加字段** + +在 `app/schemas/ad.py` 的 `EcpmReportIn` 里,找到 `feed_scene` 字段定义(以 `feed_scene: str | None = Field(` 开头的那段)。在该字段**之后**插入: + +```python + trace_id: str | None = Field( + None, + max_length=64, + description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券," + "供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空", + ) +``` + +- [ ] **Step 2: 提交** + +```bash +git add app/schemas/ad.py +git commit -m "feat(ad-schema): EcpmReportIn accepts trace_id" +``` + +--- + +### Task A4: `create_ecpm_record` 持久化 trace_id + 新增 `revenue_yuan_by_trace` + +**Files:** +- Modify: `app/repositories/ad_ecpm.py` +- Test: `tests/test_ad_ecpm_trace_revenue.py` + +- [ ] **Step 1: 写失败测试** + +Create `tests/test_ad_ecpm_trace_revenue.py`: + +```python +"""ad_ecpm_record.trace_id 落库 + 按 trace 聚合广告收益(元)。""" +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import delete + +from app.db.session import SessionLocal +from app.models.ad_ecpm import AdEcpmRecord +from app.repositories import ad_ecpm as crud_ecpm + + +def _ecpm(trace_id: str, ecpm_raw: str, session_id: str) -> AdEcpmRecord: + """构造一条 Draw 展示 eCPM(不 commit;ad_session_id 全局唯一,须各不相同)。""" + return AdEcpmRecord( + user_id=1, + ad_type="draw", + feed_scene="comparison", + ad_session_id=session_id, + ecpm_raw=ecpm_raw, + trace_id=trace_id, + report_date="2020-01-02", + created_at=datetime(2020, 1, 2, tzinfo=UTC), + ) + + +def test_revenue_yuan_by_trace_sums_and_clamps() -> None: + """同一 trace 多条展示求和;收益=min(eCPM元,¥500)/1000;无展示的 trace 不出现。 + + ecpm 200 分→2.0 元/千次→0.002 元/次;300 分→0.003;合计 0.005。 + """ + db = SessionLocal() + try: + db.add_all([ + _ecpm("t1", "200", "sess-t1-a"), + _ecpm("t1", "300", "sess-t1-b"), + _ecpm("t2", "0", "sess-t2-a"), + ]) + db.flush() + rev = crud_ecpm.revenue_yuan_by_trace(db, ["t1", "t2", "t3"]) + assert rev["t1"] == 0.005 + assert rev.get("t2", 0.0) == 0.0 + assert "t3" not in rev # 无展示的 trace 不出现在结果里 + finally: + db.rollback() + db.close() + + +def test_revenue_yuan_by_trace_empty() -> None: + """空 trace 列表直接返回 {}(避免 IN () 非法)。""" + db = SessionLocal() + try: + assert crud_ecpm.revenue_yuan_by_trace(db, []) == {} + finally: + db.close() + + +def test_create_ecpm_record_persists_trace_id() -> None: + """create_ecpm_record 落 trace_id。""" + db = SessionLocal() + try: + rec = crud_ecpm.create_ecpm_record( + db, 1, ad_type="draw", ecpm_raw="150", + ad_session_id="sess-trace-persist", feed_scene="coupon", + trace_id="trace-xyz", + ) + assert rec.trace_id == "trace-xyz" + finally: + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == "sess-trace-persist")) + db.commit() + db.close() +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pytest tests/test_ad_ecpm_trace_revenue.py -q` +Expected: FAIL — `create_ecpm_record` 无 `trace_id` 参数(TypeError)/ `revenue_yuan_by_trace` 不存在(AttributeError)。 + +- [ ] **Step 3: 实现** + +在 `app/repositories/ad_ecpm.py`: + +(a) 顶部 import 区加(与现有 `from app.core.rewards import cn_today` 并列): + +```python +from app.core import rewards +``` + +(b) `create_ecpm_record` 的签名里,在 `feed_scene: str | None = None,` 之后加一行参数: + +```python + trace_id: str | None = None, +``` + +(c) 同函数体内构造 `AdEcpmRecord(...)` 处,在 `feed_scene=feed_scene,` 之后加一行: + +```python + trace_id=trace_id, +``` + +(d) 文件末尾新增聚合器: + +```python +def revenue_yuan_by_trace(db: Session, trace_ids: list[str]) -> dict[str, float]: + """各 trace_id 的广告预估收益(元):按 trace_id 聚合 ad_ecpm_record 的展示收益。 + + 单条展示收益 = min(eCPM元, AD_ECPM_MAX_FEN/100) / 1000(与 admin 广告收益报表同口径)。 + ecpm_raw 是字符串且需逐条钳顶,故取回后 Python 求和(行数=本页各 trace 的展示条数,很小)。 + trace_id 仅信息流(比价/领券)场景客户端带,激励视频/旧数据为 NULL,按 trace_id 过滤天然只算对应场景。 + 只喂**当前页**的 trace_id(≤ 一页条数);空集合直接返回(避免 IN () 非法)。 + """ + if not trace_ids: + return {} + rows = db.execute( + select(AdEcpmRecord.trace_id, AdEcpmRecord.ecpm_raw).where( + AdEcpmRecord.trace_id.in_(trace_ids), + ) + ).all() + cap_yuan = rewards.AD_ECPM_MAX_FEN / 100.0 + out: dict[str, float] = {} + for tid, ecpm_raw in rows: + if not tid: + continue + out[tid] = out.get(tid, 0.0) + min(rewards.parse_ecpm_yuan(ecpm_raw), cap_yuan) / 1000.0 + return {tid: round(v, 6) for tid, v in out.items()} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `pytest tests/test_ad_ecpm_trace_revenue.py -q` +Expected: PASS(3 passed)。 + +- [ ] **Step 5: 提交** + +```bash +git add app/repositories/ad_ecpm.py tests/test_ad_ecpm_trace_revenue.py +git commit -m "feat(ad-ecpm): persist trace_id + revenue_yuan_by_trace aggregator" +``` + +--- + +### Task A5: `ecpm_report` 端点透传 trace_id + +**Files:** +- Modify: `app/api/v1/ad.py` + +- [ ] **Step 1: 透传字段** + +在 `app/api/v1/ad.py` 的 `ecpm_report` 函数里,找到 `crud_ecpm.create_ecpm_record(` 调用,在 `feed_scene=payload.feed_scene,` 之后加一行: + +```python + trace_id=payload.trace_id, +``` + +- [ ] **Step 2: 冒烟验证(手动,可选)** + +启动后端(`./run.sh`),用一个有效用户 JWT 调: + +Run: +```bash +curl -s -X POST http://127.0.0.1:8770/api/v1/ad/ecpm-report \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"ad_type":"draw","ecpm":"200","ad_session_id":"smoke-sess-1","feed_scene":"comparison","trace_id":"smoke-trace-1"}' +``` +Expected: `{"ok":true}`;库里 `ad_ecpm_record` 出现一条 `trace_id='smoke-trace-1'` 的记录。 + +> 端点逻辑是纯透传,已由 A4 的 schema/repo 测试覆盖;此步仅人工确认接线。 + +- [ ] **Step 3: 提交** + +```bash +git add app/api/v1/ad.py +git commit -m "feat(ad-api): ecpm-report forwards trace_id to record" +``` + +--- + +## Phase B — 后端两个看板补收益列 + +### Task B1: 领券数据看板逐行补 `ad_revenue_yuan` + +**Files:** +- Modify: `app/admin/schemas/coupon_data.py` +- Modify: `app/admin/repositories/coupon_data.py` +- Test: `tests/test_board_ad_revenue.py` + +- [ ] **Step 1: 写失败测试** + +Create `tests/test_board_ad_revenue.py`: + +```python +"""两个看板逐行「本次广告收益」(元):按 trace_id 聚合 ad_ecpm_record。""" +from __future__ import annotations + +from datetime import UTC, date, datetime + +from sqlalchemy import delete + +from app.admin.repositories import queries +from app.admin.repositories.coupon_data import coupon_data_report +from app.db.session import SessionLocal +from app.models.ad_ecpm import AdEcpmRecord +from app.models.comparison import ComparisonRecord +from app.models.coupon_state import CouponSession + + +def _ecpm(trace_id: str, ecpm_raw: str, session_id: str, scene: str) -> AdEcpmRecord: + return AdEcpmRecord( + user_id=1, ad_type="draw", feed_scene=scene, ad_session_id=session_id, + ecpm_raw=ecpm_raw, trace_id=trace_id, report_date="2020-01-02", + created_at=datetime(2020, 1, 2, tzinfo=UTC), + ) + + +def test_coupon_data_report_includes_ad_revenue() -> None: + """领券看板明细行带本次广告收益;200+300 分 → 0.005 元。""" + db = SessionLocal() + try: + db.add(CouponSession( + trace_id="rev-cp-1", device_id="d1", status="completed", app_env="prod", + platforms=["meituan-waimai"], platform_success=["meituan-waimai"], + started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2), + )) + db.add_all([ + _ecpm("rev-cp-1", "200", "cp-sess-a", "coupon"), + _ecpm("rev-cp-1", "300", "cp-sess-b", "coupon"), + ]) + db.flush() + res = coupon_data_report(db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod") + row = next(r for r in res["items"] if r["trace_id"] == "rev-cp-1") + assert row["ad_revenue_yuan"] == 0.005 + finally: + db.rollback() + db.close() + + +def test_comparison_list_includes_ad_revenue() -> None: + """比价记录列表项带本次广告收益;200 分 → 0.002 元。 + + 用独有 user_id 过滤,确保本行必落在第一页(避免共享测试库里同 user 记录多、分页把它挤掉)。 + """ + db = SessionLocal() + try: + db.add(ComparisonRecord( + trace_id="rev-cmp-1", user_id=987654, status="success", business_type="food", + created_at=datetime(2020, 1, 2, tzinfo=UTC), + )) + db.add(_ecpm("rev-cmp-1", "200", "cmp-sess-a", "comparison")) + db.commit() + items, _next, _total = queries.list_comparison_records(db, user_id=987654) + row = next(it for it in items if it.trace_id == "rev-cmp-1") + assert row.ad_revenue_yuan == 0.002 + finally: + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.trace_id.in_(["rev-cmp-1"]))) + db.execute(delete(ComparisonRecord).where(ComparisonRecord.trace_id == "rev-cmp-1")) + db.commit() + db.close() +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `pytest tests/test_board_ad_revenue.py -q` +Expected: FAIL — `coupon_data_report` 明细行无 `ad_revenue_yuan` 键(KeyError);`ComparisonRecord` 无 `ad_revenue_yuan`(AttributeError)。 + +- [ ] **Step 3: 领券 schema 加字段** + +在 `app/admin/schemas/coupon_data.py` 的 `CouponDataRow` 里,`trace_url` 字段**之后**加: + +```python + ad_revenue_yuan: float = Field( + 0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record" + ) +``` + +- [ ] **Step 4: 领券 repo 逐页补收益** + +在 `app/admin/repositories/coupon_data.py`: + +(a) import 区(现有 `from app.models.user import User` 附近)加: + +```python +from app.repositories import ad_ecpm as crud_ecpm +``` + +(b) `_session_to_row` 签名改为(加末位参数): + +```python +def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict: +``` + +并在其返回的 dict 里,`"trace_url": r.trace_url,` 之后加一行: + +```python + "ad_revenue_yuan": ad_revenue_yuan, +``` + +(c) 在 `coupon_data_report` 里,找到构造明细的这段: + +```python + items = [] + for r in page: + phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None) + items.append(_session_to_row(r, phone, nickname)) +``` + +替换为(新增 `rev_map` + 传入): + +```python + rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page]) + items = [] + for r in page: + phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None) + items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0))) +``` + +> `coupon_user_records`(手机号抽屉)仍走 `_session_to_row(r)`,`ad_revenue_yuan` 取默认 0.0——抽屉不展示收益列,无需补;字段有默认值故 schema 校验不受影响。 + +- [ ] **Step 5: 跑领券用例确认通过** + +Run: `pytest tests/test_board_ad_revenue.py::test_coupon_data_report_includes_ad_revenue -q` +Expected: PASS。 + +- [ ] **Step 6: 提交** + +```bash +git add app/admin/schemas/coupon_data.py app/admin/repositories/coupon_data.py tests/test_board_ad_revenue.py +git commit -m "feat(admin-coupon-data): per-session ad revenue column" +``` + +--- + +### Task B2: 比价记录看板逐行补 `ad_revenue_yuan` + +**Files:** +- Modify: `app/admin/schemas/comparison.py` +- Modify: `app/admin/repositories/queries.py` + +- [ ] **Step 1: 比价 schema 加字段** + +在 `app/admin/schemas/comparison.py` 的 `AdminComparisonListItem` 里,`created_at: datetime` **之前**加: + +```python + ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上 +``` + +- [ ] **Step 2: 比价 repo 逐页补收益** + +在 `app/admin/repositories/queries.py`: + +(a) import 区加: + +```python +from app.repositories import ad_ecpm +``` + +(b) 在 `list_comparison_records` 里,找到: + +```python + _attach_user_info(db, items) + return items, next_cursor, total +``` + +替换为: + +```python + _attach_user_info(db, items) + # 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。 + # ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。 + rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items]) + for it in items: + it.ad_revenue_yuan = rev.get(it.trace_id, 0.0) + return items, next_cursor, total +``` + +- [ ] **Step 3: 跑比价用例确认通过** + +Run: `pytest tests/test_board_ad_revenue.py::test_comparison_list_includes_ad_revenue -q` +Expected: PASS。 + +- [ ] **Step 4: 跑全量后端测试(确认无回归)** + +Run: `pytest -q` +Expected: 全绿(新增用例通过,原有用例不受影响)。 + +- [ ] **Step 5: Lint** + +Run: `ruff check app/ tests/` +Expected: 无新增告警。 + +- [ ] **Step 6: 提交** + +```bash +git add app/admin/schemas/comparison.py app/admin/repositories/queries.py +git commit -m "feat(admin-comparison): per-comparison ad revenue column" +``` + +--- + +## Phase C — 客户端上报带 trace_id(Android) + +> 三处都在 `E:\project\shaguabijia-app-android`。改动极小:eCPM 上报点的 trace_id 已在作用域内(比价是 `showAd(traceId)` 参数,领券是 `sessionTraceId` 类字段),只是当前没往上带。发奖(`reportFeedReward`)已在带 traceId,可作参照。 + +### Task C1: `ApiClient.reportAdImpression` 加 `traceId` 参数 + +**Files:** +- Modify: `app/src/main/java/com/jishisongfu/shaguabijia/agent/network/ApiClient.kt` + +- [ ] **Step 1: 加参数** + +找到 `reportAdImpression` 的参数列表,末尾 `feedScene: String? = null,` 之后加一行: + +```kotlin + traceId: String? = null, +``` + +- [ ] **Step 2: 写进 payload** + +同函数内,找到: + +```kotlin + if (!feedScene.isNullOrBlank()) payload.put("feed_scene", feedScene) +``` + +在其**下方**加一行: + +```kotlin + if (!traceId.isNullOrBlank()) payload.put("trace_id", traceId) +``` + +- [ ] **Step 3: 提交** + +```bash +git add app/src/main/java/com/jishisongfu/shaguabijia/agent/network/ApiClient.kt +git commit -m "feat(ad-report): reportAdImpression carries trace_id" +``` + +--- + +### Task C2: 比价展示上报带 traceId + +**Files:** +- Modify: `app/src/main/java/com/jishisongfu/shaguabijia/agent/service/ad/CompareAdController.kt` + +- [ ] **Step 1: 传 traceId** + +在 `showAd(traceId: String)` 内的 `onAdImpression` 回调里,找到 `apiClient.reportAdImpression(` 调用,其中 `feedScene = "comparison",` 之后加一行(`traceId` 即 `showAd` 的入参): + +```kotlin + traceId = traceId, // 本场比价 trace → 展示收益归属到该次比价 +``` + +- [ ] **Step 2: 提交** + +```bash +git add app/src/main/java/com/jishisongfu/shaguabijia/agent/service/ad/CompareAdController.kt +git commit -m "feat(compare-ad): report comparison impression with trace_id" +``` + +--- + +### Task C3: 领券展示上报带 traceId + +**Files:** +- Modify: `app/src/main/java/com/jishisongfu/shaguabijia/service/CouponForegroundService.kt` + +- [ ] **Step 1: 传 traceId** + +在 `onAdImpression` 回调里,找到 `apiClient.reportAdImpression(` 调用,其中 `feedScene = "coupon",` 之后加一行(`sessionTraceId` 为本类字段,整场领券不变): + +```kotlin + traceId = sessionTraceId, // 本场领券 trace → 展示收益归属到该次领券 +``` + +- [ ] **Step 2: 编译验证** + +Run(在 `E:\project\shaguabijia-app-android`): `./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL。 + +- [ ] **Step 3: 提交** + +```bash +git add app/src/main/java/com/jishisongfu/shaguabijia/service/CouponForegroundService.kt +git commit -m "feat(coupon-ad): report coupon impression with trace_id" +``` + +--- + +## Phase D — admin 前端加「广告收益」列 + +> 两个页面都在 `e:\project\shaguabijia-admin-web`。收益值单位是**元**(小数,单次很小如 ¥0.0050),用 `.toFixed(4)` 展示。 + +### Task D1: 比价记录页加列 + +**Files:** +- Modify: `src/lib/types.ts` +- Modify: `src/app/(main)/comparison-records/page.tsx` + +- [ ] **Step 1: 类型加字段** + +在 `src/lib/types.ts` 的 `ComparisonRecordListItem` 接口里,`created_at: string;` **之前**加: + +```typescript + ad_revenue_yuan: number; // 本次比价看的信息流广告预估收益(元) +``` + +- [ ] **Step 2: 表格加列** + +在 `src/app/(main)/comparison-records/page.tsx` 的 `columns` 数组里,找到「省」这一列(以 `title: '省',` 开头的对象),在其**之后**插入一列: + +```tsx + { + title: '广告收益', + key: 'ad_revenue', + width: 96, + align: 'right', + render: (_, r) => + r.ad_revenue_yuan > 0 ? ( + ¥{r.ad_revenue_yuan.toFixed(4)} + ) : ( + - + ), + }, +``` + +- [ ] **Step 3: 加宽横向滚动** + +同文件找到比价记录主表的 `scroll={{ x: 1820 }}`,改为: + +```tsx + scroll={{ x: 1920 }} +``` + +- [ ] **Step 4: 提交** + +```bash +git add src/lib/types.ts "src/app/(main)/comparison-records/page.tsx" +git commit -m "feat(admin-web): ad revenue column in comparison records table" +``` + +--- + +### Task D2: 领券数据页加列 + +**Files:** +- Modify: `src/app/(main)/coupon-data/page.tsx` + +- [ ] **Step 1: 接口加字段** + +在 `src/app/(main)/coupon-data/page.tsx` 的 `interface CouponDataRow` 里,`trace_url: string | null;` **之后**加: + +```typescript + ad_revenue_yuan: number; // 本次领券看的信息流广告预估收益(元) +``` + +- [ ] **Step 2: 加金额格式化辅助** + +在 `fmtPct` 定义(以 `const fmtPct =` 开头)之后加: + +```typescript +// 元(小数)→ "¥0.0050"(空/≤0 显示 -)。单次广告收益很小,保留 4 位。 +const fmtYuan = (v: number | null | undefined): string => + v == null || v <= 0 ? '-' : `¥${v.toFixed(4)}`; +``` + +- [ ] **Step 3: 主表加列** + +找到主表 `columns`(`CouponDataPage` 组件内的 `const columns: ColumnsType = [`)。在「耗时」列(`dataIndex: 'elapsed_ms'` 的对象)**之后**插入: + +```tsx + { + title: '广告收益', + dataIndex: 'ad_revenue_yuan', + width: 100, + align: 'right', + render: (v: number) => fmtYuan(v), + }, +``` + +- [ ] **Step 4: 加宽横向滚动** + +找到主表 `scroll={{ x: 1450 }}`,改为: + +```tsx + scroll={{ x: 1560 }} +``` + +- [ ] **Step 5: 前端类型检查 / 构建** + +Run(在 `e:\project\shaguabijia-admin-web`): `npm run build` +Expected: 构建成功、无 TS 类型错误。 + +- [ ] **Step 6: 提交** + +```bash +git add "src/app/(main)/coupon-data/page.tsx" +git commit -m "feat(admin-web): ad revenue column in coupon data table" +``` + +--- + +## 端到端验证(全部任务完成后) + +- [ ] **后端**:`pytest -q` 全绿;`alembic heads` 只有 `ad_ecpm_trace_id` 单 head。 +- [ ] **客户端**:装 debug 包,跑一次比价 + 一次领券(要有信息流广告展示);后端库 `ad_ecpm_record` 出现带 `trace_id`、`feed_scene in (comparison, coupon)` 的记录。 +- [ ] **看板**:admin 打开「比价记录」「领券数据」两页,新「广告收益」列对刚才那两次显示 > ¥0 的金额;旧数据(无 trace_id 上报)显示 `-`。 +- [ ] **口径核对**:任取一行,手动核对 `ad_ecpm_record` 中该 `trace_id` 的各条 `ecpm_raw`,按 `Σ min(ecpm/100, 500)/1000` 算出的值与页面一致。 + +--- + +## 回滚 + +- 前端/客户端:回退对应 commit 即可(纯展示/上报,无副作用)。 +- 后端:`alembic downgrade -1` 撤 `ad_ecpm_trace_id`(会拆回两个 head——与本计划实施前状态一致);看板收益列在无 trace_id 列时会因查询报错,故 downgrade 迁移前需先回退 Phase A/B 的 commit。正常情况不需回滚。 -- 2.52.0 From 107157cdd1dedb55723e8f6da9fa3998ceb0abb9 Mon Sep 17 00:00:00 2001 From: guke Date: Fri, 10 Jul 2026 12:25:43 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(ad-ecpm):=20trace=5Fid=20=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E8=90=BD=E5=BA=93=20=E2=80=94=20=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B/=E8=BF=81=E7=A7=BB/Schema/Repository/Endpoint(A1-A3+A?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdEcpmRecord 新增 trace_id(String 64, index, nullable)列,与 ad_feed_reward_record 一致 - 迁移 ad_ecpm_trace_id:ADD COLUMN + CREATE INDEX,同时收敛双 head (11c44afbea58 selfstat + merge_pages_override_coupon_slot → 单 head) - EcpmReportIn.trace_id 字段透传客户端本场比价/领券 session ID - create_ecpm_record 新增 trace_id 参数并落库 - ecpm_report 端点 forward payload.trace_id 到 repository Co-Authored-By: Claude Opus 4.8 (1M context) --- alembic/versions/ad_ecpm_trace_id.py | 45 ++++++++++++++++++++++++++++ app/api/v1/ad.py | 1 + app/models/ad_ecpm.py | 3 ++ app/repositories/ad_ecpm.py | 27 +++++++++++++++++ app/schemas/ad.py | 6 ++++ 5 files changed, 82 insertions(+) create mode 100644 alembic/versions/ad_ecpm_trace_id.py diff --git a/alembic/versions/ad_ecpm_trace_id.py b/alembic/versions/ad_ecpm_trace_id.py new file mode 100644 index 0000000..ba501f0 --- /dev/null +++ b/alembic/versions/ad_ecpm_trace_id.py @@ -0,0 +1,45 @@ +"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace)+ 收敛双 head + +信息流(Draw)展示 eCPM 上报时带上本场比价/领券 trace_id,落此列;领券数据 / 比价记录看板 +按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端为 NULL。 + +顺带把当前两个 head(11c44afbea58 selfstat 表 + merge_pages_override_coupon_slot)收敛成 +单 head,让 `alembic upgrade head`(单数,部署/run.sh 用)恢复正常。 + +Revision ID: ad_ecpm_trace_id +Revises: 11c44afbea58, merge_pages_override_coupon_slot +Create Date: 2026-07-10 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "ad_ecpm_trace_id" +down_revision: Union[str, Sequence[str], None] = ( + "11c44afbea58", + "merge_pages_override_coupon_slot", +) +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # SQLite 下 ADD COLUMN(可空)与 CREATE INDEX 均原生支持,无需 batch_alter_table + # (同 ad_feed_reward_trace_id 迁移)。 + op.add_column( + "ad_ecpm_record", + sa.Column("trace_id", sa.String(length=64), nullable=True), + ) + op.create_index( + op.f("ix_ad_ecpm_record_trace_id"), + "ad_ecpm_record", + ["trace_id"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_ad_ecpm_record_trace_id"), table_name="ad_ecpm_record") + op.drop_column("ad_ecpm_record", "trace_id") diff --git a/app/api/v1/ad.py b/app/api/v1/ad.py index 2badd23..3b9cbaf 100644 --- a/app/api/v1/ad.py +++ b/app/api/v1/ad.py @@ -286,6 +286,7 @@ def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> Ecpm ad_session_id=payload.ad_session_id, adn=payload.adn, slot_id=payload.slot_id, feed_scene=payload.feed_scene, + trace_id=payload.trace_id, app_env=payload.app_env, our_code_id=payload.our_code_id, ) logger.info( diff --git a/app/models/ad_ecpm.py b/app/models/ad_ecpm.py index a766210..d7c5b64 100644 --- a/app/models/ad_ecpm.py +++ b/app/models/ad_ecpm.py @@ -32,6 +32,9 @@ class AdEcpmRecord(Base): # 点位场景:comparison(比价) / coupon(领券) / welfare(福利),供收益报表区分比价/领券 Draw 收益; # 仅信息流/Draw 上报(比价与领券共用同一代码位,只能客户端各调用点显式打标),激励视频为 NULL。 feed_scene: Mapped[str | None] = mapped_column(String(16), nullable=True) + # 本次比价/领券 trace_id(信息流场景客户端带上):把这条展示收益归属到对应比价/领券记录。 + # 领券数据 / 比价记录看板按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端 = NULL。 + trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 客户端生成的一次广告会话 id;激励视频 S2S 回调 extra 会透传同值 ad_session_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True) # 实际投放的 ADN(穿山甲 getShowEcpm().getSdkName(),如 pangle / gdt) diff --git a/app/repositories/ad_ecpm.py b/app/repositories/ad_ecpm.py index 7ffa08a..c8a1fbe 100644 --- a/app/repositories/ad_ecpm.py +++ b/app/repositories/ad_ecpm.py @@ -10,6 +10,7 @@ from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.core import rewards from app.core.rewards import cn_today from app.models.ad_ecpm import AdEcpmRecord @@ -24,6 +25,7 @@ def create_ecpm_record( adn: str | None = None, slot_id: str | None = None, feed_scene: str | None = None, + trace_id: str | None = None, app_env: str | None = None, our_code_id: str | None = None, ) -> AdEcpmRecord: @@ -43,6 +45,7 @@ def create_ecpm_record( adn=adn, slot_id=slot_id, feed_scene=feed_scene, + trace_id=trace_id, app_env=app_env, our_code_id=our_code_id, ecpm_raw=ecpm_raw, @@ -105,3 +108,27 @@ def count_today(db: Session, user_id: int) -> int: AdEcpmRecord.report_date == cn_today().isoformat(), ) ).scalar_one() + + +def revenue_yuan_by_trace(db: Session, trace_ids: list[str]) -> dict[str, float]: + """各 trace_id 的广告预估收益(元):按 trace_id 聚合 ad_ecpm_record 的展示收益。 + + 单条展示收益 = min(eCPM元, AD_ECPM_MAX_FEN/100) / 1000(与 admin 广告收益报表同口径)。 + ecpm_raw 是字符串且需逐条钳顶,故取回后 Python 求和(行数=本页各 trace 的展示条数,很小)。 + trace_id 仅信息流(比价/领券)场景客户端带,激励视频/旧数据为 NULL,按 trace_id 过滤天然只算对应场景。 + 只喂**当前页**的 trace_id(≤ 一页条数);空集合直接返回(避免 IN () 非法)。 + """ + if not trace_ids: + return {} + rows = db.execute( + select(AdEcpmRecord.trace_id, AdEcpmRecord.ecpm_raw).where( + AdEcpmRecord.trace_id.in_(trace_ids), + ) + ).all() + cap_yuan = rewards.AD_ECPM_MAX_FEN / 100.0 + out: dict[str, float] = {} + for tid, ecpm_raw in rows: + if not tid: + continue + out[tid] = out.get(tid, 0.0) + min(rewards.parse_ecpm_yuan(ecpm_raw), cap_yuan) / 1000.0 + return {tid: round(v, 6) for tid, v in out.items()} diff --git a/app/schemas/ad.py b/app/schemas/ad.py index aa2ecc1..ceb74af 100644 --- a/app/schemas/ad.py +++ b/app/schemas/ad.py @@ -63,6 +63,12 @@ class EcpmReportIn(BaseModel): description="点位场景:comparison(比价等待) / coupon(领券) / welfare(福利页);" "比价与领券共用同一 Draw 代码位,需客户端在各调用点显式标注,供收益报表区分比价/领券;激励视频为空", ) + trace_id: str | None = Field( + None, + max_length=64, + description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券," + "供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空", + ) app_env: str | None = Field( None, max_length=16, description="我们的穿山甲应用环境:prod(傻瓜比价正式) / test(测试应用)" ) -- 2.52.0 From 5e8c9d3028df80c2fcb51ed8b204b356120495e0 Mon Sep 17 00:00:00 2001 From: guke Date: Fri, 10 Jul 2026 12:25:50 +0800 Subject: [PATCH 3/6] =?UTF-8?q?test(ad-ecpm):=20revenue=5Fyuan=5Fby=5Ftrac?= =?UTF-8?q?e=20TDD=20=E2=80=94=20=E6=8C=89=20trace=20=E8=81=9A=E5=90=88?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E6=94=B6=E7=9B=8A(=E5=85=83)(A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个测试覆盖: - 同一 trace 多条 eCPM 求和 + min(eCPM元, ¥500)/1000 钳顶 - 空 trace 列表直接返回 {}(避免 IN () 非法) - create_ecpm_record 落 trace_id 持久化验证 Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_ad_ecpm_trace_revenue.py | 71 +++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/test_ad_ecpm_trace_revenue.py diff --git a/tests/test_ad_ecpm_trace_revenue.py b/tests/test_ad_ecpm_trace_revenue.py new file mode 100644 index 0000000..23f1d8e --- /dev/null +++ b/tests/test_ad_ecpm_trace_revenue.py @@ -0,0 +1,71 @@ +"""ad_ecpm_record.trace_id 落库 + 按 trace 聚合广告收益(元)。""" +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import delete + +from app.db.session import SessionLocal +from app.models.ad_ecpm import AdEcpmRecord +from app.repositories import ad_ecpm as crud_ecpm + + +def _ecpm(trace_id: str, ecpm_raw: str, session_id: str) -> AdEcpmRecord: + """构造一条 Draw 展示 eCPM(不 commit;ad_session_id 全局唯一,须各不相同)。""" + return AdEcpmRecord( + user_id=1, + ad_type="draw", + feed_scene="comparison", + ad_session_id=session_id, + ecpm_raw=ecpm_raw, + trace_id=trace_id, + report_date="2020-01-02", + created_at=datetime(2020, 1, 2, tzinfo=UTC), + ) + + +def test_revenue_yuan_by_trace_sums_and_clamps() -> None: + """同一 trace 多条展示求和;收益=min(eCPM元,¥500)/1000;无展示的 trace 不出现。 + + ecpm 200 分→2.0 元/千次→0.002 元/次;300 分→0.003;合计 0.005。 + """ + db = SessionLocal() + try: + db.add_all([ + _ecpm("t1", "200", "sess-t1-a"), + _ecpm("t1", "300", "sess-t1-b"), + _ecpm("t2", "0", "sess-t2-a"), + ]) + db.flush() + rev = crud_ecpm.revenue_yuan_by_trace(db, ["t1", "t2", "t3"]) + assert rev["t1"] == 0.005 + assert rev.get("t2", 0.0) == 0.0 + assert "t3" not in rev # 无展示的 trace 不出现在结果里 + finally: + db.rollback() + db.close() + + +def test_revenue_yuan_by_trace_empty() -> None: + """空 trace 列表直接返回 {}(避免 IN () 非法)。""" + db = SessionLocal() + try: + assert crud_ecpm.revenue_yuan_by_trace(db, []) == {} + finally: + db.close() + + +def test_create_ecpm_record_persists_trace_id() -> None: + """create_ecpm_record 落 trace_id。""" + db = SessionLocal() + try: + rec = crud_ecpm.create_ecpm_record( + db, 1, ad_type="draw", ecpm_raw="150", + ad_session_id="sess-trace-persist", feed_scene="coupon", + trace_id="trace-xyz", + ) + assert rec.trace_id == "trace-xyz" + finally: + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == "sess-trace-persist")) + db.commit() + db.close() -- 2.52.0 From 166fe9e8dd94aa920661746b3d511a3557de8c93 Mon Sep 17 00:00:00 2001 From: guke Date: Fri, 10 Jul 2026 14:18:22 +0800 Subject: [PATCH 4/6] =?UTF-8?q?test(ad-ecpm):=20exercise=20eCPM=20=C2=A550?= =?UTF-8?q?0=20clamp=20path=20+=20explicit=20zero-revenue=20assert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_ad_ecpm_trace_revenue.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_ad_ecpm_trace_revenue.py b/tests/test_ad_ecpm_trace_revenue.py index 23f1d8e..cd800e0 100644 --- a/tests/test_ad_ecpm_trace_revenue.py +++ b/tests/test_ad_ecpm_trace_revenue.py @@ -35,12 +35,14 @@ def test_revenue_yuan_by_trace_sums_and_clamps() -> None: _ecpm("t1", "200", "sess-t1-a"), _ecpm("t1", "300", "sess-t1-b"), _ecpm("t2", "0", "sess-t2-a"), + _ecpm("t_cap", "60000", "sess-cap-a"), # 600 元 CPM > ¥500 钳顶 ]) db.flush() - rev = crud_ecpm.revenue_yuan_by_trace(db, ["t1", "t2", "t3"]) + rev = crud_ecpm.revenue_yuan_by_trace(db, ["t1", "t2", "t3", "t_cap"]) assert rev["t1"] == 0.005 - assert rev.get("t2", 0.0) == 0.0 + assert rev["t2"] == 0.0 # 有展示但 eCPM=0 → 0 元(仍在结果里) assert "t3" not in rev # 无展示的 trace 不出现在结果里 + assert rev["t_cap"] == 0.5 # min(¥600, ¥500)/1000,证明钳顶生效(非 0.6) finally: db.rollback() db.close() -- 2.52.0 From e9c0a80d446fc56dceac294ed9bbd670c7a38651 Mon Sep 17 00:00:00 2001 From: guke Date: Fri, 10 Jul 2026 14:24:07 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat(admin):=20=E4=B8=A4=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E9=80=90=E8=A1=8C=E8=A1=A5=E3=80=8C=E6=9C=AC=E6=AC=A1=E5=B9=BF?= =?UTF-8?q?=E5=91=8A=E6=94=B6=E7=9B=8A=E3=80=8D(ad=5Frevenue=5Fyuan)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按本页 trace_id 一次性聚合 ad_ecpm_record 展示收益(元),挂到领券数据 看板明细行(dict 字段)与比价记录列表项(ORM 瞬态属性),防 N+1 范式与 现有 _attach_user_info / _ad_coins_by_trace 一致。 - CouponDataRow: 新增 ad_revenue_yuan: float = 0.0 字段 - AdminComparisonListItem: 新增 ad_revenue_yuan: float = 0.0 字段 - coupon_data.py: _session_to_row 增 ad_revenue_yuan 参数;coupon_data_report 在分页后调用 crud_ecpm.revenue_yuan_by_trace 按本页批量聚合 - queries.py: list_comparison_records 在 _attach_user_info 后瞬态挂 ad_revenue_yuan - 新增 tests/test_board_ad_revenue.py:两个看板各一个集成测试 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/admin/repositories/coupon_data.py | 7 ++- app/admin/repositories/queries.py | 6 +++ app/admin/schemas/comparison.py | 1 + app/admin/schemas/coupon_data.py | 3 ++ tests/test_board_ad_revenue.py | 66 +++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/test_board_ad_revenue.py diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index a290df1..7fd104e 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -18,6 +18,7 @@ from sqlalchemy.orm import Session from app.core import rewards from app.models.coupon_state import CouponClaimRecord, CouponSession from app.models.user import User +from app.repositories import ad_ecpm as crud_ecpm from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform @@ -85,7 +86,7 @@ def _success_rates(rows: list) -> dict: } -def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict: +def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict: """CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。""" return { "id": r.id, @@ -104,6 +105,7 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> "started_at": r.started_at, "claimed_count": r.claimed_count, "trace_url": r.trace_url, + "ad_revenue_yuan": ad_revenue_yuan, } @@ -246,10 +248,11 @@ def coupon_data_report( select(User.id, User.phone, User.nickname).where(User.id.in_(uids)) ).all() } + rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in page]) items = [] for r in page: phone, nickname = user_map.get(r.user_id, (None, None)) if r.user_id is not None else (None, None) - items.append(_session_to_row(r, phone, nickname)) + items.append(_session_to_row(r, phone, nickname, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0))) return { "summary": summary, diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 1142460..501dba9 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -32,6 +32,7 @@ from app.models.wallet import ( InviteCashTransaction, WithdrawOrder, ) +from app.repositories import ad_ecpm # 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券) _ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT) @@ -298,6 +299,11 @@ def list_comparison_records( limit=limit, cursor=cursor, ) _attach_user_info(db, items) + # 「本次比价看广告的预估收益」:按本页 trace_id 一次性聚合(同 _attach_user_info 逐页范式)。 + # ad_revenue_yuan 非 ORM 列,仅瞬态挂实例上供 AdminComparisonListItem(from_attributes)读出。 + rev = ad_ecpm.revenue_yuan_by_trace(db, [it.trace_id for it in items]) + for it in items: + it.ad_revenue_yuan = rev.get(it.trace_id, 0.0) return items, next_cursor, total diff --git a/app/admin/schemas/comparison.py b/app/admin/schemas/comparison.py index 5d4155c..3a5ae19 100644 --- a/app/admin/schemas/comparison.py +++ b/app/admin/schemas/comparison.py @@ -41,6 +41,7 @@ class AdminComparisonListItem(BaseModel): rom_name: str | None = None android_version: str | None = None app_version: str | None = None + ad_revenue_yuan: float = 0.0 # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上 created_at: datetime diff --git a/app/admin/schemas/coupon_data.py b/app/admin/schemas/coupon_data.py index ec4c56e..c560397 100644 --- a/app/admin/schemas/coupon_data.py +++ b/app/admin/schemas/coupon_data.py @@ -70,6 +70,9 @@ class CouponDataRow(BaseModel): started_at: datetime = Field(..., description="发起时刻(明细「时间」列)") claimed_count: int | None = None trace_url: str | None = Field(None, description="pricebot 公网 trace 链接(仅 completed 有);admin 渲染可点链接,无则显示可复制 trace_id") + ad_revenue_yuan: float = Field( + 0.0, description="本次领券看的信息流广告预估收益(元);按 trace_id 聚合 ad_ecpm_record" + ) class CouponDataOut(BaseModel): diff --git a/tests/test_board_ad_revenue.py b/tests/test_board_ad_revenue.py new file mode 100644 index 0000000..72d578c --- /dev/null +++ b/tests/test_board_ad_revenue.py @@ -0,0 +1,66 @@ +"""两个看板逐行「本次广告收益」(元):按 trace_id 聚合 ad_ecpm_record。""" +from __future__ import annotations + +from datetime import UTC, date, datetime + +from sqlalchemy import delete + +from app.admin.repositories import queries +from app.admin.repositories.coupon_data import coupon_data_report +from app.db.session import SessionLocal +from app.models.ad_ecpm import AdEcpmRecord +from app.models.comparison import ComparisonRecord +from app.models.coupon_state import CouponSession + + +def _ecpm(trace_id: str, ecpm_raw: str, session_id: str, scene: str) -> AdEcpmRecord: + return AdEcpmRecord( + user_id=1, ad_type="draw", feed_scene=scene, ad_session_id=session_id, + ecpm_raw=ecpm_raw, trace_id=trace_id, report_date="2020-01-02", + created_at=datetime(2020, 1, 2, tzinfo=UTC), + ) + + +def test_coupon_data_report_includes_ad_revenue() -> None: + """领券看板明细行带本次广告收益;200+300 分 → 0.005 元。""" + db = SessionLocal() + try: + db.add(CouponSession( + trace_id="rev-cp-1", device_id="d1", status="completed", app_env="prod", + platforms=["meituan-waimai"], platform_success=["meituan-waimai"], + started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2), + )) + db.add_all([ + _ecpm("rev-cp-1", "200", "cp-sess-a", "coupon"), + _ecpm("rev-cp-1", "300", "cp-sess-b", "coupon"), + ]) + db.flush() + res = coupon_data_report(db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod") + row = next(r for r in res["items"] if r["trace_id"] == "rev-cp-1") + assert row["ad_revenue_yuan"] == 0.005 + finally: + db.rollback() + db.close() + + +def test_comparison_list_includes_ad_revenue() -> None: + """比价记录列表项带本次广告收益;200 分 → 0.002 元。 + + 用独有 user_id 过滤,确保本行必落在第一页(避免共享测试库里同 user 记录多、分页把它挤掉)。 + """ + db = SessionLocal() + try: + db.add(ComparisonRecord( + trace_id="rev-cmp-1", user_id=987654, status="success", business_type="food", + created_at=datetime(2020, 1, 2, tzinfo=UTC), + )) + db.add(_ecpm("rev-cmp-1", "200", "cmp-sess-a", "comparison")) + db.commit() + items, _next, _total = queries.list_comparison_records(db, user_id=987654) + row = next(it for it in items if it.trace_id == "rev-cmp-1") + assert row.ad_revenue_yuan == 0.002 + finally: + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.trace_id.in_(["rev-cmp-1"]))) + db.execute(delete(ComparisonRecord).where(ComparisonRecord.trace_id == "rev-cmp-1")) + db.commit() + db.close() -- 2.52.0 From 4680277c2a7c3450b9ea73e1c18c346637d0e313 Mon Sep 17 00:00:00 2001 From: guke Date: Fri, 10 Jul 2026 14:37:18 +0800 Subject: [PATCH 6/6] fix(admin-coupon-data): fill ad_revenue_yuan in user-records drawer too Co-Authored-By: Claude Opus 4.8 (1M context) --- app/admin/repositories/coupon_data.py | 6 +++++- tests/test_board_ad_revenue.py | 23 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/app/admin/repositories/coupon_data.py b/app/admin/repositories/coupon_data.py index 7fd104e..0450404 100644 --- a/app/admin/repositories/coupon_data.py +++ b/app/admin/repositories/coupon_data.py @@ -274,7 +274,11 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict: total = db.execute( select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id) ).scalar_one() - return {"items": [_session_to_row(r) for r in rows], "total": int(total)} + rev_map = crud_ecpm.revenue_yuan_by_trace(db, [r.trace_id for r in rows]) + return { + "items": [_session_to_row(r, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)) for r in rows], + "total": int(total), + } _SLOT_OK = ("success", "already_claimed") diff --git a/tests/test_board_ad_revenue.py b/tests/test_board_ad_revenue.py index 72d578c..6f54d86 100644 --- a/tests/test_board_ad_revenue.py +++ b/tests/test_board_ad_revenue.py @@ -6,7 +6,7 @@ from datetime import UTC, date, datetime from sqlalchemy import delete from app.admin.repositories import queries -from app.admin.repositories.coupon_data import coupon_data_report +from app.admin.repositories.coupon_data import coupon_data_report, coupon_user_records from app.db.session import SessionLocal from app.models.ad_ecpm import AdEcpmRecord from app.models.comparison import ComparisonRecord @@ -64,3 +64,24 @@ def test_comparison_list_includes_ad_revenue() -> None: db.execute(delete(ComparisonRecord).where(ComparisonRecord.trace_id == "rev-cmp-1")) db.commit() db.close() + + +def test_coupon_user_records_includes_ad_revenue() -> None: + """手机号抽屉的用户领券记录也带本次广告收益(400 分 → 0.004 元)。""" + db = SessionLocal() + try: + db.add(CouponSession( + trace_id="rev-drawer-1", device_id="d2", status="completed", app_env="prod", + user_id=987655, platforms=["meituan-waimai"], platform_success=["meituan-waimai"], + started_at=datetime(2020, 1, 2, tzinfo=UTC), started_date=date(2020, 1, 2), + )) + db.add(_ecpm("rev-drawer-1", "400", "drawer-sess-a", "coupon")) + db.commit() + res = coupon_user_records(db, user_id=987655) + row = next(r for r in res["items"] if r["trace_id"] == "rev-drawer-1") + assert row["ad_revenue_yuan"] == 0.004 + finally: + db.execute(delete(AdEcpmRecord).where(AdEcpmRecord.trace_id == "rev-drawer-1")) + db.execute(delete(CouponSession).where(CouponSession.trace_id == "rev-drawer-1")) + db.commit() + db.close() -- 2.52.0