feat(wallet): 金币记录按 trace_id 聚合比价/领券看广告金币 #225

Merged
guke merged 14 commits from feat/coin-ledger-aggregate-ad-rewards into main 2026-08-07 19:12:59 +08:00
Member

背景

「金币变动记录」里看广告金币按每条广告统计,一次比价/领券的等候期会连续看多条信息流广告,于是列表刷出一长串「比价奖励 +x」「领券奖励 +x」,淹没其它记录、观感差。

改动

一次比价 / 一次领券连续看广告获得的多条金币,按 trace_id 聚合成一条展示(金额取该次会话合计)。

纯后端:比价、领券的结算上报都已把本场 trace_id 传到 /feed-reward(领券自 2026-07-15、客户端 a98cab8 起),无需 Android 改动即可生效。

具体

  • coin_transaction 新增 trace_id 列 + 索引;grant_coinstrace_id 参数;grant_feed_reward 发奖时写入本场 trace_id。
  • GET /api/v1/wallet/coin-transactions 改为按 trace_id 分组的游标分页(CTE 聚合),响应新增 merged_count(合并条数,未合并=1)。
  • Alembic 迁移:加列 + 索引 + 回填历史(从 ad_feed_reward_recordref_id == client_event_id 补 trace_id)。
  • 更正 ad_feed_reward_record.trace_id 过时注释(领券自 2026-07-15 也带)。
  • 只改 App 用户接口;admin 审计接口(list_all_coin_transactions)保持每条一行不动。

关键设计

  • 分组键biz_type ∈ (feed_ad_reward_comparison, feed_ad_reward_coupon)trace_id 非空 → 按 trace_id 合并;其余(激励视频/签到/通用信息流/空 trace 等)每条一行。
  • 代表行取组内 MAX(id)(供余额/时间/标题);amount = SUM;游标 rep_id < cursor 施于外层查询——CTE 在用户全量行上分组,id<cursor 裁剪,避免会话行跨游标时产生「残组」重复(有专门回归测试锁死)。
  • 返回 CoinLedgerRow dataclass(非 ORM,杜绝把聚合后的 amount 误写回底层流水)。
  • SQL 全程 SQLite/PG 双方言通用(dev/测试 SQLite,prod PG)。
## 背景 「金币变动记录」里看广告金币按**每条广告**统计,一次比价/领券的等候期会连续看多条信息流广告,于是列表刷出一长串「比价奖励 +x」「领券奖励 +x」,淹没其它记录、观感差。 ## 改动 把**一次比价 / 一次领券**连续看广告获得的多条金币,按 `trace_id` **聚合成一条**展示(金额取该次会话合计)。 **纯后端**:比价、领券的结算上报都已把本场 `trace_id` 传到 `/feed-reward`(领券自 2026-07-15、客户端 `a98cab8` 起),无需 Android 改动即可生效。 ### 具体 - `coin_transaction` 新增 `trace_id` 列 + 索引;`grant_coins` 增 `trace_id` 参数;`grant_feed_reward` 发奖时写入本场 trace_id。 - `GET /api/v1/wallet/coin-transactions` 改为**按 trace_id 分组的游标分页**(CTE 聚合),响应新增 `merged_count`(合并条数,未合并=1)。 - Alembic 迁移:加列 + 索引 + **回填历史**(从 `ad_feed_reward_record` 按 `ref_id == client_event_id` 补 trace_id)。 - 更正 `ad_feed_reward_record.trace_id` 过时注释(领券自 2026-07-15 也带)。 - **只改 App 用户接口**;admin 审计接口(`list_all_coin_transactions`)保持每条一行不动。 ## 关键设计 - **分组键**:`biz_type ∈ (feed_ad_reward_comparison, feed_ad_reward_coupon)` 且 `trace_id` 非空 → 按 `trace_id` 合并;其余(激励视频/签到/通用信息流/空 trace 等)每条一行。 - **代表行**取组内 `MAX(id)`(供余额/时间/标题);`amount = SUM`;游标 `rep_id < cursor` 施于**外层**查询——CTE 在用户全量行上分组,**不**按 `id<cursor` 裁剪,避免会话行跨游标时产生「残组」重复(有专门回归测试锁死)。 - 返回 `CoinLedgerRow` dataclass(非 ORM,杜绝把聚合后的 amount 误写回底层流水)。 - SQL 全程 **SQLite/PG 双方言通用**(dev/测试 SQLite,prod PG)。
guke added 13 commits 2026-08-07 16:43:43 +08:00
金币记录列表把一次比价/领券连续看广告的多条金币合并成一条,按 trace_id
聚合。纯后端:coin_transaction 加 trace_id 列 + 回填历史,/coin-transactions
分组游标分页返回合并行。领券自 2026-07-15 起结算已带 trace_id,无需改 Android。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- §11 明令禁止把分组 CTE 裁成 id<cursor(会话交错跨游标会算出"残组"与上页重复),
  补防残组回归测试;成本改口径为估算(非实测);去掉无效的 (user_id,trace_id) 索引建议
