Files
shaguabijia-app-server/docs/superpowers/plans/2026-07-10-per-session-ad-revenue.md
T
guke 80e0fb90db docs: plan for per-session ad revenue in admin boards
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:18:20 +08:00

28 KiB

逐次比价/领券广告收益 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.pyAdEcpmRecordtrace_id 列(索引)
  • Create alembic/versions/ad_ecpm_trace_id.py — 加列 + 索引,并收敛当前双 head
  • Modify app/schemas/ad.pyEcpmReportIntrace_id 字段
  • Modify app/repositories/ad_ecpm.pycreate_ecpm_record 持久化 trace_id;新增 revenue_yuan_by_trace 聚合器
  • Modify app/api/v1/ad.pyecpm_report 透传 trace_id
  • Modify app/admin/schemas/coupon_data.pyCouponDataRowad_revenue_yuan
  • Modify app/admin/repositories/coupon_data.py — 逐页补 ad_revenue_yuan
  • Modify app/admin/schemas/comparison.pyAdminComparisonListItemad_revenue_yuan
  • Modify app/admin/repositories/queries.pylist_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.ktreportAdImpressiontraceId 参数
  • 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.tsComparisonRecordListItemad_revenue_yuan
  • Modify src/app/(main)/comparison-records/page.tsx — 加「广告收益」列
  • Modify src/app/(main)/coupon-data/page.tsxCouponDataRow 加字段 + 加「广告收益」列

Phase A — 后端数据打通(落 trace_id + 收益聚合器)

Task A1: AdEcpmRecordtrace_id

Files:

  • Modify: app/models/ad_ecpm.py

  • Step 1: 加列

app/models/ad_ecpm.py 中,找到 feed_scene 这一行:

    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)
  • Step 2: 提交
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:

"""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: 提交
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: EcpmReportIntrace_id 字段

Files:

  • Modify: app/schemas/ad.py

  • Step 1: 加字段

app/schemas/ad.pyEcpmReportIn 里,找到 feed_scene 字段定义(以 feed_scene: str | None = Field( 开头的那段)。在该字段之后插入:

    trace_id: str | None = Field(
        None,
        max_length=64,
        description="本次比价/领券 trace_id(信息流场景带上):把这条展示收益归属到对应比价/领券,"
        "供领券数据/比价记录看板聚合本场广告收益;激励视频/福利为空",
    )
  • Step 2: 提交
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:

"""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_recordtrace_id 参数(TypeError)/ revenue_yuan_by_trace 不存在(AttributeError)。

  • Step 3: 实现

app/repositories/ad_ecpm.py:

(a) 顶部 import 区加(与现有 from app.core.rewards import cn_today 并列):

from app.core import rewards

(b) create_ecpm_record 的签名里,在 feed_scene: str | None = None, 之后加一行参数:

    trace_id: str | None = None,

(c) 同函数体内构造 AdEcpmRecord(...) 处,在 feed_scene=feed_scene, 之后加一行:

        trace_id=trace_id,

