docs(coin-ledger): 分步实施计划(TDD:列/迁移回填/发奖透传/聚合查询/防残组/注释)
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>
This commit is contained in:
@@ -0,0 +1,761 @@
|
||||
# 金币记录按会话汇总比价/领券看广告金币 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:** App「金币变动记录」里,一次比价 / 一次领券连续看广告获得的多条金币,按 `trace_id` 聚合成一条展示。
|
||||
|
||||
**Architecture:** 纯后端。给 `coin_transaction` 加 `trace_id` 列并在发奖时写入;`GET /api/v1/wallet/coin-transactions` 改为按 `trace_id` 分组的游标分页(比价/领券两类 feed 广告按会话合并,其余每条一行);历史行从 `ad_feed_reward_record` 回填。领券自 2026-07-15 起结算已带 trace_id,无需改 Android。仅动 App 用户接口,admin 审计接口不变。
|
||||
|
||||
**Tech Stack:** FastAPI · SQLAlchemy 2.0 · Alembic · Pydantic v2 · pytest(SQLite)。
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-08-07-coin-ledger-aggregate-ad-rewards-design.md`
|
||||
|
||||
**约定:** dev / pytest 均 **SQLite**,prod PostgreSQL——所有 SQL 保持 SQLite/PG 通用(不用 PG 专有语法)。金额单位为金币(非分)。
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| 文件 | 改动 | 职责 |
|
||||
|---|---|---|
|
||||
| `app/core/rewards.py` | 加常量 `FEED_AD_SESSION_BIZ_TYPES` | 「按会话聚合」的两类 biz_type 单一来源(查询 + 写入 + 测试共用) |
|
||||
| `app/models/wallet.py` | `CoinTransaction` 加 `trace_id` 列 | 金币流水会话键 |
|
||||
| `app/repositories/wallet.py` | `grant_coins` 加 `trace_id` 参;`list_coin_transactions` 改分组分页;加 `CoinLedgerRow` | 写入透传 + 聚合读取 |
|
||||
| `app/repositories/ad_feed_reward.py` | `grant_feed_reward` 调 `grant_coins` 时传 `trace_id` | 把本场 trace_id 写进金币流水 |
|
||||
| `app/schemas/welfare.py` | `CoinTransactionOut` 加 `merged_count` | 下发合并条数 |
|
||||
| `app/models/ad_feed_reward.py` | 更正 `trace_id` 注释 | 文档(领券自 2026-07-15 也带) |
|
||||
| `alembic/versions/coin_transaction_trace_id.py` | 新建迁移 | 加列 + 索引 + 回填历史 |
|
||||
| `tests/test_welfare.py` | 追加测试 | 覆盖写入 / 回填 / 发奖透传 / 聚合 / 分页防残组 |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 常量 + `CoinTransaction.trace_id` 列 + `grant_coins` 透传
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/core/rewards.py`(加常量)
|
||||
- Modify: `app/models/wallet.py:86`(`remark` 后加 `trace_id` 列)
|
||||
- Modify: `app/repositories/wallet.py:165-196`(`grant_coins` 加参数 + 写入)
|
||||
- Test: `tests/test_welfare.py`(追加)
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
追加到 `tests/test_welfare.py` 末尾:
|
||||
|
||||
```python
|
||||
def test_grant_coins_persists_trace_id(client) -> None:
|
||||
"""grant_coins 传 trace_id 落库;不传则为 None。"""
|
||||
phone = "13800002001"
|
||||
_login(client, phone)
|
||||
with SessionLocal() as db:
|
||||
user = get_user_by_phone(db, phone)
|
||||
assert user is not None
|
||||
_, txn1 = crud_wallet.grant_coins(
|
||||
db, user.id, 5, biz_type="feed_ad_reward_comparison",
|
||||
ref_id="evt1", remark="比价奖励", trace_id="trace-A",
|
||||
)
|
||||
_, txn2 = crud_wallet.grant_coins(
|
||||
db, user.id, 30, biz_type="signin", remark="每日签到奖励",
|
||||
)
|
||||
db.commit()
|
||||
assert txn1.trace_id == "trace-A"
|
||||
assert txn2.trace_id is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pytest tests/test_welfare.py::test_grant_coins_persists_trace_id -q`
|
||||
Expected: FAIL —— `TypeError: grant_coins() got an unexpected keyword argument 'trace_id'`
|
||||
|
||||
- [ ] **Step 3: 加常量**
|
||||
|
||||
在 `app/core/rewards.py` 的签到常量段之后(约第 32 行 `SIGNIN_CYCLE_LEN` 之后)插入:
|
||||
|
||||
```python
|
||||
# ===== 信息流广告「按会话聚合」的 biz_type =====
|
||||
# 比价 / 领券等候期看的信息流广告,每条各写一条 coin_transaction;这两类在「金币变动记录」
|
||||
# 里按 trace_id 聚合成一条展示(见 repositories/wallet.list_coin_transactions)。其余类型
|
||||
# (reward_video / guide_video / 通用 feed_ad_reward / signin / task_* 等)不聚合。
|
||||
FEED_AD_SESSION_BIZ_TYPES: tuple[str, str] = (
|
||||
"feed_ad_reward_comparison",
|
||||
"feed_ad_reward_coupon",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 加模型列**
|
||||
|
||||
`app/models/wallet.py`,把 `remark` 行(第 86 行)后面补一列:
|
||||
|
||||
```python
|
||||
remark: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# 会话键:仅比价/领券信息流发奖(feed_ad_reward_comparison/coupon)时写入本场 trace_id,
|
||||
# 金币记录列表据此把一次比价/领券的多条广告金币聚合成一条。其余类型 = NULL。
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
```
|
||||
|
||||
(`String` 在该文件已导入。`index=True` 会在测试 `create_all` 时自动建 `ix_coin_transaction_trace_id`。)
|
||||
|
||||
- [ ] **Step 5: `grant_coins` 加参数 + 写入**
|
||||
|
||||
`app/repositories/wallet.py` 的 `grant_coins`:签名加 `trace_id`,构造 `CoinTransaction` 时带上。
|
||||
|
||||
签名(第 165-173 行)改为:
|
||||
|
||||
```python
|
||||
def grant_coins(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
amount: int,
|
||||
*,
|
||||
biz_type: str,
|
||||
ref_id: str | None = None,
|
||||
remark: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
) -> tuple[CoinAccount, CoinTransaction]:
|
||||
```
|
||||
|
||||
`CoinTransaction(...)` 构造(第 185-193 行)改为:
|
||||
|
||||
```python
|
||||
txn = CoinTransaction(
|
||||
user_id=user_id,
|
||||
amount=amount,
|
||||
balance_after=acc.coin_balance,
|
||||
biz_type=biz_type,
|
||||
ref_id=ref_id,
|
||||
remark=remark,
|
||||
trace_id=trace_id,
|
||||
created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None), # 存北京 wall-clock(客户端原样切片显示)
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 跑测试确认通过**
|
||||
|
||||
Run: `pytest tests/test_welfare.py::test_grant_coins_persists_trace_id -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: 回归 + lint**
|
||||
|
||||
Run: `pytest tests/test_welfare.py -q && ruff check app/core/rewards.py app/models/wallet.py app/repositories/wallet.py tests/test_welfare.py`
|
||||
Expected: 原有用例仍 PASS,无新增 lint。
|
||||
|
||||
- [ ] **Step 8: 提交**
|
||||
|
||||
```bash
|
||||
git add app/core/rewards.py app/models/wallet.py app/repositories/wallet.py tests/test_welfare.py
|
||||
git commit -m "feat(wallet): coin_transaction 增 trace_id 列 + grant_coins 透传
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: 迁移(加列 + 索引 + 回填历史)
|
||||
|
||||
**Files:**
|
||||
- Create: `alembic/versions/coin_transaction_trace_id.py`
|
||||
- Test: `tests/test_welfare.py`(追加回填语义测试)
|
||||
|
||||
- [ ] **Step 1: 写失败测试(回填语义)**
|
||||
|
||||
在 `tests/test_welfare.py` 顶部 import 区补两行(若尚无):
|
||||
|
||||
```python
|
||||
from sqlalchemy import text
|
||||
from app.models.ad_feed_reward import AdFeedRewardRecord
|
||||
```
|
||||
|
||||
追加测试:
|
||||
|
||||
```python
|
||||
def test_backfill_coin_trace_id_from_ad_record(client) -> None:
|
||||
"""回填:coin_transaction(trace_id 空)按 ref_id==client_event_id 从 ad_feed_reward_record 补 trace_id;
|
||||
只补比价/领券两类,无关类型与无匹配的不动。SQL 与迁移 coin_transaction_trace_id 保持同步。"""
|
||||
phone = "13800002002"
|
||||
_login(client, phone)
|
||||
with SessionLocal() as db:
|
||||
user = get_user_by_phone(db, phone)
|
||||
assert user is not None
|
||||
# 广告表:两条带 trace_id 的记录(模拟客户端已上报)
|
||||
db.add(AdFeedRewardRecord(
|
||||
client_event_id="evt-cmp", user_id=user.id, reward_date="2026-08-07",
|
||||
duration_seconds=10, unit_count=1, ecpm_raw="1000",
|
||||
feed_scene="comparison", trace_id="trace-CMP", coin=5, status="granted",
|
||||
))
|
||||
db.add(AdFeedRewardRecord(
|
||||
client_event_id="evt-cpn", user_id=user.id, reward_date="2026-08-07",
|
||||
duration_seconds=10, unit_count=1, ecpm_raw="1000",
|
||||
feed_scene="coupon", trace_id="trace-CPN", coin=7, status="granted",
|
||||
))
|
||||
db.commit()
|
||||
# 老金币流水:trace_id 全空(模拟改动前),ref_id 指向上面的广告事件
|
||||
_, c1 = crud_wallet.grant_coins(db, user.id, 5, biz_type="feed_ad_reward_comparison", ref_id="evt-cmp", remark="比价奖励")
|
||||
_, c2 = crud_wallet.grant_coins(db, user.id, 7, biz_type="feed_ad_reward_coupon", ref_id="evt-cpn", remark="领券奖励")
|
||||
_, c3 = crud_wallet.grant_coins(db, user.id, 30, biz_type="signin", remark="每日签到奖励")
|
||||
db.commit()
|
||||
assert c1.trace_id is None and c2.trace_id is None
|
||||
|
||||
# 执行与迁移 upgrade() 等价的回填 SQL(务必与迁移保持一致)
|
||||
db.execute(text(
|
||||
"""
|
||||
UPDATE coin_transaction SET trace_id = (
|
||||
SELECT r.trace_id FROM ad_feed_reward_record r
|
||||
WHERE r.client_event_id = coin_transaction.ref_id)
|
||||
WHERE biz_type IN ('feed_ad_reward_comparison', 'feed_ad_reward_coupon')
|
||||
AND trace_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM ad_feed_reward_record r2
|
||||
WHERE r2.client_event_id = coin_transaction.ref_id
|
||||
AND r2.trace_id IS NOT NULL)
|
||||
"""
|
||||
))
|
||||
db.commit()
|
||||
db.refresh(c1); db.refresh(c2); db.refresh(c3)
|
||||
assert c1.trace_id == "trace-CMP" # 比价补上
|
||||
assert c2.trace_id == "trace-CPN" # 领券补上
|
||||
assert c3.trace_id is None # 签到不动
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pytest tests/test_welfare.py::test_backfill_coin_trace_id_from_ad_record -q`
|
||||
Expected: FAIL —— `sqlite3.OperationalError: no such column: trace_id`(Task 1 已加列则此步应改为直接 PASS;若已 PASS 说明 create_all 已含列,跳到 Step 4 建迁移)。
|
||||
|
||||
> 说明:本测试验证的是回填 SQL 的 JOIN 语义,不经 Alembic。Task 1 完成后列已在测试库存在,测试可能直接 PASS——这是预期的(回填 SQL 本身是正确逻辑)。真正要新建的产物是迁移文件(Step 4),供 dev/prod 使用。
|
||||
|
||||
- [ ] **Step 3: 跑测试确认通过**
|
||||
|
||||
Run: `pytest tests/test_welfare.py::test_backfill_coin_trace_id_from_ad_record -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 4: 建迁移文件**
|
||||
|
||||
Create `alembic/versions/coin_transaction_trace_id.py`:
|
||||
|
||||
```python
|
||||
"""coin_transaction.trace_id (金币记录按会话聚合比价/领券看广告金币)
|
||||
|
||||
Revision ID: coin_transaction_trace_id
|
||||
Revises: comparison_updated_at
|
||||
Create Date: 2026-08-07
|
||||
|
||||
比价/领券信息流发奖时把本场 trace_id 一并写入 coin_transaction;金币变动记录接口按
|
||||
trace_id 把一次比价/领券的多条广告金币聚合成一条。历史行从 ad_feed_reward_record
|
||||
回填(ref_id == client_event_id)。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'coin_transaction_trace_id'
|
||||
down_revision: Union[str, Sequence[str], None] = 'comparison_updated_at'
|
||||
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。
|
||||
op.add_column(
|
||||
'coin_transaction',
|
||||
sa.Column('trace_id', sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_coin_transaction_trace_id'),
|
||||
'coin_transaction',
|
||||
['trace_id'],
|
||||
unique=False,
|
||||
)
|
||||
# 历史回填:从 ad_feed_reward_record 按 ref_id==client_event_id 补 trace_id。仅比价/领券两类、
|
||||
# 仅当前为空、且广告行确有 trace_id 时补(EXISTS 守护);`trace_id IS NULL` 保证重跑幂等。
|
||||
# 相关子查询 SQLite/PG 通用。两类 biz_type 在应用侧为 rewards.FEED_AD_SESSION_BIZ_TYPES,
|
||||
# 此处按「迁移不可变」原则硬编码历史快照(勿改为 import 应用常量)。
|
||||
# 大表(prod PG)如需可改按 id 区间分批;此处一次性 UPDATE。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE coin_transaction SET trace_id = (
|
||||
SELECT r.trace_id FROM ad_feed_reward_record r
|
||||
WHERE r.client_event_id = coin_transaction.ref_id)
|
||||
WHERE biz_type IN ('feed_ad_reward_comparison', 'feed_ad_reward_coupon')
|
||||
AND trace_id IS NULL
|
||||
AND EXISTS (SELECT 1 FROM ad_feed_reward_record r2
|
||||
WHERE r2.client_event_id = coin_transaction.ref_id
|
||||
AND r2.trace_id IS NOT NULL)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
op.f('ix_coin_transaction_trace_id'),
|
||||
table_name='coin_transaction',
|
||||
)
|
||||
op.drop_column('coin_transaction', 'trace_id')
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 验证迁移可正反向应用(用一次性 scratch SQLite,不碰 dev 库)**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
DATABASE_URL="sqlite:///./data/_migcheck.db" python -m alembic upgrade head && \
|
||||
DATABASE_URL="sqlite:///./data/_migcheck.db" python -m alembic downgrade -1 && \
|
||||
DATABASE_URL="sqlite:///./data/_migcheck.db" python -m alembic upgrade head && \
|
||||
rm -f ./data/_migcheck.db
|
||||
```
|
||||
Expected: 三条 alembic 命令均无报错,最终 head 落在 `coin_transaction_trace_id`;`rm` 清掉临时库。
|
||||
|
||||
- [ ] **Step 6: lint**
|
||||
|
||||
Run: `ruff check alembic/versions/coin_transaction_trace_id.py tests/test_welfare.py`
|
||||
Expected: 无新增 lint。
|
||||
|
||||
- [ ] **Step 7: 提交**
|
||||
|
||||
```bash
|
||||
git add alembic/versions/coin_transaction_trace_id.py tests/test_welfare.py
|
||||
git commit -m "feat(wallet): 迁移加 coin_transaction.trace_id + 索引 + 回填历史
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `grant_feed_reward` 把 trace_id 写进金币流水
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/repositories/ad_feed_reward.py:219-223`(`grant_coins` 调用加 `trace_id`)
|
||||
- Test: `tests/test_welfare.py`(追加)
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
在 `tests/test_welfare.py` 顶部 import 区补(若尚无):
|
||||
|
||||
```python
|
||||
from sqlalchemy import select
|
||||
from app.models.wallet import CoinTransaction
|
||||
from app.repositories import ad_feed_reward as crud_feed
|
||||
```
|
||||
|
||||
追加测试:
|
||||
|
||||
```python
|
||||
def test_grant_feed_reward_sets_coin_trace_id(client) -> None:
|
||||
"""grant_feed_reward(comparison) 把 trace_id 透传给 grant_coins,coin_transaction 带上本场 trace_id。"""
|
||||
phone = "13800002003"
|
||||
_login(client, phone)
|
||||
with SessionLocal() as db:
|
||||
user = get_user_by_phone(db, phone)
|
||||
assert user is not None
|
||||
rec = crud_feed.grant_feed_reward(
|
||||
db, user.id,
|
||||
client_event_id="evt-fr-1", ecpm="1000", duration_seconds=10,
|
||||
feed_scene="comparison", trace_id="trace-FR", display_coin=5,
|
||||
)
|
||||
assert rec.status == "granted", rec.status
|
||||
txn = db.execute(
|
||||
select(CoinTransaction).where(
|
||||
CoinTransaction.user_id == user.id,
|
||||
CoinTransaction.ref_id == "evt-fr-1",
|
||||
)
|
||||
).scalar_one()
|
||||
assert txn.biz_type == "feed_ad_reward_comparison"
|
||||
assert txn.trace_id == "trace-FR"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pytest tests/test_welfare.py::test_grant_feed_reward_sets_coin_trace_id -q`
|
||||
Expected: FAIL —— `AssertionError: assert None == 'trace-FR'`(`grant_coins` 尚未收到 trace_id)。
|
||||
|
||||
- [ ] **Step 3: 传 trace_id**
|
||||
|
||||
`app/repositories/ad_feed_reward.py` 第 219-223 行 `grant_coins` 调用改为:
|
||||
|
||||
```python
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, coin,
|
||||
biz_type=reward_biz, ref_id=client_event_id,
|
||||
remark=reward_remark, trace_id=trace_id,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑测试确认通过**
|
||||
|
||||
Run: `pytest tests/test_welfare.py::test_grant_feed_reward_sets_coin_trace_id -q`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: lint**
|
||||
|
||||
Run: `ruff check app/repositories/ad_feed_reward.py tests/test_welfare.py`
|
||||
Expected: 无新增 lint。
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add app/repositories/ad_feed_reward.py tests/test_welfare.py
|
||||
git commit -m "feat(ad-feed): 发奖把本场 trace_id 写入金币流水
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: 聚合查询 —— `list_coin_transactions` 分组 + `CoinLedgerRow` + `merged_count`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/repositories/wallet.py`(imports + 加 `CoinLedgerRow` + 重写 `list_coin_transactions`)
|
||||
- Modify: `app/schemas/welfare.py:23-32`(`CoinTransactionOut` 加 `merged_count`)
|
||||
- Test: `tests/test_welfare.py`(追加 3 个聚合测试 + 一个 `_seed_coin` 帮手)
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
在 `tests/test_welfare.py` 追加帮手 + 测试:
|
||||
|
||||
```python
|
||||
def _seed_coin(db, user_id, amount, biz_type, *, trace_id=None, ref_id=None, remark=None):
|
||||
crud_wallet.grant_coins(
|
||||
db, user_id, amount, biz_type=biz_type, ref_id=ref_id, remark=remark, trace_id=trace_id
|
||||
)
|
||||
|
||||
|
||||
def test_coin_transactions_aggregate_by_trace(client) -> None:
|
||||
"""一次比价的多条广告金币聚合成一条:金额合计、merged_count=条数、balance_after 取最后一条。"""
|
||||
phone = "13800002004"
|
||||
token = _login(client, phone)
|
||||
with SessionLocal() as db:
|
||||
user = get_user_by_phone(db, phone)
|
||||
assert user is not None
|
||||
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="t1", ref_id="e1", remark="比价奖励")
|
||||
_seed_coin(db, user.id, 3, "feed_ad_reward_comparison", trace_id="t1", ref_id="e2", remark="比价奖励")
|
||||
_seed_coin(db, user.id, 4, "feed_ad_reward_comparison", trace_id="t1", ref_id="e3", remark="比价奖励")
|
||||
db.commit()
|
||||
r = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token))
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
row = items[0]
|
||||
assert row["biz_type"] == "feed_ad_reward_comparison"
|
||||
assert row["amount"] == 12 # 5+3+4
|
||||
assert row["merged_count"] == 3
|
||||
assert row["balance_after"] == 12 # 末条到账后余额(本用户从 0 起)
|
||||
|
||||
|
||||
def test_coin_transactions_distinct_traces_stay_separate(client) -> None:
|
||||
"""不同 trace(两次比价 / 一次领券)各成一条;不同会话不合并。"""
|
||||
phone = "13800002005"
|
||||
token = _login(client, phone)
|
||||
with SessionLocal() as db:
|
||||
user = get_user_by_phone(db, phone)
|
||||
assert user is not None
|
||||
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="cmpA", ref_id="a1", remark="比价奖励")
|
||||
_seed_coin(db, user.id, 6, "feed_ad_reward_comparison", trace_id="cmpB", ref_id="b1", remark="比价奖励")
|
||||
_seed_coin(db, user.id, 6, "feed_ad_reward_comparison", trace_id="cmpB", ref_id="b2", remark="比价奖励")
|
||||
_seed_coin(db, user.id, 7, "feed_ad_reward_coupon", trace_id="cpnC", ref_id="c1", remark="领券奖励")
|
||||
db.commit()
|
||||
items = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)).json()["items"]
|
||||
assert len(items) == 3
|
||||
assert sorted(i["amount"] for i in items) == [5, 7, 12]
|
||||
cpn = next(i for i in items if i["biz_type"] == "feed_ad_reward_coupon")
|
||||
assert cpn["amount"] == 7 and cpn["merged_count"] == 1
|
||||
|
||||
|
||||
def test_coin_transactions_non_session_rows_stay_per_row(client) -> None:
|
||||
"""签到 / 无 trace 的通用信息流各自一行,不被聚合;夹在比价广告中间的签到不影响比价聚合。"""
|
||||
phone = "13800002006"
|
||||
token = _login(client, phone)
|
||||
with SessionLocal() as db:
|
||||
user = get_user_by_phone(db, phone)
|
||||
assert user is not None
|
||||
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="t1", ref_id="e1", remark="比价奖励")
|
||||
_seed_coin(db, user.id, 30, "signin", remark="每日签到奖励") # 夹在两条比价广告中间
|
||||
_seed_coin(db, user.id, 4, "feed_ad_reward_comparison", trace_id="t1", ref_id="e2", remark="比价奖励")
|
||||
_seed_coin(db, user.id, 8, "feed_ad_reward", trace_id=None, ref_id="w1", remark="信息流广告奖励")
|
||||
db.commit()
|
||||
items = client.get("/api/v1/wallet/coin-transactions", headers=_auth(token)).json()["items"]
|
||||
assert len(items) == 3
|
||||
cmp_row = next(i for i in items if i["biz_type"] == "feed_ad_reward_comparison")
|
||||
assert cmp_row["amount"] == 9 and cmp_row["merged_count"] == 2
|
||||
signin_row = next(i for i in items if i["biz_type"] == "signin")
|
||||
assert signin_row["amount"] == 30 and signin_row["merged_count"] == 1
|
||||
feed_row = next(i for i in items if i["biz_type"] == "feed_ad_reward")
|
||||
assert feed_row["amount"] == 8 and feed_row["merged_count"] == 1
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
Run: `pytest tests/test_welfare.py::test_coin_transactions_aggregate_by_trace -q`
|
||||
Expected: FAIL —— 未聚合,返回 3 条 / `merged_count` 字段缺失(`KeyError` 或 `len(items)==3`)。
|
||||
|
||||
- [ ] **Step 3: schema 加 `merged_count`**
|
||||
|
||||
`app/schemas/welfare.py` 的 `CoinTransactionOut`(第 23-32 行)在 `created_at` 后加一行:
|
||||
|
||||
```python
|
||||
class CoinTransactionOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
amount: int = Field(..., description="正=入账,负=出账")
|
||||
balance_after: int
|
||||
biz_type: str
|
||||
ref_id: str | None = None
|
||||
remark: str | None = None
|
||||
created_at: datetime
|
||||
merged_count: int = Field(1, description="本行合并的底层流水条数(比价/领券按会话聚合;未合并=1)")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 补 imports + 加 `CoinLedgerRow`**
|
||||
|
||||
`app/repositories/wallet.py` 顶部:把 `from sqlalchemy import func, select, update`(第 15 行)改为:
|
||||
|
||||
```python
|
||||
from sqlalchemy import String, and_, case, cast, func, literal, select, update
|
||||
```
|
||||
|
||||
在 stdlib import 区(约第 7-13 行)加一行:
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
```
|
||||
|
||||
在 `list_coin_transactions` 之前加返回行类型:
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class CoinLedgerRow:
|
||||
"""`list_coin_transactions` 的返回行。广告类按 trace_id 聚合后的展示行,
|
||||
**非 ORM 对象**(避免把聚合后的 amount 误写回底层流水)。"""
|
||||
id: int
|
||||
amount: int
|
||||
balance_after: int
|
||||
biz_type: str
|
||||
ref_id: str | None
|
||||
remark: str | None
|
||||
created_at: datetime
|
||||
merged_count: int
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 重写 `list_coin_transactions`**
|
||||
|
||||
`app/repositories/wallet.py` 第 260-279 行整体替换为:
|
||||
|
||||
```python
|
||||
def list_coin_transactions(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
limit: int = 20,
|
||||
cursor: int | None = None,
|
||||
) -> tuple[list[CoinLedgerRow], int | None]:
|
||||
"""金币流水分页(游标式,按 id 倒序)。
|
||||
|
||||
比价/领券两类信息流广告(rewards.FEED_AD_SESSION_BIZ_TYPES)且带 trace_id 的行,
|
||||
按 trace_id 聚合成一条(一次比价/领券 = 一行):金额合计、代表行取组内最新一条
|
||||
(MAX(id))的余额/时间、merged_count=组内条数。其余每条一行。
|
||||
|
||||
分组必须在该用户**全量**行上算真实 rep_id 后再按 rep_id 过滤——**不可**把 CTE 输入
|
||||
裁成 id<cursor,否则会话行交错跨游标时会算出与上页重复的「残组」。
|
||||
|
||||
cursor 为上一页最后一条的 id(即其组 rep_id);返回 (本页列表, next_cursor)。
|
||||
"""
|
||||
ct = CoinTransaction
|
||||
group_key = case(
|
||||
(
|
||||
and_(
|
||||
ct.biz_type.in_(rewards.FEED_AD_SESSION_BIZ_TYPES),
|
||||
ct.trace_id.is_not(None),
|
||||
),
|
||||
literal("T:") + ct.trace_id,
|
||||
),
|
||||
else_=literal("I:") + cast(ct.id, String),
|
||||
).label("group_key")
|
||||
|
||||
grp = (
|
||||
select(
|
||||
group_key,
|
||||
func.max(ct.id).label("rep_id"),
|
||||
func.sum(ct.amount).label("total_amount"),
|
||||
func.count().label("merged_count"),
|
||||
)
|
||||
.where(ct.user_id == user_id)
|
||||
.group_by(group_key)
|
||||
.cte("grp")
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
ct.id,
|
||||
grp.c.total_amount.label("amount"),
|
||||
ct.balance_after,
|
||||
ct.biz_type,
|
||||
ct.ref_id,
|
||||
ct.remark,
|
||||
ct.created_at,
|
||||
grp.c.merged_count,
|
||||
)
|
||||
.select_from(grp)
|
||||
.join(ct, ct.id == grp.c.rep_id)
|
||||
)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(grp.c.rep_id < cursor)
|
||||
stmt = stmt.order_by(grp.c.rep_id.desc()).limit(limit)
|
||||
|
||||
rows = db.execute(stmt).all()
|
||||
items = [
|
||||
CoinLedgerRow(
|
||||
id=r.id,
|
||||
amount=int(r.amount),
|
||||
balance_after=r.balance_after,
|
||||
biz_type=r.biz_type,
|
||||
ref_id=r.ref_id,
|
||||
remark=r.remark,
|
||||
created_at=r.created_at,
|
||||
merged_count=int(r.merged_count),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
next_cursor = items[-1].id if len(items) == limit else None
|
||||
return items, next_cursor
|
||||
```
|
||||
|
||||
(端点 `app/api/v1/wallet.py` 无需改:`CoinTransactionOut.model_validate(it)` 对 `CoinLedgerRow` dataclass 按 `from_attributes` 读取即可。)
|
||||
|
||||
- [ ] **Step 6: 跑三个聚合测试确认通过**
|
||||
|
||||
Run: `pytest tests/test_welfare.py -k "coin_transactions_aggregate_by_trace or distinct_traces_stay_separate or non_session_rows_stay_per_row" -q`
|
||||
Expected: 3 PASS
|
||||
|
||||
- [ ] **Step 7: 全量回归 + lint**
|
||||
|
||||
Run: `pytest tests/test_welfare.py -q && ruff check app/repositories/wallet.py app/schemas/welfare.py tests/test_welfare.py`
|
||||
Expected: 原有用例(含 `test_signin_flow` / `test_exchange_flow` 等对 coin-transactions 的断言)仍 PASS;无新增 lint。
|
||||
|
||||
- [ ] **Step 8: 提交**
|
||||
|
||||
```bash
|
||||
git add app/repositories/wallet.py app/schemas/welfare.py tests/test_welfare.py
|
||||
git commit -m "feat(wallet): 金币记录按 trace_id 聚合比价/领券看广告金币
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: 分页防残组回归测试
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/test_welfare.py`(追加)
|
||||
|
||||
- [ ] **Step 1: 写回归测试**
|
||||
|
||||
追加:
|
||||
|
||||
```python
|
||||
def test_coin_transactions_pagination_no_phantom_regroup(client) -> None:
|
||||
"""交错跨游标不产生残组:会话广告成员被其它记录隔开、rep_id 在游标上、成员在游标下时,
|
||||
翻到下一页该会话不得以「残组」重复出现(锁死 spec §11 反面优化警示)。"""
|
||||
phone = "13800002007"
|
||||
token = _login(client, phone)
|
||||
with SessionLocal() as db:
|
||||
user = get_user_by_phone(db, phone)
|
||||
assert user is not None
|
||||
_seed_coin(db, user.id, 5, "feed_ad_reward_comparison", trace_id="t1", ref_id="e1", remark="比价奖励") # id=n+1
|
||||
_seed_coin(db, user.id, 30, "signin", remark="每日签到奖励") # id=n+2
|
||||
_seed_coin(db, user.id, 40, "signin", remark="每日签到奖励") # id=n+3
|
||||
_seed_coin(db, user.id, 4, "feed_ad_reward_comparison", trace_id="t1", ref_id="e2", remark="比价奖励") # id=n+4 = t1 的 rep
|
||||
db.commit()
|
||||
# 第 1 页(limit=2):按 rep 降序 = [t1(rep=n+4, 合计 9), signin(n+3, 40)]
|
||||
p1 = client.get("/api/v1/wallet/coin-transactions?limit=2", headers=_auth(token)).json()
|
||||
assert len(p1["items"]) == 2
|
||||
assert p1["items"][0]["biz_type"] == "feed_ad_reward_comparison"
|
||||
assert p1["items"][0]["amount"] == 9 and p1["items"][0]["merged_count"] == 2
|
||||
assert p1["items"][1]["biz_type"] == "signin" and p1["items"][1]["amount"] == 40
|
||||
assert p1["next_cursor"] is not None
|
||||
# 第 2 页:只剩另一条 signin(30);t1 的 rep 在游标上,成员虽在游标下也不得成残组重复
|
||||
p2 = client.get(
|
||||
f"/api/v1/wallet/coin-transactions?limit=2&cursor={p1['next_cursor']}",
|
||||
headers=_auth(token),
|
||||
).json()
|
||||
assert len(p2["items"]) == 1
|
||||
assert p2["items"][0]["biz_type"] == "signin" and p2["items"][0]["amount"] == 30
|
||||
assert all(i["biz_type"] != "feed_ad_reward_comparison" for i in p2["items"])
|
||||
assert p2["next_cursor"] is None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测试确认通过(锁定不变量)**
|
||||
|
||||
Run: `pytest tests/test_welfare.py::test_coin_transactions_pagination_no_phantom_regroup -q`
|
||||
Expected: PASS(Task 4 的全量分组实现已正确;若 FAIL 说明 CTE 被错误地按 cursor 裁剪,回到 Task 4 Step 5 修正)。
|
||||
|
||||
- [ ] **Step 3: lint**
|
||||
|
||||
Run: `ruff check tests/test_welfare.py`
|
||||
Expected: 无新增 lint。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add tests/test_welfare.py
|
||||
git commit -m "test(wallet): 锁死金币记录分组分页不产生残组
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: 更正 `ad_feed_reward_record.trace_id` 过时注释
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/models/ad_feed_reward.py:39-40`
|
||||
|
||||
- [ ] **Step 1: 改注释**
|
||||
|
||||
`app/models/ad_feed_reward.py` 第 39-40 行两行注释替换为:
|
||||
|
||||
```python
|
||||
# 本次会话 trace_id:比价一直带;领券自 2026-07-15(客户端 a98cab8)起也带;福利/旧客户端 = NULL。
|
||||
# 用途:比价记录页按 trace_id 聚合「比价赚 N 金币」;金币记录列表把一次比价/领券的多条广告金币聚合成一条。
|
||||
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 冒烟 + lint**
|
||||
|
||||
Run: `python -c "import app.models.ad_feed_reward" && ruff check app/models/ad_feed_reward.py`
|
||||
Expected: 无报错、无 lint。
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add app/models/ad_feed_reward.py
|
||||
git commit -m "docs(ad-feed): 更正 trace_id 注释(领券自 2026-07-15 也带)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 收尾验证
|
||||
|
||||
- [ ] **全量测试**:`pytest -q`(对齐 memory「preexisting-test-lint-debt」:先前已知失败数不变、无本改动引入的新失败)。
|
||||
- [ ] **lint**:`ruff check .`(无本改动引入的新问题)。
|
||||
- [ ] **迁移落 dev**:`python -m alembic upgrade head`(把 dev SQLite 迁到最新,确认 `./run.sh` 正常)。
|
||||
|
||||
---
|
||||
|
||||
## Self-Review(写完计划后自查)
|
||||
|
||||
**Spec 覆盖:**
|
||||
- §4 加列 → Task 1 Step 4 + Task 2。✓
|
||||
- §5 写入透传 → Task 1 Step 5 + Task 3。✓
|
||||
- §6 聚合查询(CASE 分组键、MAX(id) 代表/游标、biz_type 门控、常量)→ Task 4 Step 4-5 + Task 1 常量。✓
|
||||
- §7 `merged_count` 下发 → Task 4 Step 3。✓
|
||||
- §8 回填(幂等 + EXISTS 守护 + 通用 SQL)→ Task 2 Step 4。✓
|
||||
- §9 注释更正 → Task 6。✓
|
||||
- §10 App 端无需改 → 端点未改(Task 4 Step 5 注)。✓
|
||||
- §11 反面优化警示(残组)→ Task 4 docstring + Task 5 回归测试。✓
|
||||
- §3 只改用户接口、admin 不动 → 全程只碰 `crud_wallet.list_coin_transactions`,未触 `app/admin`。✓
|
||||
- §12 测试(分组/隔离/空 trace/分页/回填)→ Task 2/4/5。✓
|
||||
|
||||
**占位扫描:** 无 TBD / TODO;每个代码步骤均给出完整代码与确切命令。✓
|
||||
|
||||
**类型一致:** `grant_coins(..., trace_id=...)`(Task 1)↔ `grant_feed_reward` 调用(Task 3)↔ 查询 `rewards.FEED_AD_SESSION_BIZ_TYPES`(Task 4)↔ 迁移硬编码同两值(Task 2,有意快照)一致;`CoinLedgerRow` 字段(Task 4 Step 4)↔ `CoinTransactionOut` 字段(Task 4 Step 3)逐一对应(id/amount/balance_after/biz_type/ref_id/remark/created_at/merged_count)。✓
|
||||
Reference in New Issue
Block a user