- §8 回填补幂等/大表分批/子查询唯一性守护
- §6 两类 biz_type 抽成常量 FEED_AD_SESSION_BIZ_TYPES 三处共用
- §3 显式界定:只改 App 用户接口,admin 的 list_all_coin_transactions 保持每条一行(审计)
- 记录未来读优化逃生口(会话聚合投影表)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6 个任务、逐步 test-first,含精确代码与命令;端点不改、admin 不动。
对应 spec docs/superpowers/specs/2026-08-07-coin-ledger-aggregate-ad-rewards-design.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
对齐 spec §8:2026-07-15 前老比价广告 ad 行 trace_id 为空时,回填须跳过、保持 NULL。
(code-review Task 2 的 minor 建议)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
否则 seeding 顺序若变,用例会退化成非跨游标却仍通过、失守护意义。
(code-review Task 5 的建议)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
最终整体 review 的可选建议;本特性已多次触及该文件 import 区,顺手清掉先前的
F401 死导入,test_welfare.py lint 归零。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

🤖 review-pr 深审结论

🔴 一个部署阻断项:alembic 多头(必须先修再合);其余实现优秀(置信度 0.92)。功能本身正确、测试扎实,问题只在迁移链。

阻断项(合并 main 后实跑复现)

  • high alembic/versions/coin_transaction_trace_id.py:19down_revision='comparison_updated_at'。但 #224savings_record_trace_id 已先合入 main挂在 comparison_updated_at 下 → 合并后两个 alembic head。已实跑复现:
    $ alembic upgrade head
    FAILED: Multiple head revisions are present for given argument 'head'; please specify a specific target revision...
    $ alembic heads
    coin_transaction_trace_id (head)
    savings_record_trace_id (head)
    
    部署常用的 alembic upgrade head 会直接失败。测试没抓到是因为测试用 create_all 建表、不跑迁移(故 19 passed 仍绿)。
    修复(一行):把 down_revision 改为 'savings_record_trace_id'(#224 已先合入,线性化接在其后),或加一条 alembic merge 迁移。改完 alembic heads 应回单头。

测试/构建 已实跑(合并 main 后 worktree,venv Py3.12)

  • pytest tests/test_welfare.py19 passed
  • alembic upgrade headFAILED(Multiple heads,见上)alembic heads → 两个 head

正面(rebase 一行后即可合)

  • 聚合 list_coin_transactions 正确且注释到位:"T:"+trace_id / "I:"+id 前缀分组不撞车;CTE 先对全量分组再按 rep_id<cursor 游标过滤(正确避免跨页「残组」);next_cursor=rep_id 自洽。
  • 迁移回填幂等(EXISTS 守护 + trace_id IS NULL),biz_type 硬编码历史快照符合「迁移不可变」;downgrade 对称。
  • CoinLedgerRow 用 frozen dataclass 而非 ORM 行,避免把聚合后的 amount 误写回底层流水——好设计。
  • 测试覆盖到位:grant 落库 / 回填 SQL(与迁移同步)/ grant_feed_reward 透传 / 聚合展示 均有专测。

建议:仅需改 coin_transaction_trace_id.pydown_revision → 'savings_record_trace_id',重跑 alembic heads 确认单头后即可合并。

## 🤖 review-pr 深审结论 🔴 **一个部署阻断项:alembic 多头(必须先修再合)**;其余实现优秀(置信度 0.92)。功能本身正确、测试扎实,问题只在迁移链。 **阻断项(合并 main 后实跑复现)** - **high** `alembic/versions/coin_transaction_trace_id.py:19` — `down_revision='comparison_updated_at'`。但 #224 的 `savings_record_trace_id` 已先合入 `main`,**也**挂在 `comparison_updated_at` 下 → 合并后**两个 alembic head**。已实跑复现: ``` $ alembic upgrade head FAILED: Multiple head revisions are present for given argument 'head'; please specify a specific target revision... $ alembic heads coin_transaction_trace_id (head) savings_record_trace_id (head) ``` 部署常用的 `alembic upgrade head` 会直接失败。**测试没抓到**是因为测试用 `create_all` 建表、不跑迁移(故 19 passed 仍绿)。 **修复(一行)**:把 `down_revision` 改为 `'savings_record_trace_id'`(#224 已先合入,线性化接在其后),或加一条 alembic merge 迁移。改完 `alembic heads` 应回单头。 **测试/构建**:✅ 已实跑(合并 main 后 worktree,venv Py3.12) - `pytest tests/test_welfare.py` → **19 passed** - `alembic upgrade head` → **FAILED(Multiple heads,见上)**;`alembic heads` → 两个 head **正面(rebase 一行后即可合)** - 聚合 `list_coin_transactions` 正确且注释到位:`"T:"+trace_id` / `"I:"+id` 前缀分组不撞车;CTE 先对**全量**分组再按 `rep_id<cursor` 游标过滤(正确避免跨页「残组」);`next_cursor=rep_id` 自洽。 - 迁移回填幂等(`EXISTS` 守护 + `trace_id IS NULL`),biz_type 硬编码历史快照符合「迁移不可变」;`downgrade` 对称。 - `CoinLedgerRow` 用 frozen dataclass 而非 ORM 行,避免把聚合后的 amount 误写回底层流水——好设计。 - 测试覆盖到位:grant 落库 / 回填 SQL(与迁移同步)/ `grant_feed_reward` 透传 / 聚合展示 均有专测。 建议:仅需改 `coin_transaction_trace_id.py` 的 `down_revision → 'savings_record_trace_id'`,重跑 `alembic heads` 确认单头后即可合并。
guke added 1 commit 2026-08-07 18:58:18 +08:00
origin/main(#224) 的 savings_record_trace_id 与本分支 coin_transaction_trace_id 都以
comparison_updated_at 为父,汇合后 alembic 双 head(upgrade head 报 multiple heads)。
把本分支迁移重挂到 savings_record_trace_id 之上,线性化为单 head:
comparison_updated_at → savings_record_trace_id → coin_transaction_trace_id。
两迁移分别改 savings_record / coin_transaction,互不依赖,串行安全。

验证:alembic heads 单 head;scratch DB 从 base 全链 upgrade head 通过;
test_welfare / compare_record / admin_read / order_savings 共 66 项测试通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

🤖 review-pr 深审结论

🟢 可合 · 置信度 0.9 —— 设计正确、测试严谨、对最新 main 合并后无回归。(完整仓 worktree 深审 + 合并结果实跑)

构建 / 测试(已实跑)

  • tests/test_welfare.py 隔离跑:19/19 通过(含 7 条新增)。
  • 全量套件(合并后树):836 passed / 12 failed / 5 errors;基线 origin/main 同套件:829 passed / 相同的 12 failed + 5 errors。→ 本 PR 净 +7 通过、零新增失败;那 12+5 是 main 上既有的全量跑测试污染(device_push / guide_video / withdraw_tiers 等,与本改动无关)。
  • alembic 合并后 heads = 单一 coin_transaction_trace_id(作者已把 down_revision 重挂到 savings_record_trace_id 消双 head )。
  • ruff:仅新迁移文件有标准 alembic 模板 import 排序 nit(base 也有 4 个既有 nit、项目未 gate),ruff --fix 即可,不影响行为。

设计正确性 👍

  • 反残组是真的:CTE 在用户全量行上分组、游标 rep_id < cursor 施于外层 → 正确规避会话行跨游标的「残组」重复;test_coin_transactions_pagination_no_phantom_regroup 断言游标严格落在会话两成员 id 之间,是货真价实的跨游标回归守护。
  • 分组键 T:trace / I:id 命名空间隔离无碰撞;rep_id=MAX(id) 跨组全局唯一 → 游标分页无重/漏。
  • 返回 CoinLedgerRow(frozen dataclass、非 ORM)杜绝聚合后 amount 误写回底层流水,考虑周到。
  • 迁移:加可空列+索引(SQLite/PG 原生);回填相关子查询 EXISTS 守护 + trace_id IS NULL 幂等;client_event_idUniqueConstraint → 标量子查询 PG 安全(不会多行报错);biz_type 按「迁移不可变」硬编码快照,正确。
  • merged_count 默认 1、grant_coins 新增可选 kwarg → 向后兼容;admin list_all_coin_transactions 未动,blast radius 收敛。
  • trace_id 串路:请求 → grant_feed_rewardgrant_coinsCoinTransaction.trace_id,与分组键、常量三处一致。

小建议(非阻塞)

  • [low] 性能:list_coin_transactions 每页都在用户全量 coin_transaction 上分组(为正确性刻意不做 id<cursor 预裁)。重度用户深翻页为 O(N)/页;当前量级 OK(trace_id/user_id 有索引),表增大后可关注。docstring 已注明该权衡。
  • [low/info] 展示:会话广告与其它记录交错时,聚合行 balance_after 取会话末条、amount 取合计,可能出现相邻行「余额差 ≠ 显示金额」;聚合固有、实际会话广告多连发、少见,非底层账目错误。
  • [info] 那 12+5 全量失败是 main 既有测试隔离/共享 SQLite 污染,建议团队另行治理,与本 PR 无关。

🤖 由 review-pr 自动深审 · 全程只读、worktree 旁路隔离,未触碰任何分支/工作区

## 🤖 review-pr 深审结论 🟢 **可合** · 置信度 0.9 —— 设计正确、测试严谨、对最新 main 合并后**无回归**。(完整仓 worktree 深审 + 合并结果实跑) ### 构建 / 测试(已实跑) - `tests/test_welfare.py` 隔离跑:**19/19 通过**(含 7 条新增)。 - 全量套件(合并后树):**836 passed** / 12 failed / 5 errors;基线 `origin/main` 同套件:**829 passed** / **相同的** 12 failed + 5 errors。→ 本 PR 净 **+7 通过、零新增失败**;那 12+5 是 main 上既有的全量跑测试污染(device_push / guide_video / withdraw_tiers 等,与本改动无关)。 - alembic 合并后 `heads` = 单一 `coin_transaction_trace_id`(作者已把 down_revision 重挂到 `savings_record_trace_id` 消双 head ✅)。 - ruff:仅新迁移文件有标准 alembic 模板 import 排序 nit(base 也有 4 个既有 nit、项目未 gate),`ruff --fix` 即可,不影响行为。 ### 设计正确性 👍 - **反残组是真的**:CTE 在用户**全量行**上分组、游标 `rep_id < cursor` 施于**外层** → 正确规避会话行跨游标的「残组」重复;`test_coin_transactions_pagination_no_phantom_regroup` 断言游标严格落在会话两成员 id 之间,是货真价实的跨游标回归守护。 - 分组键 `T:trace` / `I:id` 命名空间隔离无碰撞;`rep_id=MAX(id)` 跨组全局唯一 → 游标分页无重/漏。 - 返回 `CoinLedgerRow`(frozen dataclass、非 ORM)杜绝聚合后 amount 误写回底层流水,考虑周到。 - 迁移:加可空列+索引(SQLite/PG 原生);回填相关子查询 `EXISTS` 守护 + `trace_id IS NULL` 幂等;`client_event_id` 有 `UniqueConstraint` → 标量子查询 PG 安全(不会多行报错);biz_type 按「迁移不可变」硬编码快照,正确。 - `merged_count` 默认 1、`grant_coins` 新增可选 kwarg → 向后兼容;admin `list_all_coin_transactions` 未动,blast radius 收敛。 - trace_id 串路:请求 → `grant_feed_reward` → `grant_coins` → `CoinTransaction.trace_id`,与分组键、常量三处一致。 ### 小建议(非阻塞) - **[low] 性能**:`list_coin_transactions` 每页都在用户**全量** coin_transaction 上分组(为正确性刻意不做 id<cursor 预裁)。重度用户深翻页为 O(N)/页;当前量级 OK(trace_id/user_id 有索引),表增大后可关注。docstring 已注明该权衡。 - **[low/info] 展示**:会话广告与其它记录交错时,聚合行 balance_after 取会话末条、amount 取合计,可能出现相邻行「余额差 ≠ 显示金额」;聚合固有、实际会话广告多连发、少见,非底层账目错误。 - **[info]** 那 12+5 全量失败是 main **既有**测试隔离/共享 SQLite 污染,建议团队另行治理,与本 PR 无关。 --- *🤖 由 review-pr 自动深审 · 全程只读、worktree 旁路隔离,未触碰任何分支/工作区*
guke merged commit e8c2ebda13 into main 2026-08-07 19:12:59 +08:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: WonderableAI/shaguabijia-app-server#225