(d) 文件末尾新增聚合器:

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: 提交
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.pyecpm_report 函数里,找到 crud_ecpm.create_ecpm_record( 调用,在 feed_scene=payload.feed_scene, 之后加一行:

        trace_id=payload.trace_id,
  • Step 2: 冒烟验证(手动,可选)

启动后端(./run.sh),用一个有效用户 JWT 调:

Run:

curl -s -X POST http://127.0.0.1:8770/api/v1/ad/ecpm-report \
  -H "Authorization: Bearer <USER_JWT>" -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: 提交
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:

"""两个看板逐行「本次广告收益」(元):按 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);ComparisonRecordad_revenue_yuan(AttributeError)。

  • Step 3: 领券 schema 加字段

app/admin/schemas/coupon_data.pyCouponDataRow 里,trace_url 字段之后加:

    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 附近)加:

from app.repositories import ad_ecpm as crud_ecpm

(b) _session_to_row 签名改为(加末位参数):

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, 之后加一行:

        "ad_revenue_yuan": ad_revenue_yuan,

(c) 在 coupon_data_report 里,找到构造明细的这段:

    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 + 传入):

    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: 提交
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.pyAdminComparisonListItem 里,created_at: datetime 之前加:

    ad_revenue_yuan: float = 0.0   # 本次比价看的信息流广告预估收益(元),queries 瞬态挂 ORM 实例上
  • Step 2: 比价 repo 逐页补收益

app/admin/repositories/queries.py:

(a) import 区加:

from app.repositories import ad_ecpm

(b) 在 list_comparison_records 里,找到:

    _attach_user_info(db, items)
    return items, next_cursor, total

替换为:

    _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: 提交
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.reportAdImpressiontraceId 参数

Files:

  • Modify: app/src/main/java/com/jishisongfu/shaguabijia/agent/network/ApiClient.kt

  • Step 1: 加参数

找到 reportAdImpression 的参数列表,末尾 feedScene: String? = null, 之后加一行:

        traceId: String? = null,
  • Step 2: 写进 payload

同函数内,找到:

            if (!feedScene.isNullOrBlank()) payload.put("feed_scene", feedScene)

在其下方加一行:

            if (!traceId.isNullOrBlank()) payload.put("trace_id", traceId)
  • Step 3: 提交
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", 之后加一行(traceIdshowAd 的入参):

                            traceId = traceId, // 本场比价 trace → 展示收益归属到该次比价
  • Step 2: 提交
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 为本类字段,整场领券不变):

                            traceId = sessionTraceId, // 本场领券 trace → 展示收益归属到该次领券
  • Step 2: 编译验证

Run(在 E:\project\shaguabijia-app-android): ./gradlew :app:compileDebugKotlin Expected: BUILD SUCCESSFUL。

  • Step 3: 提交
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.tsComparisonRecordListItem 接口里,created_at: string; 之前加:

  ad_revenue_yuan: number; // 本次比价看的信息流广告预估收益(元)
  • Step 2: 表格加列

src/app/(main)/comparison-records/page.tsxcolumns 数组里,找到「省」这一列(以 title: '省', 开头的对象),在其之后插入一列:

    {
      title: '广告收益',
      key: 'ad_revenue',
      width: 96,
      align: 'right',
      render: (_, r) =>
        r.ad_revenue_yuan > 0 ? (
          <span style={{ color: '#3f8600' }}>¥{r.ad_revenue_yuan.toFixed(4)}</span>
        ) : (
          <span style={{ color: '#ccc' }}>-</span>
        ),
    },
  • Step 3: 加宽横向滚动

同文件找到比价记录主表的 scroll={{ x: 1820 }},改为:

        scroll={{ x: 1920 }}
  • Step 4: 提交
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.tsxinterface CouponDataRow 里,trace_url: string | null; 之后加:

  ad_revenue_yuan: number; // 本次领券看的信息流广告预估收益(元)
  • Step 2: 加金额格式化辅助

fmtPct 定义(以 const fmtPct = 开头)之后加:

// 元(小数)→ "¥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<CouponDataRow> = [)。在「耗时」列(dataIndex: 'elapsed_ms' 的对象)之后插入:

    {
      title: '广告收益',
      dataIndex: 'ad_revenue_yuan',
      width: 100,
      align: 'right',
      render: (v: number) => fmtYuan(v),
    },
  • Step 4: 加宽横向滚动

找到主表 scroll={{ x: 1450 }},改为:

        scroll={{ x: 1560 }}
  • Step 5: 前端类型检查 / 构建

Run(在 e:\project\shaguabijia-admin-web): npm run build Expected: 构建成功、无 TS 类型错误。

  • Step 6: 提交
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_idfeed_scene in (comparison, coupon) 的记录。
  • 看板:admin 打开「比价记录」「领券数据」两页,新「广告收益」列对刚才那两次显示 > ¥0 的金额;旧数据(无 trace_id 上报)显示 -
  • 口径核对:任取一行,手动核对 ad_ecpm_record 中该 trace_id 的各条 ecpm_raw,按 Σ min(ecpm/100, 500)/1000 算出的值与页面一致。

回滚

  • 前端/客户端:回退对应 commit 即可(纯展示/上报,无副作用)。
  • 后端:alembic downgrade -1ad_ecpm_trace_id(会拆回两个 head——与本计划实施前状态一致);看板收益列在无 trace_id 列时会因查询报错,故 downgrade 迁移前需先回退 Phase A/B 的 commit。正常情况不需回滚。