Compare commits

..

1 Commits

Author SHA1 Message Date
marco d9babc0ba3 更新文档0709 2026-07-09 14:23:59 +08:00
45 changed files with 23 additions and 3434 deletions
@@ -1,72 +0,0 @@
"""analytics_selfstat tables
Revision ID: 11c44afbea58
Revises: admin_user_plain_password
Create Date: 2026-07-08 16:32:49.351817
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '11c44afbea58'
down_revision: str | Sequence[str] | None = 'admin_user_plain_password'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
'analytics_selfstat',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('device_id', sa.String(length=64), nullable=False),
sa.Column('epoch_id', sa.String(length=64), nullable=False),
sa.Column('app_ver', sa.String(length=32), nullable=True),
sa.Column('oem', sa.String(length=32), nullable=True),
sa.Column('os', sa.String(length=32), nullable=True),
sa.Column('batches_attempted', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('batches_ok', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('batches_fail', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('retries', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('queue_depth', sa.Integer(), nullable=False, server_default='0'),
sa.Column('sent_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True),
server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_analytics_selfstat_created_at'), ['created_at'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_selfstat_device_id'), ['device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_selfstat_epoch_id'), ['epoch_id'], unique=False)
op.create_table(
'analytics_selfstat_event',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('snapshot_id', sa.Integer(), nullable=False),
sa.Column('event', sa.String(length=64), nullable=False),
sa.Column('attempted', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('drop_capture', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('delivered', sa.BigInteger(), nullable=False, server_default='0'),
sa.Column('drop_undelivered', sa.BigInteger(), nullable=False, server_default='0'),
sa.ForeignKeyConstraint(['snapshot_id'], ['analytics_selfstat.id'], ),
sa.PrimaryKeyConstraint('id'),
)
with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_event'), ['event'], unique=False)
batch_op.create_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id'), ['snapshot_id'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('analytics_selfstat_event', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_snapshot_id'))
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_event_event'))
op.drop_table('analytics_selfstat_event')
with op.batch_alter_table('analytics_selfstat', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_epoch_id'))
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_device_id'))
batch_op.drop_index(batch_op.f('ix_analytics_selfstat_created_at'))
op.drop_table('analytics_selfstat')
-44
View File
@@ -1,44 +0,0 @@
"""ad_ecpm_record.trace_id(展示收益归属到比价/领券 trace)
信息流(Draw)展示 eCPM 上报时带上本场比价/领券 trace_id,落此列;领券数据 / 比价记录看板
按 trace_id 聚合"本次广告收益"。激励视频/福利/旧客户端为 NULL。
本迁移原以 (11c44afbea58, merge_pages_override_coupon_slot) 为双亲、顺带收敛双 head,
但与它并行落 main 的 merge_selfstat_coupon_slot 已用同一对双亲做了纯收敛 → 同一对
父节点出现两个收敛点、main 上又成双 head。故重挂到该 merge 之后成单链(仅改链接、
schema 改动不变;两文件都保留,已 stamp 在 merge 上的库可直接线性升级)。
Revision ID: ad_ecpm_trace_id
Revises: merge_selfstat_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] = "merge_selfstat_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")
-32
View File
@@ -1,32 +0,0 @@
"""coupon_claim_record 加 app_env 列(领券所属 session 环境;每券成功率表按它过滤 prod/dev)
Revision ID: coupon_claim_app_env
Revises: coupon_session_platform_success
Create Date: 2026-07-08 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "coupon_claim_app_env"
down_revision: str | Sequence[str] | None = "coupon_session_platform_success"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op:
batch_op.add_column(sa.Column("app_env", sa.String(length=16), nullable=True))
batch_op.create_index(
"ix_coupon_claim_record_app_env", ["app_env"], unique=False
)
def downgrade() -> None:
with op.batch_alter_table("coupon_claim_record", schema=None) as batch_op:
batch_op.drop_index("ix_coupon_claim_record_app_env")
batch_op.drop_column("app_env")
@@ -1,37 +0,0 @@
"""coupon_session 加 platform_success 列(本次至少领到一张的平台 id 列表)
供 admin「领券数据」算 ②整单成功率 / ③点位成功率(平台粒度)。数据落点:服务端 /step 逐帧
按 trace_id 并集写入(见 app/repositories/coupon_state.merge_session_platform_success)。旧行 NULL
视作空集,已建表环境靠它补列、全新环境顺序应用不重复加列。设计:docs/guides/领券成功率指标-设计与埋点.md。
Revision ID: coupon_session_platform_success
Revises: admin_user_plain_password
Create Date: 2026-07-07 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "coupon_session_platform_success"
down_revision: str | Sequence[str] | None = "admin_user_plain_password"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# PG 用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 model 的 _JSON variant / 建表迁移)。
_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql")
def upgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.add_column(sa.Column("platform_success", _JSON, nullable=True))
def downgrade() -> None:
with op.batch_alter_table("coupon_session", schema=None) as batch_op:
batch_op.drop_column("platform_success")
@@ -1,28 +0,0 @@
"""合并两个 alembic head:admin_user_pages_override(#126 权限)+ coupon_claim_app_env(领券成功率)。
两条迁移都从 admin_user_plain_password 分叉——#126 经 pull main 进入本分支,领券成功率为本分支新增——
于是出现两个 head。本迁移仅把二者收敛成单 head,让 `alembic upgrade head`(单数,部署/run.sh 用)
恢复正常;**不含任何表结构 / 数据改动**(纯 merge)。
Revision ID: merge_pages_override_coupon_slot
Revises: admin_user_pages_override, coupon_claim_app_env
Create Date: 2026-07-09 00:00:00.000000
"""
from collections.abc import Sequence
revision: str = "merge_pages_override_coupon_slot"
down_revision: str | Sequence[str] | None = (
"admin_user_pages_override",
"coupon_claim_app_env",
)
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""纯合并 head,无 schema 改动。"""
def downgrade() -> None:
"""拆回两个 head,无 schema 改动。"""
@@ -1,29 +0,0 @@
"""合并两个 alembic head:11c44afbea58(#127 埋点健康度 selfstat)+ merge_pages_override_coupon_slot(#130 自带的合并迁移)。
三条分支都从 admin_user_plain_password 分叉(#126 权限 / #127 selfstat / #130 领券成功率)。
#130 自带的 merge 创建时本地 main 尚无 #127 的 11c44afbea58,只收敛了 #126 + 自身两条,
#130 合入后 main 上仍留两个 head → `alembic upgrade head`(单数,部署/run.sh 用)直接报错、服务起不来。
本迁移仅把二者收敛成单 head;**不含任何表结构 / 数据改动**(纯 merge)。
Revision ID: merge_selfstat_coupon_slot
Revises: 11c44afbea58, merge_pages_override_coupon_slot
Create Date: 2026-07-10 00:00:00.000000
"""
from collections.abc import Sequence
revision: str = "merge_selfstat_coupon_slot"
down_revision: str | Sequence[str] | None = (
"11c44afbea58",
"merge_pages_override_coupon_slot",
)
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""纯合并 head,无 schema 改动。"""
def downgrade() -> None:
"""拆回两个 head,无 schema 改动。"""
-2
View File
@@ -26,7 +26,6 @@ from app.admin.routers.cps import router as cps_router
from app.admin.routers.dashboard import router as dashboard_router
from app.admin.routers.device_liveness import router as device_liveness_router
from app.admin.routers.ops_stat_config import router as ops_stat_config_router
from app.admin.routers.analytics_health import router as analytics_health_router
from app.admin.routers.event_logs import router as event_logs_router
from app.admin.routers.feedback import router as feedback_router
from app.admin.routers.feedback_qr import router as feedback_qr_router
@@ -98,7 +97,6 @@ admin_app.include_router(withdraw_router)
admin_app.include_router(price_report_router)
admin_app.include_router(feedback_router)
admin_app.include_router(event_logs_router)
admin_app.include_router(analytics_health_router)
admin_app.include_router(feedback_qr_router)
admin_app.include_router(admins_router)
admin_app.include_router(roles_router)
-146
View File
@@ -1,146 +0,0 @@
"""埋点健康度聚合(埋点成功率 / 上报成功率)。
只存原始累计快照,查询时在 Python 侧差分聚合(admin 低频、量级小,跨 PG/SQLite 无方言坑;
与 cps.py / coupon_data.py 同款约定)。差分按 (device_id, epoch_id, event) 分区、created_at
升序,相邻做差、负值夹 0;每增量按其快照 created_at 归入北京天桶。
"""
from __future__ import annotations
from collections import defaultdict
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.analytics_selfstat import AnalyticsSelfStat as H
from app.models.analytics_selfstat import AnalyticsSelfStatEvent as E
_COUNTS = ("attempted", "drop_capture", "delivered", "drop_undelivered")
def diff_snapshots(rows: list[dict]) -> list[dict]:
"""累计快照行 → 每快照增量行(纯逻辑)。
rows 每行含 device_id/epoch_id/event/created_at/app_ver/oem/os + 四个累计计数。
返回每行含 dims + created_at + 四个增量 d_*(分区首行增量=累计值;负值夹 0)。
"""
parts: dict[tuple, list[dict]] = defaultdict(list)
for r in rows:
parts[(r["device_id"], r["epoch_id"], r["event"])].append(r)
out: list[dict] = []
for group in parts.values():
group.sort(key=lambda r: (r["created_at"], r.get("id", 0)))
prev = {k: 0 for k in _COUNTS}
for r in group:
deltas = {f"d_{k}": max(0, int(r[k]) - prev[k]) for k in _COUNTS}
out.append({
"device_id": r["device_id"], "epoch_id": r["epoch_id"], "event": r["event"],
"created_at": r["created_at"], "app_ver": r["app_ver"],
"oem": r["oem"], "os": r["os"], **deltas,
})
prev = {k: int(r[k]) for k in _COUNTS}
return out
def _cn_day(dt: datetime) -> str:
"""created_at(UTC 口径)→ 北京日期字符串 YYYY-MM-DD。naive 当 UTC,tz-aware 直接换算。"""
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return dt.astimezone(rewards.CN_TZ).date().isoformat()
def _rates(sums: dict) -> dict:
"""由四个增量和派生两段率(分母 0 → None)。"""
persisted_denom = sums["attempted"]
report_denom = sums["delivered"] + sums["drop_undelivered"]
return {
**sums,
"track_success_rate": (
(sums["attempted"] - sums["drop_capture"]) / persisted_denom
if persisted_denom else None
),
"report_success_rate": (
sums["delivered"] / report_denom if report_denom else None
),
}
def _sum_deltas(deltas: list[dict]) -> dict:
return {k: sum(d[f"d_{k}"] for d in deltas) for k in _COUNTS}
def _fetch_rows(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
"""取 [from, to) 区间行 + 每分区在 from 左侧的最后一条基线行(供第一条区间增量做差)。"""
cols = (
H.id, H.device_id, H.epoch_id, E.event, H.created_at,
H.app_ver, H.oem, H.os,
E.attempted, E.drop_capture, E.delivered, E.drop_undelivered,
)
in_range = db.execute(
select(*cols).join(E, E.snapshot_id == H.id)
.where(H.created_at >= date_from, H.created_at < date_to)
).mappings().all()
# 注:基线子查询无下界扫 from 左侧全量(spec §7 已接受的取舍;量级变大再上物化 rollup)。
# 用 max(id) 而非 max(created_at) 选"最新一条":id 严格单调,避免 SQLite 秒级时间戳撞车时选歧义。
sub = (
select(H.device_id, H.epoch_id, E.event, func.max(H.id).label("max_id"))
.join(E, E.snapshot_id == H.id)
.where(H.created_at < date_from)
.group_by(H.device_id, H.epoch_id, E.event)
.subquery()
)
baseline = db.execute(
select(*cols).join(E, E.snapshot_id == H.id).join(
sub, sub.c.max_id == H.id
)
).mappings().all()
return [dict(r) for r in list(baseline) + list(in_range)]
def _in_range_deltas(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
"""差分后只保留 created_at ∈ [from, to) 的增量(基线行被差分用后丢弃)。
Python 侧过滤需对齐 tz 口径:SQLite 返回 naive UTC,PG 返回 aware UTC。
统一转成 naive UTC 再比较,兼容两种后端。
"""
def _to_naive_utc(dt: datetime) -> datetime:
if dt.tzinfo is not None:
return dt.astimezone(UTC).replace(tzinfo=None)
return dt
from_naive = _to_naive_utc(date_from)
to_naive = _to_naive_utc(date_to)
deltas = diff_snapshots(_fetch_rows(db, date_from, date_to))
return [d for d in deltas if from_naive <= _to_naive_utc(d["created_at"]) < to_naive]
def overview(db: Session, date_from: datetime, date_to: datetime) -> dict:
deltas = _in_range_deltas(db, date_from, date_to)
return _rates(_sum_deltas(deltas))
def trend(db: Session, date_from: datetime, date_to: datetime) -> list[dict]:
deltas = _in_range_deltas(db, date_from, date_to)
by_day: dict[str, list[dict]] = defaultdict(list)
for d in deltas:
by_day[_cn_day(d["created_at"])].append(d)
return [
{"day": day, **_rates(_sum_deltas(items))}
for day, items in sorted(by_day.items())
]
def breakdown(db: Session, date_from: datetime, date_to: datetime, dim: str) -> list[dict]:
if dim not in ("event", "app_ver", "oem"):
raise ValueError(f"invalid dim: {dim!r}")
deltas = _in_range_deltas(db, date_from, date_to)
by_key: dict[str, list[dict]] = defaultdict(list)
for d in deltas:
by_key[d.get(dim) or "(unknown)"].append(d)
rows = [{"key": key, **_rates(_sum_deltas(items))} for key, items in by_key.items()]
rows.sort(key=lambda r: (r["report_success_rate"] is None, r["report_success_rate"] or 0.0))
return rows
+6 -111
View File
@@ -5,21 +5,17 @@
- 发起数 = 区间内全部 session(含 started/completed/failed/abandoned),= 流失统计的基数。
- 完成数 / 耗时均值 / 分位 = 仅 status==completed 子集(成功跑完才有可比的"领券耗时")。
- summary/daily/hourly/total 在全量上算,不受分页;items 为排序后当前页。
- 另含 coupon_slot_report(数据源 coupon_claim_record):按 coupon_id「按券成功率」表,见设计 §13。
"""
from __future__ import annotations
from datetime import UTC, datetime
from datetime import date as _date
from datetime import UTC, date as _date, datetime
from sqlalchemy import case, func, or_, select
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.coupon_state import CouponClaimRecord, CouponSession
from app.models.coupon_state import 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
def _cn_hour(dt: datetime) -> int:
@@ -46,47 +42,7 @@ def _avg(vals: list[int]) -> int | None:
return round(sum(vals) / len(vals)) if vals else None
def _success_rates(rows: list) -> dict:
"""平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md §3/§12):
- sel(s) = 勾选平台(`platforms` 空 → 全领三档 DEFAULT_PLATFORMS);
- succ(s) = `platform_success` ∩ sel(至少领到一张的平台);
- ② 整单成功率 = #{sel⊆succ 且 sel≠∅} / 发起数;
- ③ 点位成功率 = Σ|succ| / Σ|sel|;per_platform[p] = 勾了 p 且成功 / 勾了 p。
基数含全部 session(started/completed/failed/abandoned),与「发起数」同基数。
"""
started = len(rows)
full_success = 0
point_success = 0
point_total = 0
per_succ = {p: 0 for p in DEFAULT_PLATFORMS}
per_total = {p: 0 for p in DEFAULT_PLATFORMS}
for r in rows:
sel = set(r.platforms) if r.platforms else set(DEFAULT_PLATFORMS)
succ = set(r.platform_success or []) & sel
point_success += len(succ)
point_total += len(sel)
if sel and succ == sel:
full_success += 1
for p in sel:
if p in per_total: # 只统计三档已知平台;未知/非法平台 id 不进 per_platform
per_total[p] += 1
if p in succ:
per_succ[p] += 1
return {
"full_success_count": full_success,
"full_success_rate": round(full_success / started, 4) if started else None,
"point_success_count": point_success,
"point_total_count": point_total,
"point_success_rate": round(point_success / point_total, 4) if point_total else None,
"per_platform": {
p: (round(per_succ[p] / per_total[p], 4) if per_total[p] else None)
for p in DEFAULT_PLATFORMS
},
}
def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad_revenue_yuan: float = 0.0) -> dict:
def _session_to_row(r, phone: str | None = None, nickname: str | None = None) -> dict:
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
return {
"id": r.id,
@@ -105,7 +61,6 @@ def _session_to_row(r, phone: str | None = None, nickname: str | None = None, ad
"started_at": r.started_at,
"claimed_count": r.claimed_count,
"trace_url": r.trace_url,
"ad_revenue_yuan": ad_revenue_yuan,
}
@@ -129,7 +84,6 @@ def coupon_data_report(
date_to: str,
user: str | None = None,
app_env: str | None = None,
statuses: list[str] | None = None,
granularity: str = "day",
limit: int = 500,
offset: int = 0,
@@ -139,8 +93,6 @@ def coupon_data_report(
- user:手机号/昵称模糊搜(匹配不到任何用户 → 空结果)。
- app_env:prod/dev 精确;None=全部。
- statuses:领券状态多选(started/completed/failed/abandoned);None/空=全部。整个视图
(汇总/成功率/趋势/明细)按选中状态算,与 app_env 同级过滤(方案 A)。
- sort:time=发起时刻倒序(默认) / elapsed=全程耗时倒序(None 末尾)。
"""
by_hour = granularity == "hour"
@@ -163,8 +115,6 @@ def coupon_data_report(
)
if app_env is not None:
stmt = stmt.where(CouponSession.app_env == app_env)
if statuses:
stmt = stmt.where(CouponSession.status.in_(statuses))
if user_ids is not None:
stmt = stmt.where(CouponSession.user_id.in_(user_ids))
rows = list(db.execute(stmt).scalars())
@@ -181,7 +131,6 @@ def coupon_data_report(
"p50_ms": _percentile(completed_elapsed, 50),
"p95_ms": _percentile(completed_elapsed, 95),
"p99_ms": _percentile(completed_elapsed, 99),
**_success_rates(rows),
}
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
@@ -248,11 +197,10 @@ 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, ad_revenue_yuan=rev_map.get(r.trace_id, 0.0)))
items.append(_session_to_row(r, phone, nickname))
return {
"summary": summary,
@@ -274,57 +222,4 @@ 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()
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")
_SLOT_TRIED = ("success", "already_claimed", "failed")
def coupon_slot_report(
db: Session, *, date_from: str, date_to: str, app_env: str | None = None
) -> dict:
"""按 coupon_id(具体券)聚合成功率(见 docs/guides/领券成功率指标-设计与埋点.md §13)。
数据源 coupon_claim_record(粒度=设备-天,唯一键 device+coupon+day)。
- 尝试 = status ∈ {success, already_claimed, failed}(skipped 排除);
- 成功 = status ∈ {success, already_claimed};成功率 = 成功/尝试;
- claim_date 区间 + app_env(None=全部)过滤;按 tried 倒序返回。
"""
d_from = _date.fromisoformat(date_from)
d_to = _date.fromisoformat(date_to)
ok = case((CouponClaimRecord.status.in_(_SLOT_OK), 1), else_=0)
stmt = (
select(
CouponClaimRecord.coupon_id,
func.max(CouponClaimRecord.coupon_name).label("coupon_name"),
func.count().label("tried"),
func.sum(ok).label("succeeded"),
)
.where(
CouponClaimRecord.claim_date >= d_from,
CouponClaimRecord.claim_date <= d_to,
CouponClaimRecord.status.in_(_SLOT_TRIED),
)
.group_by(CouponClaimRecord.coupon_id)
)
if app_env is not None:
stmt = stmt.where(CouponClaimRecord.app_env == app_env)
items = []
for coupon_id, coupon_name, tried, succeeded in db.execute(stmt).all():
tried = int(tried or 0)
succeeded = int(succeeded or 0)
items.append({
"coupon_id": coupon_id,
"coupon_name": coupon_name,
"platform": coupon_id_to_platform(coupon_id),
"tried": tried,
"succeeded": succeeded,
"success_rate": round(succeeded / tried, 4) if tried else None,
})
items.sort(key=lambda x: (-x["tried"], x["coupon_id"]))
return {"items": items}
return {"items": [_session_to_row(r) for r in rows], "total": int(total)}
-6
View File
@@ -32,7 +32,6 @@ from app.models.wallet import (
InviteCashTransaction,
WithdrawOrder,
)
from app.repositories import ad_ecpm
# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券)
_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
@@ -299,11 +298,6 @@ 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
-49
View File
@@ -1,49 +0,0 @@
"""admin 埋点健康度:埋点成功率 / 上报成功率 总览 + 趋势 + 下钻(只读)。"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from app.admin.deps import AdminDb, get_current_admin
from app.admin.repositories import analytics_health as repo
from app.admin.schemas.analytics_health import (
HealthBreakdownRow,
HealthMetrics,
HealthTrendPoint,
)
router = APIRouter(
prefix="/admin/api/analytics-health",
tags=["admin-analytics-health"],
dependencies=[Depends(get_current_admin)],
)
@router.get("/overview", response_model=HealthMetrics, summary="两段成功率总览")
def overview(
db: AdminDb,
date_from: Annotated[datetime, Query()],
date_to: Annotated[datetime, Query()],
) -> HealthMetrics:
return HealthMetrics(**repo.overview(db, date_from, date_to))
@router.get("/trend", response_model=list[HealthTrendPoint], summary="按北京天趋势")
def trend(
db: AdminDb,
date_from: Annotated[datetime, Query()],
date_to: Annotated[datetime, Query()],
) -> list[HealthTrendPoint]:
return [HealthTrendPoint(**p) for p in repo.trend(db, date_from, date_to)]
@router.get("/breakdown", response_model=list[HealthBreakdownRow], summary="按维度下钻")
def breakdown(
db: AdminDb,
date_from: Annotated[datetime, Query()],
date_to: Annotated[datetime, Query()],
dim: Annotated[str, Query(pattern="^(event|app_ver|oem)$")] = "event",
) -> list[HealthBreakdownRow]:
return [HealthBreakdownRow(**r) for r in repo.breakdown(db, date_from, date_to, dim)]
+1 -36
View File
@@ -18,8 +18,6 @@ from app.admin.schemas.coupon_data import (
CouponDataOut,
CouponDataRow,
CouponDataSummary,
CouponSlotRow,
CouponSlotsOut,
CouponUserRecordsOut,
)
from app.core.rewards import cn_today
@@ -54,10 +52,6 @@ def get_coupon_data(
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
user: Annotated[str | None, Query(description="用户手机号/昵称模糊搜;不传=全部")] = None,
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
status: Annotated[
list[str] | None,
Query(description="领券状态多选 started/completed/failed/abandoned;不传=全部"),
] = None,
granularity: Annotated[
str, Query(description="day=按天 / hour=按小时(北京);区间>1 天建议 day")
] = "day",
@@ -79,7 +73,7 @@ def get_coupon_data(
env = None if app_env == "all" else app_env
result = coupon_data.coupon_data_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(),
user=user, app_env=env, statuses=status, granularity=granularity,
user=user, app_env=env, granularity=granularity,
limit=limit, offset=offset, sort=sort,
)
return CouponDataOut(
@@ -93,35 +87,6 @@ def get_coupon_data(
)
@router.get(
"/coupons",
response_model=CouponSlotsOut,
summary="按券成功率(coupon_id 粒度;成功/(成功+失败),skipped 排除,设备-天口径)",
)
def get_coupon_slots(
db: AdminDb,
date_from: Annotated[str | None, Query(description="起始日 北京 YYYY-MM-DD,默认今天")] = None,
date_to: Annotated[str | None, Query(description="结束日 北京 YYYY-MM-DD,闭区间,默认=date_from")] = None,
app_env: Annotated[str, Query(description="prod(默认) / dev / all(全部环境)")] = "prod",
) -> CouponSlotsOut:
today = cn_today()
d_from = _parse_day(date_from, field="date_from", default=today)
d_to = _parse_day(date_to, field="date_to", default=d_from)
if d_to < d_from:
raise HTTPException(status_code=422, detail="date_to 不能早于 date_from")
if (d_to - d_from).days + 1 > _MAX_RANGE_DAYS:
raise HTTPException(status_code=422, detail=f"区间最长 {_MAX_RANGE_DAYS}")
env = None if app_env == "all" else app_env
result = coupon_data.coupon_slot_report(
db, date_from=d_from.isoformat(), date_to=d_to.isoformat(), app_env=env
)
return CouponSlotsOut(
date_from=d_from.isoformat(),
date_to=d_to.isoformat(),
items=[CouponSlotRow(**r) for r in result["items"]],
)
@router.get(
"/user-records",
response_model=CouponUserRecordsOut,
-21
View File
@@ -1,21 +0,0 @@
"""埋点健康度 admin 响应 schema。"""
from __future__ import annotations
from pydantic import BaseModel
class HealthMetrics(BaseModel):
attempted: int
drop_capture: int
delivered: int
drop_undelivered: int
track_success_rate: float | None
report_success_rate: float | None
class HealthTrendPoint(HealthMetrics):
day: str
class HealthBreakdownRow(HealthMetrics):
key: str
-1
View File
@@ -41,7 +41,6 @@ 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
-32
View File
@@ -19,16 +19,6 @@ class CouponDataSummary(BaseModel):
p50_ms: int | None = Field(None, description="耗时 50 分位(ms,中位数)")
p95_ms: int | None = Field(None, description="耗时 95 分位(ms)")
p99_ms: int | None = Field(None, description="耗时 99 分位(ms)")
# 平台粒度成功率(见 docs/guides/领券成功率指标-设计与埋点.md):基数含全部 session。
full_success_count: int = Field(0, description="整单成功数(勾选平台全部领到的 session 数)")
full_success_rate: float | None = Field(None, description="整单成功率②=整单成功数/发起数;无数据为空")
point_success_count: int = Field(0, description="成功平台点位数(Σ 每次成功的平台数)")
point_total_count: int = Field(0, description="总平台点位数(Σ 每次勾选平台数;空勾选=全领三档)")
point_success_rate: float | None = Field(None, description="点位成功率③=成功点位/总点位;无数据为空")
per_platform: dict[str, float | None] = Field(
default_factory=dict,
description="分平台点位成功率 {平台id: rate|None};恒含美团/淘宝/京东三档,区间内无人勾选的平台为 None",
)
class CouponDataDaily(BaseModel):
@@ -70,9 +60,6 @@ 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):
@@ -94,22 +81,3 @@ class CouponUserRecordsOut(BaseModel):
items: list[CouponDataRow]
total: int
class CouponSlotRow(BaseModel):
"""按券成功率一行(§13):粒度=设备-天;成功率=成功/(成功+失败),skipped 排除。"""
coupon_id: str
coupon_name: str | None = None
platform: str | None = Field(None, description="美团/淘宝/京东 平台 id;无法识别为空")
tried: int = Field(..., description="尝试数(success+already_claimed+failed 的设备-天数)")
succeeded: int = Field(..., description="成功数(success+already_claimed)")
success_rate: float | None = Field(None, description="成功率=成功/尝试")
class CouponSlotsOut(BaseModel):
"""按券成功率表响应(§13)。"""
date_from: str
date_to: str
items: list[CouponSlotRow]
-1
View File
@@ -286,7 +286,6 @@ 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(
+1 -16
View File
@@ -6,18 +6,13 @@ POST /api/v1/analytics/events — 批量接收新手引导(及后续)埋点,appe
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Request
from app.api.deps import DbSession
from app.repositories import analytics as analytics_repo
from app.repositories import analytics_selfstat as selfstat_repo
from app.schemas.analytics import AnalyticsBatchIn, AnalyticsIngestOut
from app.schemas.analytics_selfstat import SelfStatBatchIn, SelfStatIngestOut
router = APIRouter(prefix="/api/v1/analytics", tags=["analytics"])
logger = logging.getLogger("shagua.analytics")
def _client_ip(request: Request) -> str:
@@ -34,13 +29,3 @@ def ingest_events(
) -> AnalyticsIngestOut:
n = analytics_repo.record_batch(db, batch, client_ip=_client_ip(request))
return AnalyticsIngestOut(received=n)
@router.post("/selfstat", response_model=SelfStatIngestOut, summary="上报自报计数快照")
def ingest_selfstat(batch: SelfStatBatchIn, db: DbSession) -> SelfStatIngestOut:
try:
snap_id = selfstat_repo.record_selfstat(db, batch)
except Exception: # noqa: BLE001 — 计数链路要稳,落库失败不裸奔 500,记日志回明确错误
logger.exception("selfstat ingest failed device=%s epoch=%s", batch.device_id, batch.epoch_id)
raise HTTPException(status_code=503, detail="selfstat ingest failed") from None
return SelfStatIngestOut(snapshot_id=snap_id)
+1 -10
View File
@@ -81,16 +81,7 @@ def _record_claims_blocking(
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
) -> None:
with SessionLocal() as db:
# 取本次 session 环境,给 coupon_claim_record 打 app_env 标(每券成功率表按它过滤;设计 §13)。
app_env = coupon_repo.session_app_env(db, trace_id)
coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env)
# 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率;
# 设计 route B,见 docs/guides/领券成功率指标-设计与埋点.md)。复用同一 SessionLocal、紧接 record_claims,
# 不新增连接;并集幂等(无新平台不写),trace_id 缺失或 session 行未落库则跳过。
if trace_id:
coupon_repo.merge_session_platform_success(
db, trace_id, coupon_repo.succeeded_platforms(results)
)
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
def _mark_completed_blocking(
+2 -14
View File
@@ -43,7 +43,6 @@ from app.schemas.welfare import (
WithdrawRequest,
WithdrawResultOut,
WithdrawStatusOut,
WithdrawTierOut,
)
logger = logging.getLogger("shagua.wallet")
@@ -174,15 +173,8 @@ def unbind_wechat(
return UnbindWechatResultOut(bound=False)
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关/档位")
def withdraw_info(
user: CurrentUser,
db: DbSession,
source: str = Query(
"coin_cash",
description="提现账户:coin_cash(福利页,下发 tiers 档位) / invite_cash(邀请页,tiers 为空走旧逻辑)",
),
) -> WithdrawInfoOut:
@router.get("/withdraw-info", response_model=WithdrawInfoOut, summary="提现额度/绑定状态/免确认开关")
def withdraw_info(user: CurrentUser, db: DbSession) -> WithdrawInfoOut:
u = db.get(User, user.id)
# 顺带同步免确认授权状态(捕获首单确认后已生效的授权 pending→active),让开关展示实时
auth = crud_wallet.sync_transfer_auth(db, user.id)
@@ -193,7 +185,6 @@ def withdraw_info(
wechat_nickname=u.wechat_nickname if u else None,
wechat_avatar_url=u.wechat_avatar_url if u else None,
transfer_auth_enabled=bool(auth and auth.state == "active"),
tiers=[WithdrawTierOut(**t) for t in crud_wallet.withdraw_tier_states(db, user.id, source)],
)
@@ -227,9 +218,6 @@ def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> Withdraw
status_code=status.HTTP_409_CONFLICT,
detail="已有提现申请正在审核或打款中,请处理完成后再申请",
) from e
except crud_wallet.WithdrawTierUnavailableError as e:
# 福利页档位闸(7-9):次数满/已选其他额度。正常客户端已按 tiers 预拦,此处兜底防绕过。
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="今日额度已达上限") from e
except crud_wallet.InsufficientCashError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="现金余额不足") from e
-25
View File
@@ -6,7 +6,6 @@
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from typing import NamedTuple
# 业务时区:签到的"今天"按北京时间算,不能用 UTC。
# 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。
@@ -53,30 +52,6 @@ WITHDRAW_MIN_CENTS: int = 10
WITHDRAW_MAX_CENTS: int = 5_000_000 # 5 万元
# ===== 提现档位(福利页 coin_cash;7-9 对齐原型 withdrawal.html)=====
# 后端是档位唯一真相源:withdraw-info 按此下发,create_withdraw 按此校验(防绕过客户端刷)。
# 规则(2026-07-09 拍板):
# - 新人档(is_newbie):账号历史一次性,"发起就算用过"(任意状态含被拒),用过即不再下发;
# 0.1 与 0.3 各自独立同天可各提一次,且不参与常规档"每日选一个额度"互斥。
# - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),三档每天只能选一个。
# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。
class WithdrawTier(NamedTuple):
amount_cents: int
label: str # 客户端档位方块展示文案
badge: str | None # 角标文案;None=无角标
daily_limit: int # 每日次数上限(新人档的"历史一次性"另由 is_newbie 判定)
is_newbie: bool
WITHDRAW_TIERS_COIN_CASH: tuple[WithdrawTier, ...] = (
WithdrawTier(10, "0.1", "新人福利", 1, True),
WithdrawTier(30, "0.3", "新人福利", 1, True),
WithdrawTier(50, "0.5", None, 3, False),
WithdrawTier(1000, "10", None, 1, False),
WithdrawTier(2000, "20", None, 1, False),
)
# ===== 一次性任务(领一次,user_task 去重)=====
TASK_ENABLE_NOTIFICATION = "enable_notification"
-4
View File
@@ -7,10 +7,6 @@ from app.models.ad_watch_log import AdWatchLog # noqa: F401
from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
from app.models.admin_role import AdminRole # noqa: F401
from app.models.analytics_event import AnalyticsEvent # noqa: F401
from app.models.analytics_selfstat import ( # noqa: F401
AnalyticsSelfStat,
AnalyticsSelfStatEvent,
)
from app.models.app_config import AppConfig # noqa: F401
from app.models.comparison import ComparisonRecord # noqa: F401
from app.models.cps_activity import CpsActivity # noqa: F401
-3
View File
@@ -32,9 +32,6 @@ 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)
-59
View File
@@ -1,59 +0,0 @@
"""埋点/上报成功率自报计数快照表(append-only)。
客户端周期上报「自 epoch 起算的累计计数」;服务端只存原始快照,查询时在 Python 侧差分聚合
(见 app/admin/repositories/analytics_health.py)。与既有 analytics_event 表完全独立。
- analytics_selfstat :一快照一行(快照头 + 设备维度 + 设备级诊断量)
- analytics_selfstat_event :一 event 一行(四类累计计数),外键指向快照头
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class AnalyticsSelfStat(Base):
__tablename__ = "analytics_selfstat"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
epoch_id: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
# 设备维度(每设备固定,下钻用)
app_ver: Mapped[str | None] = mapped_column(String(32), nullable=True)
oem: Mapped[str | None] = mapped_column(String(32), nullable=True)
os: Mapped[str | None] = mapped_column(String(32), nullable=True)
# 设备级诊断量(累计;queue_depth 是瞬时 gauge)
batches_attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
batches_ok: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
batches_fail: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
retries: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
queue_depth: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
sent_at: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # 端上报时刻 epoch ms
# 服务端接收时间(权威,用于时间分桶与分区排序)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
def __repr__(self) -> str: # pragma: no cover
return f"<AnalyticsSelfStat id={self.id} device={self.device_id} epoch={self.epoch_id}>"
class AnalyticsSelfStatEvent(Base):
__tablename__ = "analytics_selfstat_event"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
snapshot_id: Mapped[int] = mapped_column(
ForeignKey("analytics_selfstat.id"), index=True, nullable=False
)
event: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
attempted: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
drop_capture: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
delivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
drop_undelivered: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
def __repr__(self) -> str: # pragma: no cover
return f"<AnalyticsSelfStatEvent snap={self.snapshot_id} event={self.event}>"
-7
View File
@@ -66,9 +66,6 @@ class CouponClaimRecord(Base):
# success / already_claimed / failed / skipped(原样取 pricebot coupon 结果)
status: Mapped[str] = mapped_column(String(24), nullable=False)
# 领券所属 session 的环境 prod/dev(/step 按 trace_id 查 coupon_session.app_env 打标)。
# 旧行 NULL(不回填)。admin「按券成功率」表据此过滤环境。见设计 §13。
app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
vendor: Mapped[str | None] = mapped_column(String(48), nullable=True)
coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# 这张领到几张(pricebot display_count;给不出时为 None)
@@ -243,10 +240,6 @@ class CouponSession(Base):
platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
# 领到总张数(收尾帧带)。
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 本次 session 至少领到一张(status∈{success,already_claimed})的平台 id 列表,如 ["meituan-waimai","jd-waimai"]。
# admin「领券数据」据此算整单成功率(②)/点位成功率(③);服务端 /step 逐帧按 trace_id 并集写入
# (见 coupon_state.merge_session_platform_success)。旧行=NULL → 视作空集。
platform_success: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# pricebot done 帧回传的公网调试链接(price.shaguabijia.com/traces/{dir});含落盘时分秒、拼不出,只能存
# (同 ComparisonRecord.trace_url)。admin「领券数据」明细据此渲染可点 trace 链接;未到 done(failed/abandoned)为空。
trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
-27
View File
@@ -10,7 +10,6 @@ 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
@@ -25,7 +24,6 @@ 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:
@@ -45,7 +43,6 @@ 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,
@@ -108,27 +105,3 @@ 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()}
+2 -11
View File
@@ -176,19 +176,10 @@ def grant_feed_reward(
)
return _commit_record(db, rec, client_event_id)
# 按点位场景拆流水文案(2026-07):比价等候期看的广告 vs 领券时看的广告,在收益明细里分开显示。
# feed_scene=comparison→比价奖励 / coupon→领券奖励;其它(welfare/空/旧端不带)维持通用「信息流广告奖励」。
# 客户端按此 biz_type 直显固定文案(见 CoinHistoryViewModel.coinTitle),故 remark 只作后台留痕/兜底。
if feed_scene == "comparison":
reward_biz, reward_remark = "feed_ad_reward_comparison", "比价奖励"
elif feed_scene == "coupon":
reward_biz, reward_remark = "feed_ad_reward_coupon", "领券奖励"
else:
reward_biz, reward_remark = "feed_ad_reward", "信息流广告奖励"
crud_wallet.grant_coins(
db, user_id, coin,
biz_type=reward_biz, ref_id=client_event_id,
remark=reward_remark,
biz_type="feed_ad_reward", ref_id=client_event_id,
remark="信息流广告奖励",
)
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
-38
View File
@@ -1,38 +0,0 @@
"""自报计数快照落库。一次事务:插 1 条快照头 + N 条 event 行,返回快照 id。"""
from __future__ import annotations
from sqlalchemy.orm import Session
from app.models.analytics_selfstat import AnalyticsSelfStat, AnalyticsSelfStatEvent
from app.schemas.analytics_selfstat import SelfStatBatchIn
def record_selfstat(db: Session, batch: SelfStatBatchIn) -> int:
snap = AnalyticsSelfStat(
device_id=batch.device_id,
epoch_id=batch.epoch_id,
app_ver=batch.app_ver,
oem=batch.oem,
os=batch.os,
batches_attempted=batch.batches_attempted,
batches_ok=batch.batches_ok,
batches_fail=batch.batches_fail,
retries=batch.retries,
queue_depth=batch.queue_depth,
sent_at=batch.sent_at,
)
db.add(snap)
db.flush() # 拿到 snap.id
db.add_all([
AnalyticsSelfStatEvent(
snapshot_id=snap.id,
event=e.event,
attempted=e.attempted,
drop_capture=e.drop_capture,
delivered=e.delivered,
drop_undelivered=e.drop_undelivered,
)
for e in batch.events
])
db.commit()
return snap.id
+1 -83
View File
@@ -147,22 +147,12 @@ def reset_today_completion(db: Session, device_id: str) -> int:
# ===== 领券记录(coupon_claim_record)=====
def session_app_env(db: Session, trace_id: str | None) -> str | None:
"""按 trace_id 取 coupon_session.app_env(每券成功率表打环境标用);无 trace_id / 查不到 → None。"""
if not trace_id:
return None
return db.execute(
select(CouponSession.app_env).where(CouponSession.trace_id == trace_id)
).scalar_one_or_none()
def record_claims(
db: Session,
device_id: str,
user_id: int | None,
trace_id: str | None,
results: list[dict],
app_env: str | None = None,
) -> int:
"""一批券领取结果幂等写入,返回写入(新增 + 更新)条数。
@@ -196,15 +186,12 @@ def record_claims(
row.user_id = user_id
if count is not None:
row.claimed_count = count
if app_env is not None:
row.app_env = app_env
row.extra = r
else:
db.add(CouponClaimRecord(
device_id=device_id, user_id=user_id,
coupon_id=coupon_id, claim_date=today,
status=status, app_env=app_env,
vendor=r.get("vendor"), coupon_name=r.get("name"),
status=status, vendor=r.get("vendor"), coupon_name=r.get("name"),
claimed_count=count, trace_id=trace_id, reason=r.get("reason"),
extra=r,
))
@@ -248,47 +235,6 @@ def sum_claimed_count(db: Session, user_id: int) -> int:
return int(total or 0)
# ===== 领券平台推导(coupon_id → 平台;成功平台集)=====
# 成功语义:success + already_claimed 算成功(pricebot 代码 emit already_claimed,协议 enum 漏了);
# failed / skipped 不算。与 sum_claimed_count 同口径。
_SUCCESS_STATUSES = frozenset({"success", "already_claimed"})
# 三档平台 id 及固定序(美团→淘宝→京东),与客户端 DEFAULT_PLATFORM_ORDER 对齐。
DEFAULT_PLATFORMS: tuple[str, ...] = ("meituan-waimai", "taobao-shanguang", "jd-waimai")
def coupon_id_to_platform(coupon_id: str | None) -> str | None:
"""coupon_id 前缀 → 平台 id;无法识别 / 空 → None。
与客户端 `CouponForegroundService.couponIdToPlatform` 同词表:
mt_→美团外卖 / tb_·ele_·elm_→淘宝闪购 / jd_→京东外卖。
"""
if not coupon_id:
return None
if coupon_id.startswith("mt_"):
return "meituan-waimai"
if coupon_id.startswith(("tb_", "ele_", "elm_")):
return "taobao-shanguang"
if coupon_id.startswith("jd_"):
return "jd-waimai"
return None
def succeeded_platforms(results: list[dict]) -> list[str]:
"""一批券结果 → 至少领到一张的平台集(按 DEFAULT_PLATFORMS 去重保序)。
只取 status∈{success, already_claimed} 的券;失败/跳过、无法识别平台的券跳过。
"""
ok: set[str] = set()
for r in results:
if r.get("status") in _SUCCESS_STATUSES:
platform = coupon_id_to_platform(r.get("coupon_id"))
if platform is not None:
ok.add(platform)
return [p for p in DEFAULT_PLATFORMS if p in ok]
# ===== 领券任务流水(coupon_session,admin「领券数据」看板数据源)=====
def upsert_coupon_session(
@@ -375,31 +321,3 @@ def upsert_coupon_session(
except IntegrityError:
# 并发下另一请求刚插了同 trace_id → 唯一约束撞,回滚忽略(本就幂等)。
db.rollback()
def merge_session_platform_success(
db: Session, trace_id: str, platforms: list[str]
) -> None:
"""把本帧「成功平台」并入 coupon_session.platform_success(按 trace_id,并集幂等,按 DEFAULT_PLATFORMS 保序)。
- 领券 /step 每逢带券结果的帧调一次(平台成败布尔,跨帧取并集天然幂等,不重复计)。
- 读不到该 trace_id 的行 → **静默跳过**(不建兜底行;设计 §5:started 帧几乎必先落库)。
- 并集无变化(该平台已记过)→ 不写库,省一次 UPDATE。
- fire-and-forget:调用方已吞异常;并发唯一冲突回滚忽略。
"""
if not platforms:
return
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == trace_id)
).scalar_one_or_none()
if row is None:
return
merged = set(row.platform_success or []) | set(platforms)
new_list = [p for p in DEFAULT_PLATFORMS if p in merged]
if new_list == (row.platform_success or []):
return # 幂等:无新平台,不写
row.platform_success = new_list
try:
db.commit()
except IntegrityError:
db.rollback()
+1 -101
View File
@@ -11,7 +11,7 @@ import unicodedata
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select, update
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -67,10 +67,6 @@ class WithdrawTooFrequentError(Exception):
"""提现申请过于频繁,或已有未完成提现单。"""
class WithdrawTierUnavailableError(Exception):
"""该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。"""
class WithdrawTransferError(Exception):
"""调用微信转账失败(已退回余额)。"""
@@ -609,89 +605,6 @@ def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> N
db.commit()
def _beijing_today_start_utc() -> datetime:
"""北京时今日 0 点(转 UTC)。WithdrawOrder.created_at 是 func.now()(UTC)存储,
比较时统一转 UTC,与 admin 看板 today_start 同口径(admin/repositories/queries.py)。"""
return (
datetime.now(rewards.CN_TZ)
.replace(hour=0, minute=0, second=0, microsecond=0)
.astimezone(timezone.utc)
)
def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -> list[dict]:
"""福利页(coin_cash)提现档位的可提现状态。withdraw-info 下发与 create_withdraw 校验共用此口径。
规则(2026-07-09 拍板,7-9提现ui对齐):
- 新人档(0.1/0.3):账号历史一次性——只要发起过(**任意状态**,含被拒/失败,"发起就算")
即视为已用,直接**从返回列表消失**;两档各自独立互不影响,不参与"每日选一个额度"互斥。
- 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。
- invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。
余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)。
"""
if source != "coin_cash":
return []
tiers = rewards.WITHDRAW_TIERS_COIN_CASH
amounts = [t.amount_cents for t in tiers]
newbie_amounts = [t.amount_cents for t in tiers if t.is_newbie]
# 新人档历史是否用过:任意时间、任意状态("发起就算")
used_newbie: set[int] = set(
db.execute(
select(WithdrawOrder.amount_cents)
.distinct()
.where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.source == "coin_cash",
WithdrawOrder.amount_cents.in_(newbie_amounts),
)
).scalars()
) if newbie_amounts else set()
# 今日(北京日)每档已发起次数(任意状态)
today_counts: dict[int, int] = {
int(amount): int(cnt)
for amount, cnt in db.execute(
select(WithdrawOrder.amount_cents, func.count(WithdrawOrder.id))
.where(
WithdrawOrder.user_id == user_id,
WithdrawOrder.source == "coin_cash",
WithdrawOrder.amount_cents.in_(amounts),
WithdrawOrder.created_at >= _beijing_today_start_utc(),
)
.group_by(WithdrawOrder.amount_cents)
)
}
# "每日选一个额度":今天发起过的常规档(新人档不算)
selected_regular = next(
(t.amount_cents for t in tiers if not t.is_newbie and today_counts.get(t.amount_cents, 0) > 0),
None,
)
out: list[dict] = []
for t in tiers:
if t.is_newbie:
if t.amount_cents in used_newbie:
continue # 用过即消失,不再下发
out.append({
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
"is_newbie": True, "available": True, "disabled_reason": None,
"remaining_today": 1,
})
continue
used = today_counts.get(t.amount_cents, 0)
if selected_regular is not None and selected_regular != t.amount_cents:
available, reason, remaining = False, "other_tier_selected", 0
elif used >= t.daily_limit:
available, reason, remaining = False, "quota_exhausted", 0
else:
available, reason, remaining = True, None, t.daily_limit - used
out.append({
"amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
"is_newbie": False, "available": available, "disabled_reason": reason,
"remaining_today": remaining,
})
return out
def create_withdraw(
db: Session,
user_id: int,
@@ -747,19 +660,6 @@ def create_withdraw(
if active_order_id is not None:
raise WithdrawTooFrequentError
# 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
# 客户端刷)。放在幂等返回/在途互斥之后:同号重试仍原样返回旧单,不被档位闸误杀。
# allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。
if source == "coin_cash" and not allow_sub_min:
tier_state = next(
(t for t in withdraw_tier_states(db, user_id, source) if t["amount_cents"] == amount_cents),
None,
)
if tier_state is None: # 非预设档位金额,或新人档已用过(已从列表消失)
raise InvalidWithdrawAmountError
if not tier_state["available"]:
raise WithdrawTierUnavailableError
# 账户须存在(原子扣款的 UPDATE 不会建账户)
get_or_create_account(db, user_id, commit=True)
-6
View File
@@ -63,12 +63,6 @@ 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(测试应用)"
)
-34
View File
@@ -1,34 +0,0 @@
"""自报计数上报 schema。字段名对齐客户端 payload(snake_case),累计值语义。"""
from __future__ import annotations
from pydantic import BaseModel, Field
class SelfStatEventIn(BaseModel):
event: str = Field(max_length=64)
attempted: int = 0
drop_capture: int = 0
delivered: int = 0
drop_undelivered: int = 0
class SelfStatBatchIn(BaseModel):
device_id: str = Field(max_length=64)
epoch_id: str = Field(max_length=64)
sent_at: int | None = None
app_ver: str | None = Field(default=None, max_length=32)
oem: str | None = Field(default=None, max_length=32)
os: str | None = Field(default=None, max_length=32)
batches_attempted: int = 0
batches_ok: int = 0
batches_fail: int = 0
retries: int = 0
queue_depth: int = 0
# 允许空列表:某次快照只上报设备级计数(batches_*/retries/queue_depth)、无 event 细分时也合法
# (与 AnalyticsBatchIn 的 min_length=1 有意不同——那是行为事件、必须至少一条)。
events: list[SelfStatEventIn] = Field(default_factory=list, max_length=200)
class SelfStatIngestOut(BaseModel):
ok: bool = True
snapshot_id: int
-19
View File
@@ -75,21 +75,6 @@ class ExchangeResultOut(BaseModel):
# ===== 提现(现金 → 微信零钱) =====
class WithdrawTierOut(BaseModel):
"""提现档位(福利页 coin_cash;7-9 对齐原型)。served by rewards.WITHDRAW_TIERS_COIN_CASH。"""
amount_cents: int = Field(..., description="档位金额(分)")
label: str = Field(..., description="档位方块展示文案,如 0.1 / 10")
badge: str | None = Field(None, description="角标文案(如 新人福利);无则空")
is_newbie: bool = Field(False, description="新人档:历史一次性,用过后不再下发;免广告直提")
available: bool = Field(True, description="当前是否可提(次数/选一额度口径;余额由客户端自判)")
disabled_reason: str | None = Field(
None,
description="不可提原因:quota_exhausted(今日次数满) / other_tier_selected(今日已选其他额度)",
)
remaining_today: int = Field(0, description="今日剩余可提次数")
class WithdrawInfoOut(BaseModel):
min_cents: int = Field(..., description="单次最低提现(分)")
max_cents: int = Field(..., description="单次最高提现(分)")
@@ -99,10 +84,6 @@ class WithdrawInfoOut(BaseModel):
transfer_auth_enabled: bool = Field(
False, description="是否已开启免确认到账(开启后提现免跳微信确认,直接到账)"
)
tiers: list[WithdrawTierOut] = Field(
default_factory=list,
description="提现档位(source=coin_cash 下发;invite_cash 为空,客户端走旧逻辑)",
)
# ===== 免确认收款授权(用户授权免确认模式)=====
+4 -48
View File
@@ -1,4 +1,4 @@
# coupon_state — 领券状态表(今日状态三张 + 领券流水 coupon_session)
# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录)
> 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
> 同模型文件里还有第四张表 [`coupon_session`](./coupon_session.md)(一次领券任务一行的全程流水,admin「领券数据」看板数据源,#99)——维度与本文三张「设备×日」状态表不同,单独成文。
@@ -8,9 +8,8 @@
- **`coupon_prompt_engagement`** — 弹窗频控源。按 `(device, App 包名, 自然日)` 记「今天**这个 App** 是否对领券引导窗表达过**意向**」(弹出即记 `shown` / 点「一键领取」=`claim_started` / 点拒绝关闭=`dismissed` 都算)。切到外卖 App 时据此决定弹不弹:今天**该 App** engage 过就不再弹该 App。频控维度自 2026-06-14 起含 `package`,美团/淘宝/京东各自独立、互不压制。
- **`coupon_daily_completion`** — 首页置灰源。按 `(device, 自然日)` 记「今天是否已**跑完整轮**领券(到 done 帧)」。首页「去领取」卡据此置灰:今天跑完了就不能再领。
- **`coupon_claim_record`** — 资产沉淀层。按 `(device, 券, 自然日)` 记每张券的领取结果(success/already_claimed/failed/skipped),**纯沉淀**(资产/画像/排查/CPS 归因),当前**不参与**「要不要领 / 弹不弹」的判断。
- **`coupon_session`** — admin「领券数据」看板数据源(**独立流水表,不是「今日状态」表**)。按 `trace_id` 一次领券一行,走 `POST /api/v1/coupon/session` 两段上报(发起/收尾),记全程耗时 + 各平台耗时 + `platform_success`(成功平台,算整单②/点位③成功率)。详见下方专节。
前三张「今日状态」表的共同口径:
三表共同口径:
- **判断维度是 `device_id`,不是 `user_id`**:券发到的是设备上登录的那个外卖账号,device 比 user 更贴近「哪个登录环境」,且 `device_id` 全链路现成、不依赖领券鉴权(领券 MVP 阶段 `/coupon/step` 不鉴权)。客户端 `getOrCreateDeviceId` 生成存 SP,**卸载重装会变 → 当新设备重新弹一次**(产品预期)。
- **日期 = `Asia/Shanghai` 自然日**`claim_date` / `engage_date` / `complete_date``repositories/coupon_state.today_cn()`)。每日可领的券(签到/天天红包)靠这天然每天一条。
- **`user_id` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。
@@ -92,7 +91,7 @@
### 用在哪 / 增删改查
- **C / U(幂等 upsert**`record_claims`,由 `POST /api/v1/coupon/step` 写入。一帧的券结果来自 pricebot 的 `last_coupon_result`(最后一张)+ `action.params.coupon_results`(全量)——**会重复带同一张券**,端点 `_extract_coupon_results` 先**按 `coupon_id` 去重**(全量覆盖单张),仓库再靠唯一键幂等:已有则更新 `status`/`reason`/`claimed_count`/`extra`(以最后一次为准),否则插入。
- **U / D**:无业务删除。
- **R**`GET /admin/api/coupon-data/coupons``coupon_slot_report`)—— admin「按券成功率」表,按 `coupon_id` 聚合 成功/(成功+失败)`skipped` 排除,设备-天口径,按 `app_env` 过滤)。见设计 §13
- **R****当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)
### 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
@@ -103,7 +102,6 @@
| `coupon_id` | String(64) | NOT NULL | 券标识(取自 pricebot 结果) |
| `claim_date` | **Date** | NOT NULL | **北京时间**自然日(`today_cn()`);每日可领的券靠它天然每天一条 |
| `status` | String(24) | NOT NULL | `success` / `already_claimed` / `failed` / `skipped`(原样取 pricebot coupon 结果) |
| `app_env` | String(16) | index, 可空 | 领券所属 session 环境 `prod`/`dev``/step``trace_id``coupon_session.app_env` 打标);旧行 NULL(不回填)。admin「按券成功率」表按它过滤。见设计 §13 |
| `vendor` | String(48) | 可空 | 券提供方 |
| `coupon_name` | String(128) | 可空 | 取 pricebot `name` |
| `claimed_count` | Integer | 可空 | 这张领到几张(pricebot `display_count`,给不出时 None;兼容 `claimed_count` |
@@ -123,49 +121,7 @@
---
## coupon_session — 领券任务流水(一次领券一行,admin「领券数据」看板数据源)
`trace_id` 唯一,一次领券一行。与上面三张「今日状态」表不同:本表走 `POST /api/v1/coupon/session`(客户端**两段上报**:发起 `started` 建行、收尾 `completed`/`failed`/`abandoned``trace_id` 更新同一行),记从发起到收尾的全程耗时 + 各平台耗时 + 机型/ROM。发起即落库 → admin 可算「发起数」与中途流失(started 无终态 = 未完成)。
### 用在哪 / 增删改查
- **C / U(幂等 upsert**`upsert_coupon_session`,由 `POST /api/v1/coupon/session` 两段上报。**状态只前进**(started 帧重复到不覆盖已有终态);终态补 `finished_at`
- **U(并集写)**`merge_session_platform_success`,由 `POST /api/v1/coupon/step` 每逢**带券结果的帧**调用——把本帧「成功平台」(`status∈{success,already_claimed}` 的券 → `coupon_id` 前缀映射平台)**并入** `platform_success`(并集幂等,无新平台不写;读不到该 trace 行则跳过)。复用 `record_claims` 的同一 `SessionLocal`,不新增连接。
- **R**admin `GET /admin/api/coupon-data``coupon_data_report`)—— 发起/完成数、耗时分位、**整单成功率②/点位成功率③**、按天/小时趋势、逐条明细;`GET /admin/api/coupon-data/user-records` 某用户全部领券。
### 字段
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
| `id` | Integer | PK, autoincrement | |
| `trace_id` | String(64) | NOT NULL, UNIQUE | 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键 |
| `device_id` | String(64) | NOT NULL | |
| `user_id` | Integer | index, 可空 | 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空 |
| `status` | String(16) | NOT NULL | `started` / `completed` / `failed` / `abandoned`;started 无终态 = 中途流失 |
| `app_env` | String(16) | index, 可空 | `prod` / `dev`;admin 报表默认只看 prod(防测试串台) |
| `platforms` | JSON | 可空 | 发起勾选平台 `["meituan-waimai",…]`(空 = 全领三档);**③点位成功率的分母来源** |
| `origin_package` | String(64) | 可空 | 发起来源外卖 App 包名;null = App 内(傻瓜比价首页)发起,非空 = 外卖侧弹券 |
| `device_model` | String(128) | 可空 | Build.MANUFACTURER + MODEL |
| `rom` | String(64) | 可空 | OemDetector,如 "ColorOS 14" |
| `started_at` | DateTime(tz) | NOT NULL | 发起时刻(客户端墙钟);明细「时间」列、趋势 X 轴 |
| `started_date` | **Date** | NOT NULL | 发起的**北京**自然日;admin 按天聚合/筛选(索引) |
| `finished_at` | DateTime(tz) | 可空 | 收尾时刻(服务端 now);未收尾(流失)为空 |
| `elapsed_ms` | Integer | 可空 | 全程耗时(ms,客户端点发起→收尾);均值/分位只统计 completed |
| `platform_elapsed` | JSON | 可空 | 各平台领券耗时 `{"meituan-waimai":3200,…}`ms |
| `claimed_count` | Integer | 可空 | 领到总张数(收尾帧带) |
| `platform_success` | JSONPG JSONB | 可空 | **本次至少领到一张(`status∈{success,already_claimed}`)的平台 id 列表**,如 `["meituan-waimai","jd-waimai"]``/step` 逐帧按 `trace_id` **并集**写入(`merge_session_platform_success`;旧行 NULL 视作空集。admin 据此算整单成功率②(`platforms``platform_success`)/点位成功率③(Σ交集/Σ勾选)。设计:[领券成功率指标](../guides/领券成功率指标-设计与埋点.md) |
| `trace_url` | String(512) | 可空 | pricebot done 帧回传的公网 trace 链接;未到 donefailed/abandoned)为空 |
| `created_at` | DateTime(tz) | server_default now() | |
| `updated_at` | DateTime(tz) | server_default now(), onupdate now() | |
### 索引与约束
- PK `id`index `user_id``app_env`UNIQUE(`trace_id`) = `uq_coupon_session_trace`Index(`started_date`, `app_env`) = `ix_coupon_session_date_env`admin 主聚合/筛选)。
### 注意
- `platform_success` 是**布尔性质**的平台集,跨帧**并集**天然幂等 → `/step` 每帧并入不重复计;失败/中途退出的 session 也能拿到崩溃前已成的平台。写放大 ≈ 领券券数(仅带券结果的帧写)。
- 成功率**基数 = 区间全部 session**(含 abandoned/failed,与「发起数」同基数(设计 §3)。`coupon_id → 平台` 用前缀(`mt_`/`tb_`·`ele_`·`elm_`/`jd_`,与客户端 `couponIdToPlatform` 同词表。
---
## 今日状态三表共性小结
## 三表共性小结
- 数据流向:客户端 → `POST /api/v1/coupon/step`(透传给 pricebot)→ 结果回写这三张表(best-effort,写库失败不影响领券)。
- 唯一键都含 `device_id` + 某个北京自然日列(engagement 还含 `package`,按 App 频控);`user_id` 永远是可空旁路(资产留痕,不进唯一键、不阻塞判断)。
- 无硬外键:`user_id` 软指 `user.id``trace_id` 软指 pricebot work_logs(详见 [OVERVIEW → 表间关系 & Join Key](./OVERVIEW.md))。
@@ -1,294 +0,0 @@
# 领券「整单成功率」与「点位成功率」指标 — 设计与埋点
- 日期:2026-07-07
- 状态:待评审
- 涉及仓库:`shaguabijia-app-server`**纯服务端**;客户端零改动)
- 数据源表:`coupon_session`admin「领券数据」看板)
## 1. 背景与目标
admin「领券数据」看板(数据源 `coupon_session`,见 `app/admin/repositories/coupon_data.py`)当前能算:**领券发起数、完成数、全程耗时均值/分位**。产品还想要两个成功率指标,现有埋点算不出来:
- **② 整单成功率** = 一次发起里勾选的平台**全部**领到券的次数 / 领券发起数
- **③ 点位成功率** = 平台维度的领取成功率(每个平台「点位」成没成功)
> 口径决定(2026-07-07):「点位」= **平台粒度**(美团 / 淘宝闪购 / 京东),不是「每张券」。
耗时中位数、发起数已由 `coupon_session.elapsed_ms` / `status=started` 计数满足,本设计只补 ②③。
## 2. 关键结论:per-slot 信号已在库,缺的是「按 session 可靠归因 + 看板可过滤」
服务端 `/api/v1/coupon/step``app/api/v1/coupon.py`)每帧都调 `record_claims`,把**每张券**的结果(`status ∈ success / already_claimed / failed / skipped`)写进 `coupon_claim_record`,还带 `trace_id`。原始成败信号**已经落库**。
但该表**不能**直接支撑本指标:
1. 唯一键是 `(device_id, coupon_id, claim_date)`**不含 trace_id**;且 `record_claims` 冲突更新时**不更新 trace_id**`app/repositories/coupon_state.py``record_claims`,仅 INSERT 时写 trace_id)。→ 同一张券当天跨多次 session 会折叠成一行、只归属**最早**那次 → **按 session 归因不可靠**(直接砸 ②「整单全成功」)。
2. `coupon_claim_record``app_env` / `origin_package` → 无法像看板那样只看 prod、也无法拆 Path AApp 内发起)/ Path B(外卖侧弹券)。
因此采用 **route B**:在 `/step` 里服务端推导「本次 session 哪些平台成功」,直接写到 `coupon_session` 行——该表按 `trace_id` 唯一、已带 `app_env` / `origin_package` / `platforms`,指标干净可过滤、可拆路径。
## 3. 指标口径(平台粒度,已定)
记一次 session 为 `s`
- `sel(s)` = **勾选平台集** = `coupon_session.platforms`;为空表示「全领」→ 取默认 `{meituan-waimai, taobao-shanguang, jd-waimai}``|sel(s)|` = 该次「点位数」。
- `succ(s)` = **成功平台集** = 本次 session 里**至少领到一张**`status ∈ {success, already_claimed}`)的平台集合。
指标:
- **③ 点位成功率** = `Σ_s |succ(s) ∩ sel(s)|` / `Σ_s |sel(s)|`
(分母即「发起数 × 各自点位数」;全部全领时等于 发起数 × 3)
- **② 整单成功率** = `#{ s : sel(s) ⊆ succ(s) 且 sel(s) ≠ ∅ }` / `发起数`
已定边界:
1. **③ 分母用「勾选平台」`sel(s)`**。勾了淘宝但淘宝没领到 = 该点位未成功(不特判「平台没券」)。若日后要「真没券的平台不计入分母」,再引入 `platform_attempted`(见 §8)。
2. **②③ 基数 = 区间内全部 session**(含 `started` / `failed` / `abandoned`),与看板「发起数」同基数。**失败 / 中途退出的 session 的 `succ(s)` 取它崩溃前真领到的平台**(不一律算 0)。
3. **成功语义**`status ∈ {success, already_claimed}` = 成功;`failed` / `skipped` = 未成功。与 `sum_claimed_count``app/repositories/coupon_state.py`)一致。
> pricebot 协议文档把 `status` enum 写作 `success|failed|skipped`,但**代码实际还会 emit `already_claimed`**pricebot `app/services/coupon_provider.py` 等多处)——以代码为准,含 already_claimed 是对的。`skipped` 目前 MVP 阶段基本不出现。
## 4. 数据模型改动
`coupon_session``app/models/coupon_state.py::CouponSession`)新增一列:
| 列 | 类型 | 说明 |
|----|------|------|
| `platform_success` | `_JSON`PG→JSONB / SQLite→JSON),nullable | 本次 session **至少领到一张**的平台 id 列表,如 `["meituan-waimai","jd-waimai"]`。旧行 = `NULL` → 视作空集。 |
- alembic 新迁移:`add_column coupon_session.platform_success`nullable、无 server_default。
- **分母 `sel(s)` 复用已有 `platforms` 列,不新增字段。** 净新增仅此一列。
- 同步更新表字典 `docs/database/coupon_state.md`
## 5. 服务端推导逻辑(`/step`
`app/api/v1/coupon.py::coupon_step` 内(已有 `results = _extract_coupon_results(resp_json)`)新增:
1. 对每条 result 求平台:**按 `coupon_id` 前缀映射**(与客户端 `CouponForegroundService.couponIdToPlatform` 对齐,且与 `platforms` / `platform_elapsed` 用同一套平台 id 词表):
- `mt_``meituan-waimai`
- `tb_` / `ele_` / `elm_``taobao-shanguang`
- `jd_``jd-waimai`
- 其余 → 跳过(无法识别)
> 不用券的 `vendor` 字段做映射:vendor 词表未必等于这三档平台 id,而 `sel(s)` 用的就是这三档,`succ(s)` 必须同词表。
2. 收集 `status ∈ {success, already_claimed}` 的平台集合 `ok_platforms`
3.`device_id``ok_platforms` 非空:调用新 repo 函数把 `ok_platforms` **并入** `coupon_session.platform_success`(按 `trace_id`)。
新增 `app/repositories/coupon_state.py::merge_session_platform_success(db, trace_id, ok_platforms)`
- 读现有行 → `platform_success = 现有 ok_platforms`(去重、保序)→ 写回、commit`IntegrityError` 回滚忽略(同现有 upsert 兜底)。
- **并集幂等**:跨帧多次并入同一平台不会重复;done 帧的全量 `coupon_results` 保证完整;失败 / 中途退出的 session 靠中间帧 `last_coupon_result` 已并入的平台拿到「部分成功」。
- **fire-and-forget**:包 `run_in_threadpool` + 整段 try/except 只 log,绝不连累 `/step` 返回(与现有 `record_claims` / `mark_completed` 同规格)。
**建行 / 时序(已定:行不存在则跳过本次并入)**
- 客户端 `/session started``start()`(任务发起那刻)就发,而首个带券的 `/step` 要等领券循环跑起来、晚几秒;到 `/step``ok_platforms` 时,`coupon_session` 行几乎必然已存在。故 `merge_session_platform_success` **读不到行就跳过**,不建兜底行——实现最简,也不引入 `started_at` 不精确的脏行。
- 残留丢数窗口:仅当「`started` 上报丢失」**且**「`/step` done 先于 `/session` terminal 落库」两者同时成立,该 session 的 `platform_success` 才会缺(terminal 帧会建行但那之后没有 `/step` 再并入)。两条件叠加概率极低,且指标是聚合口径、可容忍个别缺失。
- 若上线后观测到该缺失不可忽略,再降级为「读不到行则 upsert 建最小兜底行(`started_at=now()`)」——届时改 `merge_session_platform_success` 一处即可,不影响其余设计。
**写放大(重要,非每 step**`merge_session_platform_success` **只在「本帧带券结果」时触发**——即 pricebot 在**单券完成帧**给 `last_coupon_result`、**最终 done 帧**给全量 `coupon_results` 的那些帧;领单张券途中的导航/点击帧(占 step 大头)`_extract_coupon_results` 返回空 → **不写**。所以写频次 ≈ **本次领的券数**(通常个位数),且落在**服务端今天已有的** `record_claims` 写的**同一批帧**上,不新增写的帧。
实现:把 merge 放进**现有 `_record_claims_blocking` 的同一个 `SessionLocal`**(紧接 `record_claims`),边际成本 = 每张券完成时多一条 `UPDATE coupon_session`(按 `trace_id` 唯一索引),不新增连接 / 不新增 `run_in_threadpool` 调用。
可选降级(若要「一次 session 只写一次」):只在 done 帧写 `platform_success`(全量 `coupon_results` 一次算完)。代价:`failed` / `abandoned`(没 done 帧)拿不到「崩溃前已成平台」→ 失败单部分成功丢失,与 §3「失败单取实际成的平台」相悖。**默认取每券帧并入**(失败单也如实统计),此降级留作观测到写压力后再启用。
## 6. admin 聚合与呈现
`app/admin/repositories/coupon_data.py::coupon_data_report``summary` 增加(沿用「全量拉区间 → Python 聚合」风格,与分位一致):
- `full_success_rate`(②)、`point_success_rate`(③)
- 可选 `per_platform``{platform: rate}`(各平台点位成功率,拆美团/淘宝/京东)
计算:对区间内 sessions`sel = platforms or 默认三档``succ = set(platform_success) ∩ sel`;按 §3 公式汇总。
- 过滤:默认 `app_env == 'prod'`(同现有分位口径,防测试串台)。
- 拆路径:`origin_package` 已在表上 → 看板后续可加「Path A / Path B」筛选项(本设计不含前端图表细节)。
- schema`app/admin/schemas/coupon_data.py` 的 summary 加对应字段(+ 可选 `per_platform`)。
## 7. 改动清单
- [x] `app/models/coupon_state.py``CouponSession``platform_success`
- [x] `alembic/versions/`:新迁移 add column `coupon_session.platform_success`
- [x] `app/repositories/coupon_state.py`:新增 `merge_session_platform_success`
- [x] `app/api/v1/coupon.py``/step` 推导 `ok_platforms` 并 union(前缀映射 + fire-and-forget
- [x] `app/admin/repositories/coupon_data.py``summary` 加 ②③(+ 可选 `per_platform`
- [x] `app/admin/schemas/coupon_data.py``summary` schema 加字段
- [x] `docs/database/coupon_state.md`:补 `platform_success` 列说明(并补 coupon_session 整节)
- [x] `tests/``tests/test_coupon_platform_success.py`10 测试,见 §9
## 8. 不做(YAGNI / 边界)
- **客户端不改、历史不回填**`platform_success` 只对新 session 生效(route B 的固有取舍,用户已接受)。
- **不引入 `platform_attempted`**:③ 分母用勾选平台。若日后要「排除真没券的平台」,注意 `platform_elapsed.keys()` 已近似「被处理过的平台」,可作 attempted 的现成来源,多半仍不必加列。
- **不动 `coupon_claim_record`** 的去重 / 归因:本指标绕开它,避免牵动频控 / 资产 / CPS 语义。
- **不做券级(每张券)成功率**:已选平台粒度。
## 9. 测试口径要点
- **union 幂等**:同一 `trace_id` 多帧并入同一平台,`platform_success` 不重复、保序。
- **失败单部分成功**session `failed`,但美团已成 → `succ = {meituan-waimai}`,计入 ③ 分子;② 仅当 `sel ⊆ succ` 才算整单成功。
- **空 `platforms``sel` 取默认三档**(全领)。
- **成功语义**`already_claimed` 计成功;`skipped` 不计。
- **基数**`abandoned` 计入 ②③ 基数,`succ` 取实际成的平台。
- **prod 过滤**`dev` 环境 session 不进指标。
## 10. 对 pricebot-backend 的影响
**结论:不需要改 pricebot,也不改发往 pricebot 的请求 / 不加调用 / 不加负载。**
- `/step` 仍原样透传请求 bytes 给 pricebot;本设计只**多解析 pricebot 的响应**`coupon_results` / `last_coupon_result`),而这两个字段服务端**今天已在** `_extract_coupon_results` / `record_claims` 里解析。零新增字段需求、零额外上游调用、pricebot 负载不变。
- **只读依赖(既有耦合,非新引入)**:平台映射靠 pricebot 的 `coupon_id` 前缀约定(`mt_` / `tb_` / `ele_` / `elm_` / `jd_`)。客户端 `couponIdToPlatform` 早就依赖同一套;本设计只是加了这份映射的第二个消费者。维护耦合:pricebot 若改 `coupon_id` 前缀,客户端与本指标会**一起**失效——但这是既有风险,依赖方向不变。无法识别前缀的券按「跳过」处理(与客户端一致)。
- 认账的 pricebot 事实(源:`app/models/response.py` + `docs/projects/领券-客户端对接协议.md`):
- `CouponResult = {coupon_id, name, vendor, status, reason?, duration_ms?}``coupon_results` 仅最终 done 帧全量,中间帧走 `last_coupon_result`(单张)。
- `vendor` 是来源标签(`meituan_internal` / `dianping_cps` …),**不等于**三档平台 id;且 `mt_dianping_xxx`(大众点评 CPS)也带 `mt_` 前缀归美团 → 印证「用 `coupon_id` 前缀、不用 `vendor`」正确。
---
## 11. 实现状态(交付记录 · 2026-07-07
**状态:实现完成、TDD 全绿、未提交、迁移未应用。** 纯服务端(shaguabijia-app-server),客户端 / pricebot 未动。
### 已交付改动
| 文件 | 改动 |
|---|---|
| `app/models/coupon_state.py` | `CouponSession``platform_success``_JSON`, nullable |
| `app/repositories/coupon_state.py` | `coupon_id_to_platform` / `succeeded_platforms` / `merge_session_platform_success` + 常量 `DEFAULT_PLATFORMS` / `_SUCCESS_STATUSES` |
| `app/api/v1/coupon.py` | `_record_claims_blocking` 内、`record_claims` 之后并入本帧成功平台(同一 `SessionLocal` |
| `app/admin/repositories/coupon_data.py` | `_success_rates(rows)` → summary 加 `full_success_rate②` / `point_success_rate③` + 3 个支撑计数 |
| `app/admin/schemas/coupon_data.py` | `CouponDataSummary` 加 5 字段 |
| `alembic/versions/coupon_session_platform_success.py` | add column;revision=`coupon_session_platform_success`down=`admin_user_plain_password`(当前 head |
| `docs/database/coupon_state.md` | 补 `coupon_session` 整节(表原本无文档)+ 新列 |
| `tests/test_coupon_platform_success.py` | 10 个测试(TDD |
> §7 里「可选 `per_platform`」原标 YAGNI 延后;**已在 §122026-07-08)补做**(随 admin 前端看板卡一并接入,见下)。
### 测试与验证
- 本特性:`Set-Location e:\project\shaguabijia-app-server; & .\.venv\Scripts\python.exe -m pytest tests\test_coupon_platform_success.py -q`**10 passed**
- 全量 `pytest -q`:**306 passed / 5 failed**。5 个失败**全部预存、与本次无关**:
- `test_coupon_proxy.py::test_coupon_step_passes_body_through`(测试断言 `json=` 但 handler 用 `content=` 转发;写代码前 sanity run 就红)。
- `test_invite.py` + `test_invite_compare_reward.py` 共 4 个(单独跑也红;本次改动集零 invite 文件)。
- 迁移:临时库 `alembic upgrade head` 通过、列已建、单一 head。
- lint:新增代码 `ruff` 全清;`coupon_state.py` 剩 3 处 pre-existing UP017`upsert_coupon_session``timezone.utc`)未动。
### 待办(在后端项目里继续)
1. **应用迁移**:`alembic upgrade head`(DDL 已验;线上只前进统计、历史不回填)。
2. **提交**:尚未提交;建议先开分支再提交。
3. **admin 前端图表**:后端指标已就绪(summary 的 `full_success_rate` / `point_success_rate` 等),看板卡 / 趋势展示待接前端。
4. (可选)修预存 `test_coupon_step_passes_body_through`(一行:capture `content` 而非 `json`)。
### 续开发须知
- `platform_success` 只在**带券结果的帧**写(`/step``succeeded_platforms(results)` 非空才 merge),≈ 领券券数量级、非每 step;复用 `record_claims` 同一 `SessionLocal`、不新增连接。
- merge **读不到 session 行则跳过**(不建兜底行);并集幂等、无新平台不写。
- 口径:成功=`status∈{success,already_claimed}`;基数含 `abandoned`/`failed`;③ 分母=`platforms`(空→全领三档);`coupon_id`→平台走前缀,与客户端 `couponIdToPlatform` 同词表。
---
## 12. 续做:admin 前端看板卡 + 分平台点位成功率(设计 · 2026-07-08
承 §11 待办 #3(前端图表)与 §7「可选 `per_platform`」。本轮把 ②③ 接入 admin「领券数据」页,并把 ③ 按平台拆(`per_platform`)。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。
- 状态:设计已评审通过(2026-07-08),待实现。
- 涉及仓库:`shaguabijia-app-server`(后端)+ `shaguabijia-admin-web`(admin 前端,Next.js + antd)。
### 12.1 后端:`per_platform` 分平台点位成功率
`app/admin/repositories/coupon_data.py::_success_rates(rows)` 在现有合计基础上,对每个 `p ∈ DEFAULT_PLATFORMS`(美团/淘宝/京东)累加:
- 分母 `per_total[p]` = 勾选了 p 的 session 数(`p ∈ sel`,空勾选 `sel` 按全领三档);
- 分子 `per_succ[p]` = 其中 `p ∈ succ`(该平台至少领到一张)的 session 数;
- `per_platform[p]` = `round(per_succ[p] / per_total[p], 4)`;分母 0 → `None`
**不变量**:`Σ_p per_succ[p] == point_success_count``Σ_p per_total[p] == point_total_count`(三档词表下恒成立;非三档平台 id 不计入 `per_platform`,由 `if p in per_total` 守卫)。用 §9 / `test_coupon_data_success_rates` 数据自检:美团 3/4=0.75、淘宝 2/3=0.6667、京东 1/2=0.5;合计仍 6/9=0.6667。
- summary 加一项 `per_platform`:**恒含三档键**,如 `{"meituan-waimai":0.75,"taobao-shanguang":0.6667,"jd-waimai":0.5}`(区间内无人勾选的平台 → 值 `None`)。
- schema `app/admin/schemas/coupon_data.py::CouponDataSummary``per_platform: dict[str, float | None]`
### 12.2 前端:admin-web「领券数据」汇总卡补一段
`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`(单文件,`CouponDataSummary` 为该页内联类型):
- 内联 `CouponDataSummary` 补:`full_success_count` / `full_success_rate` / `point_success_count` / `point_total_count` / `point_success_rate` + `per_platform: Record<string, number | null>`
- 新 helper `fmtPct(v) = v == null ? '-' : ${(v*100).toFixed(1)}%`(沿用本页 `-` 空值风格,数学同大盘 `pct`);antd 导入补 `Tooltip`,新增 `import { InfoCircleOutlined } from '@ant-design/icons'`
- 汇总卡「耗时分位」行之后,`Divider` + 两行 `Statistic`(各 `Col flex="1 1 0"`):
- 行1:**整单成功率** `fmtPct(full_success_rate)` · **点位成功率(合计)** `fmtPct(point_success_rate)`;标题各带 ⓘ `Tooltip`(口径说明;合计率注明「平台粒度、= 三档之和,与『数据大盘』券粒度口径不同」)。
- 行2:**美团 / 淘宝 / 京东 点位成功率** `fmtPct(per_platform['meituan-waimai' | 'taobao-shanguang' | 'jd-waimai'])`(平台名同「美团耗时」列既有叫法)。
- 不显示支撑数(合计与分平台均纯百分比);tooltip 不带分母。
### 12.3 测试
-`tests/test_coupon_platform_success.py::test_coupon_data_success_rates`:断言 `per_platform == {"meituan-waimai":0.75,"taobao-shanguang":round(2/3,4),"jd-waimai":0.5}`,并断言和不变量(`Σ 分子 == point_success_count == 6``Σ 分母 == point_total_count == 9`)。
- 前端 `shaguabijia-admin-web` lint / type-check 通过。
- 端到端:随下一步「真实 /step 实测」在跑起来的 admin-web + 后端页面上核对卡片渲染。
### 12.4 不做(YAGNI)
- 成功率**趋势线**:后端 `daily` / `hourly` 不含率字段,加趋势要另改聚合,超出本轮范围。
- 合计 / 分平台的**支撑数副文本**、tooltip 带分母。
- 客户端 / pricebot 改动;历史回填。
---
## 13. 续做:每券(coupon_id)成功率明细表(设计 · 2026-07-08)
产品要更细粒度:到**具体领券点位**(如「美团外卖红包天天领」「美团甄选好店」),即按 `coupon_id` 算成功/失败率,比 §12 的平台粒度再细一层。**改前端 + 后端 + 测试**;客户端 / pricebot 仍零改动。
- 状态:设计已评审通过(2026-07-08),待实现。
- 关键取舍(已定):数据源用 **`coupon_claim_record`**(它本就是「单张券一天一条」的系统记录,已带 `coupon_name` / `status` / `vendor` / `trace_id`),唯一缺 `app_env` → 补一列即可。§2 当初绕开它是因为**平台指标要按 session 归因**;而**每券成功率是全局聚合、不需要 session 归因**,`(device,券,天)` 折叠反而天然去重防刷,故这里用它是对的。
### 13.1 指标口径(已定)
- **成功** = `status ∈ {success, already_claimed}`;**尝试(分母)** = `status ∈ {success, already_claimed, failed}`;**`skipped` 排除**(无券可领/不适用,不算尝试、不进分母、不展示)。
- **成功率** = 成功 / 尝试(某券区间内无 tried 行 → 不出现在表里,无除零)。
- **粒度 = 「设备-天」**(非「每次点击」):`coupon_claim_record` 唯一键 `(device, coupon_id, claim_date)`,同设备当天同券只留最后状态。所以「尝试」= 有多少**设备-天**尝试过该券,「成功」= 其中最终领到的。要每次点击级须换源(route B / 原始事件),本轮不做。
- **环境**:跟随页面「环境」(prod/dev/全部),按 `app_env` 过滤;旧行 `app_env=NULL` **不回填** → 仅「全部」视图可见。
- **日期**:按 `claim_date`(Asia/Shanghai 自然日),与页面日期范围一致。
### 13.2 数据模型
`CouponClaimRecord`(`app/models/coupon_state.py`)加一列:
| 列 | 类型 | 说明 |
|----|------|------|
| `app_env` | `String(16)`,index,nullable | prod / dev。`/step` 落库时按 session 的 app_env 打标;旧行 NULL(不回填)。 |
- alembic 新迁移:`add_column coupon_claim_record.app_env`,nullable + index,无 server_default。
- 同步更新 `docs/database/coupon_state.md` 的 coupon_claim_record 节。
### 13.3 写路径(`/step`)
- `app/repositories/coupon_state.py::record_claims` 加参数 `app_env: str | None = None`;INSERT 时写入,UPDATE 时 `if app_env is not None: row.app_env = app_env`(不用 None 覆盖已有)。
- 新增轻量 repo 助手 `session_app_env(db, trace_id) -> str | None`(按 trace_id 取 `coupon_session.app_env`,查不到返回 None)。
- `app/api/v1/coupon.py::_record_claims_blocking`:`trace_id` 存在则先 `app_env = coupon_repo.session_app_env(db, trace_id)`,传给 `record_claims(..., app_env=app_env)`;`merge_session_platform_success` 保持原样(其自身 select 不变)。查不到 session / 无 trace_id → `app_env=None`(行为同旧)。
> 写放大:仅「带券结果的帧」触发(同 §5),每帧多一次 `session_app_env` 小查询(按 trace_id 唯一索引),可忽略。
### 13.4 admin 聚合(新 repo 函数)
`app/admin/repositories/coupon_data.py` 新增 `coupon_slot_report(db, *, date_from, date_to, app_env)`(与 `coupon_user_records` 同居本文件,同属「领券数据」看板;更新模块 docstring 注明本文件现读 `coupon_session` + `coupon_claim_record` 两源):
- `SELECT coupon_id, MAX(coupon_name) AS coupon_name, COUNT(*) AS tried, SUM(CASE WHEN status IN (success,already_claimed) THEN 1 ELSE 0 END) AS succeeded`
`WHERE claim_date ∈ [from,to] AND status IN (success,already_claimed,failed)` (+ `AND app_env = :env` 当 env 非「全部」) `GROUP BY coupon_id`
- 每行:`platform = coupon_id_to_platform(coupon_id)`(复用前缀映射,无法识别→None),`success_rate = round(succeeded/tried, 4)`
-`tried` 倒序、`coupon_id` 次序返回 `{"items": [...]}`
### 13.5 接口 + schema(新子端点)
- 路由 `app/admin/routers/coupon_data.py`:加 `@router.get("/coupons")``get_coupon_slots`,参数 `date_from` / `date_to` / `app_env`(同主 report 的解析与 `_MAX_RANGE_DAYS` 校验,`app_env="all"→None`),调 `coupon_slot_report`。路径全称 `/admin/api/coupon-data/coupons`(与现有 `/coupon-data/user-records` 同款子端点)。
- schema `app/admin/schemas/coupon_data.py`:
- `CouponSlotRow{coupon_id: str, coupon_name: str|None, platform: str|None, tried: int, succeeded: int, success_rate: float|None}`
- `CouponSlotsOut{date_from: str, date_to: str, items: list[CouponSlotRow]}`
### 13.6 前端
`shaguabijia-admin-web/src/app/(main)/coupon-data/page.tsx`:
- 汇总卡下方(或趋势图下方)新增一张「按券成功率」表:列 **券名**(`coupon_name || coupon_id`)/ **平台**(美团/淘宝/京东/其他)/ **尝试** / **成功** / **成功率**(`fmtPct`)。默认按尝试倒序;支持 antd 列排序。
- 点「查询」时,除主 report 外并行 `api.get('/admin/api/coupon-data/coupons', {params:{date_from,date_to,app_env}})`;空则不显示表。
- 新增内联类型 `CouponSlotRow`;平台名复用映射(`meituan-waimai→美团` 等,null→其他)。
### 13.7 测试
- 后端:`record_claims` stamp `app_env`(INSERT/UPDATE 两路);`session_app_env` 助手;`/step` 集成写入 `app_env`;`coupon_slot_report` 聚合(多券 × success/already_claimed/failed/skipped × 两 env:断言 tried/succeeded/rate、**skipped 排除**、env 过滤、排序);schema 契约。
- 前端:`tsc --noEmit`
### 13.8 不做(YAGNI)
- 每次点击级成功率(需换数据源);成功率趋势线;`app_env` 历史回填(可选一次性 `join trace_id→session.app_env`,默认不做);券级 tooltip / 明细下钻。
- 券表为**全局聚合**,**不随页面「用户」搜索框过滤**(用户维度非本需求;`coupon_claim_record` 虽有 `user_id`,YAGNI)。与主明细表按用户过滤的行为不同,属有意为之。
@@ -1,830 +0,0 @@
# 逐次比价/领券广告收益 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 <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: 提交**
```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 ? (
<span style={{ color: '#3f8600' }}>¥{r.ad_revenue_yuan.toFixed(4)}</span>
) : (
<span style={{ color: '#ccc' }}>-</span>
),
},
```
- [ ] **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<CouponDataRow> = [`)。在「耗时」列(`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。正常情况不需回滚。
-166
View File
@@ -1,166 +0,0 @@
"""收益明细「金币记录」文案验证脚手架(2026-07 文案改版验收用)。
问题:客户端按 bizType 显示固定文案(路线B,强制覆盖后端 remark),但账号若没有对应
bizType 的流水,收益明细页就空着,无从验证本脚本往指定测试用户塞每种 bizType 各一条
流水,让你在手机收益明细页一屏核对全部新文案;验收完 --clean 一键删除,不污染数据
dev 库用(APP_ENV=dev 时才允许 --seed/--clean)所有测试流水 ref_id 前缀 TESTDOC,
按前缀精确清理,不会误删真实流水
用法(pricebot env 直调,见项目 CLAUDE.md):
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --show
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --seed
D:/miniconda/envs/pricebot/python.exe scripts/seed_coinhistory_labels_test.py --clean
"""
from __future__ import annotations
import argparse
import sys
from app.core.config import settings
from app.core.rewards import CN_TZ
from datetime import datetime
from app.db.session import SessionLocal
from app.models.user import User
from app.models.wallet import CoinAccount, CoinTransaction
TEST_PHONE = "11111111111"
REF_PREFIX = "TESTDOC" # 所有本脚本造的流水都带这个 ref_id 前缀,便于精确清理
# 每条 = (bizType, 故意写错/留空的 remark, 金币数)。
# remark 故意填「错的」→ 若客户端仍显示新文案 = 证明路线B强制覆盖生效(无视后端 remark)。
# task_ 一条留空 remark → 走客户端兜底映射。顺序即手机上从新到旧的展示顺序(后塞的在最上)。
CASES: list[tuple[str, str, int]] = [
("signin", "每日签到 第99天(旧文案,应被覆盖)", 220),
("signin_boost", "签到膨胀 第99天", 3000),
("reward_video", "看视频奖励金币(旧文案,应被覆盖)", 200),
("feed_ad_reward_comparison", "", 50), # 后端新拆:比价场景 → 比价奖励
("feed_ad_reward_coupon", "", 50), # 后端新拆:领券场景 → 领券奖励
("feed_ad_reward", "信息流广告奖励(welfare/旧数据兜底)", 50),
("price_report_reward", "上报更低价审核通过(旧文案,应被覆盖)", 1000),
("feedback_reward", "意见反馈被采纳(旧文案,应被覆盖)", 10000),
("task_enable_notification", "", 750), # remark 留空 → 客户端「打开消息提醒奖励」
# 下两条现实中不会进金币记录(invite 发现金进邀请钱包 / compare_milestone 后端死代码不发钱),
# 仅用于验证「杀掉好友比价奖励 + 兜底改任务奖励」:两条都应显示「任务奖励」(remark 被强制无视)。
("invite", "好友比价奖励(旧文案,应被杀→任务奖励)", 200),
("compare_milestone", "", 120),
]
def _client_coin_title(biz_type: str, remark: str | None) -> str:
"""复刻 CoinHistoryViewModel.coinTitle 的最新逻辑(路线B),用于 --show 预览。
必须与客户端保持一致;客户端改了这里也要同步,否则预览会骗人
"""
fixed = {
"exchange_out": "金币兑换现金",
"signin": "每日签到奖励",
"signin_boost": "签到膨胀奖励",
"reward_video": "看视频赚金币",
"ad_reward": "看视频赚金币",
"feed_ad_reward_comparison": "比价奖励",
"feed_ad_reward_coupon": "领券奖励",
"feed_ad_reward": "信息流广告奖励",
"price_report_reward": "爆料奖励",
"feedback_reward": "反馈奖励",
"invite": "任务奖励", # 杀掉"好友比价奖励",归兜底
}
if biz_type in fixed:
return fixed[biz_type]
if remark:
return remark
if biz_type == "task_enable_notification":
return "打开消息提醒奖励"
return "任务奖励"
def _get_user(db) -> User:
u = db.query(User).filter(User.phone == TEST_PHONE).first()
if not u:
print(f"✗ 库里没有测试号 {TEST_PHONE} —— 先在手机上用这个号登录一次再跑本脚本。")
sys.exit(1)
return u
def cmd_show() -> None:
"""只打印:每种 bizType 经客户端映射后会显示成什么(不写库)。"""
print("bizType 造流水后,收益明细页预期显示的文案:\n")
print(f" {'bizType':32} {'后端remark(故意填的)':32} → 手机显示")
print(" " + "-" * 90)
for biz, remark, _coin in CASES:
shown = _client_coin_title(biz, remark or None)
rk = (remark or "(空)")
print(f" {biz:32} {rk:32}{shown}")
print("\n注:remark 列是故意填的『旧/错』文案;'手机显示'若为新文案 = 强制覆盖生效。")
print("invite / compare_milestone 已从客户端映射删除:invite 有 remark 故显示原样,")
print("compare_milestone remark 空故落兜底『奖励』—— 两者都不再有专属新文案(符合『去掉』)。")
def cmd_seed() -> None:
if settings.APP_ENV != "dev":
print(f"✗ 拒绝:APP_ENV={settings.APP_ENV},本脚本只在 dev 库造测试数据。")
sys.exit(1)
db = SessionLocal()
u = _get_user(db)
acc = db.query(CoinAccount).filter(CoinAccount.user_id == u.id).first()
if acc is None:
acc = CoinAccount(user_id=u.id, coin_balance=0, cash_balance_cents=0)
db.add(acc)
db.flush()
now = datetime.now(CN_TZ).replace(tzinfo=None)
made = 0
for i, (biz, remark, coin) in enumerate(CASES):
ref = f"{REF_PREFIX}:{biz}:{i}"
exists = db.query(CoinTransaction).filter(CoinTransaction.ref_id == ref).first()
if exists:
continue
acc.coin_balance += coin
db.add(CoinTransaction(
user_id=u.id, amount=coin, balance_after=acc.coin_balance,
biz_type=biz, ref_id=ref, remark=remark or None, created_at=now,
))
made += 1
db.commit()
print(f"✓ 已给 user_id={u.id}({TEST_PHONE})造 {made} 条测试流水,当前金币余额 {acc.coin_balance}")
print(" → 打开手机 App「收益明细 / 金币记录」下拉刷新,逐条核对文案。")
print(" → 验收完跑 --clean 删除这些测试流水。")
def cmd_clean() -> None:
if settings.APP_ENV != "dev":
print(f"✗ 拒绝:APP_ENV={settings.APP_ENV}")
sys.exit(1)
db = SessionLocal()
u = _get_user(db)
rows = db.query(CoinTransaction).filter(
CoinTransaction.user_id == u.id,
CoinTransaction.ref_id.like(f"{REF_PREFIX}:%"),
).all()
total = sum(r.amount for r in rows)
for r in rows:
db.delete(r)
acc = db.query(CoinAccount).filter(CoinAccount.user_id == u.id).first()
if acc is not None:
acc.coin_balance -= total # 把造流水时加的余额扣回,还原
db.commit()
print(f"✓ 已删除 {len(rows)} 条 TESTDOC 测试流水,余额回扣 {total},当前 {acc.coin_balance if acc else 0}")
def main() -> None:
ap = argparse.ArgumentParser(description="收益明细金币文案验证脚手架")
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--show", action="store_true", help="只打印每种 bizType 的预期显示文案,不写库")
g.add_argument("--seed", action="store_true", help="往测试号造每种 bizType 各一条流水")
g.add_argument("--clean", action="store_true", help="删除本脚本造的所有测试流水")
args = ap.parse_args()
if args.show:
cmd_show()
elif args.seed:
cmd_seed()
elif args.clean:
cmd_clean()
if __name__ == "__main__":
main()
-73
View File
@@ -1,73 +0,0 @@
"""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"),
_ecpm("t_cap", "60000", "sess-cap-a"), # 600 元 CPM > ¥500 钳顶
])
db.flush()
rev = crud_ecpm.revenue_yuan_by_trace(db, ["t1", "t2", "t3", "t_cap"])
assert rev["t1"] == 0.005
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()
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()
-160
View File
@@ -1,160 +0,0 @@
"""埋点健康度聚合:纯差分函数 + admin 端点。"""
from __future__ import annotations
from datetime import datetime, timezone
from app.admin.repositories.analytics_health import _cn_day, _rates, diff_snapshots
def _row(device, epoch, event, ts, **cum) -> dict:
base = {"attempted": 0, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0}
base.update(cum)
return {
"device_id": device, "epoch_id": epoch, "event": event,
"created_at": datetime(2026, 7, 1, ts, 0, tzinfo=timezone.utc),
"app_ver": "0.2.12(62)", "oem": "ColorOS", "os": "Android 14",
**base,
}
def test_diff_first_row_is_full_cumulative() -> None:
rows = [_row("d", "e", "video_play", 1, attempted=100, delivered=90)]
out = diff_snapshots(rows)
assert len(out) == 1
assert out[0]["d_attempted"] == 100
assert out[0]["d_delivered"] == 90
def test_diff_consecutive_delta() -> None:
rows = [
_row("d", "e", "video_play", 1, attempted=100, delivered=90),
_row("d", "e", "video_play", 2, attempted=150, delivered=140),
]
out = sorted(diff_snapshots(rows), key=lambda r: r["created_at"])
assert out[1]["d_attempted"] == 50
assert out[1]["d_delivered"] == 50
def test_diff_epoch_reset_new_partition() -> None:
rows = [
_row("d", "e1", "video_play", 1, attempted=100),
_row("d", "e2", "video_play", 2, attempted=5),
]
out = {(r["epoch_id"]): r["d_attempted"] for r in diff_snapshots(rows)}
assert out["e1"] == 100
assert out["e2"] == 5
def test_diff_clamps_negative_on_reorder() -> None:
rows = [
_row("d", "e", "video_play", 1, attempted=100),
_row("d", "e", "video_play", 2, attempted=80),
]
out = sorted(diff_snapshots(rows), key=lambda r: r["created_at"])
assert out[1]["d_attempted"] == 0
def test_rates_computes_both_formulas() -> None:
out = _rates({"attempted": 150, "drop_capture": 0, "delivered": 140, "drop_undelivered": 10})
assert out["track_success_rate"] == 1.0
assert out["report_success_rate"] == 140 / 150
def test_rates_zero_denominator_yields_none() -> None:
out = _rates({"attempted": 0, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0})
assert out["track_success_rate"] is None
assert out["report_success_rate"] is None
def test_cn_day_beijing_boundary() -> None:
# UTC 15:59 → 北京 23:59 同日;UTC 16:00 → 北京 次日 00:00
assert _cn_day(datetime(2026, 7, 1, 15, 59, tzinfo=timezone.utc)) == "2026-07-01"
assert _cn_day(datetime(2026, 7, 1, 16, 0, tzinfo=timezone.utc)) == "2026-07-02"
def test_diff_same_timestamp_ordered_by_id() -> None:
ts = datetime(2026, 7, 1, 1, 0, tzinfo=timezone.utc)
# 相同 created_at,乱序传入(id=2 在前);应按 id 排序 → id=1(cum100) 在前
rows = [
{"id": 2, "device_id": "d", "epoch_id": "e", "event": "vp", "created_at": ts,
"app_ver": "v", "oem": "o", "os": "s",
"attempted": 150, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0},
{"id": 1, "device_id": "d", "epoch_id": "e", "event": "vp", "created_at": ts,
"app_ver": "v", "oem": "o", "os": "s",
"attempted": 100, "drop_capture": 0, "delivered": 0, "drop_undelivered": 0},
]
out = diff_snapshots(rows)
assert out[0]["d_attempted"] == 100 # id=1 sorts first → its cumulative
assert out[1]["d_attempted"] == 50 # id=2 second → 150-100
import pytest
from fastapi.testclient import TestClient
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.db.session import SessionLocal
@pytest.fixture()
def admin_client() -> TestClient:
return TestClient(admin_app)
@pytest.fixture()
def admin_token() -> str:
db = SessionLocal()
try:
if admin_repo.get_by_username(db, "health_admin") is None:
admin_repo.create_admin(db, username="health_admin", password="pw", role="super_admin")
finally:
db.close()
c = TestClient(admin_app)
r = c.post("/admin/api/auth/login", json={"username": "health_admin", "password": "pw"})
return r.json()["access_token"]
def _auth(t: str) -> dict:
return {"Authorization": f"Bearer {t}"}
def _seed_two_snapshots(client: TestClient) -> None:
for attempted, delivered in ((100, 90), (150, 140)):
client.post("/api/v1/analytics/selfstat", json={
"device_id": "d-health", "epoch_id": "e-health",
"app_ver": "0.2.12(62)", "oem": "ColorOS", "os": "Android 14",
"events": [{"event": "video_play", "attempted": attempted,
"drop_capture": 0, "delivered": delivered, "drop_undelivered": 0}],
})
def test_health_overview_requires_auth(admin_client: TestClient) -> None:
r = admin_client.get("/admin/api/analytics-health/overview",
params={"date_from": "2026-07-01T00:00:00Z", "date_to": "2030-01-01T00:00:00Z"})
assert r.status_code == 401
def test_health_overview(client: TestClient, admin_client: TestClient, admin_token: str) -> None:
_seed_two_snapshots(client) # 播种走公开 app(ingest 端点在 app,不在 admin_app)
r = admin_client.get(
"/admin/api/analytics-health/overview",
params={"date_from": "2026-07-01T00:00:00Z", "date_to": "2030-01-01T00:00:00Z"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
data = r.json()
# 两快照累计 100→150,差分后 attempted 总 150(首快照 100 + 增量 50)
assert data["attempted"] == 150
assert data["track_success_rate"] == 1.0
def test_health_breakdown(client: TestClient, admin_client: TestClient, admin_token: str) -> None:
_seed_two_snapshots(client) # 播种走公开 app
r = admin_client.get(
"/admin/api/analytics-health/breakdown",
params={"date_from": "2026-07-01T00:00:00Z", "date_to": "2030-01-01T00:00:00Z", "dim": "event"},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
rows = r.json()
assert any(row["key"] == "video_play" for row in rows)
-43
View File
@@ -1,43 +0,0 @@
"""自报计数上报端点测试。"""
from __future__ import annotations
from fastapi.testclient import TestClient
def _payload(**over) -> dict:
base = {
"device_id": "dev-1", "epoch_id": "ep-1", "sent_at": 1700000000000,
"app_ver": "0.2.12(62)", "oem": "ColorOS", "os": "Android 14",
"batches_attempted": 10, "batches_ok": 9, "batches_fail": 1,
"retries": 1, "queue_depth": 2,
"events": [
{"event": "video_play", "attempted": 100, "drop_capture": 1,
"delivered": 95, "drop_undelivered": 2},
],
}
base.update(over)
return base
def test_selfstat_ingest_ok(client: TestClient) -> None:
r = client.post("/api/v1/analytics/selfstat", json=_payload())
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert isinstance(body["snapshot_id"], int)
def test_selfstat_ingest_empty_events(client: TestClient) -> None:
r = client.post("/api/v1/analytics/selfstat", json=_payload(events=[]))
assert r.status_code == 200, r.text
assert r.json()["ok"] is True
def test_selfstat_ingest_db_error_returns_503(client: TestClient, monkeypatch) -> None:
"""落库异常时端点回 503(计数链路稳定性守卫),不裸奔 500。"""
def _boom(*_args, **_kwargs):
raise RuntimeError("db gone")
monkeypatch.setattr("app.repositories.analytics_selfstat.record_selfstat", _boom)
r = client.post("/api/v1/analytics/selfstat", json=_payload())
assert r.status_code == 503, r.text
-87
View File
@@ -1,87 +0,0 @@
"""两个看板逐行「本次广告收益」(元):按 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, coupon_user_records
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()
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()
-299
View File
@@ -1,299 +0,0 @@
"""领券「平台成功率」埋点:coupon_id→平台映射 / 成功平台推导 / session platform_success 并集。
设计:docs/guides/领券成功率指标-设计与埋点.md
"""
from __future__ import annotations
from datetime import UTC, date, datetime
from sqlalchemy import delete, func, select
from app.admin.repositories.coupon_data import coupon_data_report
from app.db.session import SessionLocal
from app.models.coupon_state import CouponSession
from app.repositories.coupon_state import (
coupon_id_to_platform,
merge_session_platform_success,
succeeded_platforms,
)
def _agg_session(
trace: str, platforms, platform_success, *, status: str = "completed"
) -> CouponSession:
"""构造一条聚合测试用 session(started_date 固定 2020-01-02、app_env=prod,不 commit)。"""
return CouponSession(
trace_id=trace,
device_id="d-agg",
status=status,
app_env="prod",
platforms=platforms,
platform_success=platform_success,
started_at=datetime(2020, 1, 2, tzinfo=UTC),
started_date=date(2020, 1, 2),
)
def _make_session(db, trace_id: str, **kw) -> CouponSession:
row = CouponSession(
trace_id=trace_id,
device_id="dev-merge",
status=kw.pop("status", "started"),
started_at=datetime(2020, 1, 1, tzinfo=UTC),
started_date=date(2020, 1, 1),
**kw,
)
db.add(row)
db.commit()
return row
def test_coupon_id_to_platform_prefix_mapping() -> None:
"""coupon_id 前缀 → 三档平台 id(与客户端 couponIdToPlatform 同词表)。"""
assert coupon_id_to_platform("mt_banjia_zhoumo") == "meituan-waimai"
assert coupon_id_to_platform("mt_cps_waimai_redpacket") == "meituan-waimai" # 大众点评CPS 也挂 mt_ → 归美团
assert coupon_id_to_platform("tb_vip_shangou_voucher") == "taobao-shanguang"
assert coupon_id_to_platform("ele_hongbao") == "taobao-shanguang"
assert coupon_id_to_platform("elm_hongbao") == "taobao-shanguang"
assert coupon_id_to_platform("jd_redpacket") == "jd-waimai"
def test_coupon_id_to_platform_unknown_returns_none() -> None:
"""无法识别的前缀 / 空 → None(调用方跳过,不计入平台)。"""
assert coupon_id_to_platform("weird_xxx") is None
assert coupon_id_to_platform("") is None
assert coupon_id_to_platform(None) is None # type: ignore[arg-type]
def test_succeeded_platforms_filters_status_and_dedups() -> None:
"""只取 status∈{success,already_claimed} 的券,映射平台后去重;失败/跳过/无法识别的不计。"""
results = [
{"coupon_id": "mt_a", "status": "success"},
{"coupon_id": "mt_b", "status": "already_claimed"}, # 也算成功 → 仍是美团
{"coupon_id": "mt_c", "status": "failed"}, # 不算
{"coupon_id": "tb_a", "status": "success"},
{"coupon_id": "jd_a", "status": "skipped"}, # 不算
{"coupon_id": "weird", "status": "success"}, # 无法识别平台 → 跳过
]
assert set(succeeded_platforms(results)) == {"meituan-waimai", "taobao-shanguang"}
def test_succeeded_platforms_empty() -> None:
assert succeeded_platforms([]) == []
def test_merge_platform_success_union_idempotent() -> None:
"""按 trace_id 把成功平台并入 platform_success:并集去重 + 按 DEFAULT_PLATFORMS 保序 + 重复并入幂等。"""
db = SessionLocal()
trace = "merge-union-1"
try:
_make_session(db, trace)
merge_session_platform_success(db, trace, ["meituan-waimai"])
merge_session_platform_success(db, trace, ["jd-waimai", "meituan-waimai"]) # 并集 + 已有幂等
db.expire_all()
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == trace)
).scalar_one()
# 美团→淘宝→京东固定序;只含实际成功的美团/京东
assert row.platform_success == ["meituan-waimai", "jd-waimai"]
finally:
db.execute(delete(CouponSession).where(CouponSession.trace_id == trace))
db.commit()
db.close()
def test_merge_platform_success_missing_row_skips() -> None:
"""trace_id 无对应行 → 静默跳过:不建兜底行、不抛异常(设计 §5)。"""
db = SessionLocal()
try:
merge_session_platform_success(db, "no-such-trace", ["meituan-waimai"])
n = db.execute(
select(func.count()).select_from(CouponSession).where(
CouponSession.trace_id == "no-such-trace"
)
).scalar_one()
assert n == 0
finally:
db.close()
def test_coupon_data_success_rates() -> None:
"""admin 聚合 ②整单成功率 / ③点位成功率:平台粒度,含空 platforms→全领三档、abandoned 入基数。
A 勾美团+淘宝两个都成 整单成功;点位 2/2
B 勾三档只成美团 非整单;点位 1/3
C 全领(platforms空3)三档全成 整单成功;点位 3/3
D abandoned勾美团无成功平台 非整单;点位 0/1(仍进基数)
发起数=4;整单成功=2 0.5;点位 6/9 0.6667
"""
db = SessionLocal()
try:
db.add_all([
_agg_session("agg-A", ["meituan-waimai", "taobao-shanguang"],
["meituan-waimai", "taobao-shanguang"]),
_agg_session("agg-B", ["meituan-waimai", "taobao-shanguang", "jd-waimai"],
["meituan-waimai"]),
_agg_session("agg-C", [], ["meituan-waimai", "taobao-shanguang", "jd-waimai"]),
_agg_session("agg-D", ["meituan-waimai"], None, status="abandoned"),
])
db.flush() # 同会话可见,不 commit(finally 回滚保持隔离)
s = coupon_data_report(
db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod"
)["summary"]
assert s["started_count"] == 4
assert s["full_success_count"] == 2
assert s["full_success_rate"] == 0.5
assert s["point_success_count"] == 6
assert s["point_total_count"] == 9
assert s["point_success_rate"] == round(6 / 9, 4)
# ③ 分平台点位成功率(§12):美团 3/4、淘宝 2/3、京东 1/2。
# 分平台(成功,总)= 美团(3,4)+淘宝(2,3)+京东(1,2) = (6,9),
# 其和正好等于上面已断言的 point_success_count=6 / point_total_count=9(和不变量)。
assert s["per_platform"] == {
"meituan-waimai": 0.75,
"taobao-shanguang": round(2 / 3, 4),
"jd-waimai": 0.5,
}
finally:
db.rollback()
db.close()
def test_coupon_data_success_rates_empty_range() -> None:
"""区间无 session → 发起数 0、两个率为 None(不除零)。"""
db = SessionLocal()
try:
s = coupon_data_report(
db, date_from="2019-01-01", date_to="2019-01-01", app_env="prod"
)["summary"]
assert s["started_count"] == 0
assert s["full_success_rate"] is None
assert s["point_success_rate"] is None
finally:
db.close()
def test_coupon_data_summary_schema_exposes_rates() -> None:
"""schema 契约:CouponDataSummary 暴露 ②③ 字段,键名与 repo 输出一致(router 直接 **summary 构造)。"""
from app.admin.schemas.coupon_data import CouponDataSummary
db = SessionLocal()
try:
db.add(_agg_session("agg-schema-1", ["meituan-waimai"], ["meituan-waimai"]))
db.flush()
summary = coupon_data_report(
db, date_from="2020-01-02", date_to="2020-01-02", app_env="prod"
)["summary"]
dumped = CouponDataSummary(**summary).model_dump()
assert dumped["full_success_rate"] == summary["full_success_rate"]
assert dumped["point_success_rate"] == summary["point_success_rate"]
assert dumped["full_success_count"] == summary["full_success_count"]
assert dumped["point_success_count"] == summary["point_success_count"]
assert dumped["point_total_count"] == summary["point_total_count"]
assert dumped["per_platform"] == summary["per_platform"]
finally:
db.rollback()
db.close()
def test_step_writes_platform_success_to_session(client) -> None:
"""/step 集成:pricebot 返 coupon_results → 本 trace 的 coupon_session.platform_success 落库(仅成功平台)。"""
from unittest.mock import MagicMock, patch
import httpx
from app.models.coupon_state import CouponClaimRecord, CouponDailyCompletion
trace = "int-trace-1"
device = "dev-int-1"
# 预置一条 started session(模拟 /session started 已先落库)
db = SessionLocal()
try:
db.add(CouponSession(
trace_id=trace, device_id=device, status="started", app_env="dev",
platforms=["meituan-waimai", "taobao-shanguang"],
started_at=datetime(2020, 1, 5, tzinfo=UTC),
started_date=date(2020, 1, 5),
))
db.commit()
finally:
db.close()
fake_resp = {
"success": True,
"action": {"command": "done", "params": {
"information": "已领 1 张",
"coupon_results": [
{"coupon_id": "mt_x", "name": "美团券", "vendor": "meituan_internal", "status": "success"},
{"coupon_id": "tb_y", "name": "淘宝券", "vendor": "taobao", "status": "failed"},
],
}},
"continue": False,
}
async def fake_post(self, url, **kw):
m = MagicMock()
m.status_code = 200
m.json = lambda: fake_resp
return m
body = {
"device_id": device, "trace_id": trace, "step": 5,
"screen_state": {"screen": {"width": 1080, "height": 2340, "density": 3.0},
"foreground": {"package": "x", "activity": ""}, "windows": []},
}
try:
with patch.object(httpx.AsyncClient, "post", fake_post):
r = client.post("/api/v1/coupon/step", json=body)
assert r.status_code == 200, r.text
db = SessionLocal()
try:
row = db.execute(
select(CouponSession).where(CouponSession.trace_id == trace)
).scalar_one()
assert row.platform_success == ["meituan-waimai"] # tb 失败不计入
claims = db.execute(
select(CouponClaimRecord).where(CouponClaimRecord.device_id == device)
).scalars().all()
assert claims and all(c.app_env == "dev" for c in claims) # session app_env 打标
finally:
db.close()
finally:
db = SessionLocal()
try:
db.execute(delete(CouponSession).where(CouponSession.trace_id == trace))
db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == device))
db.execute(delete(CouponDailyCompletion).where(CouponDailyCompletion.device_id == device))
db.commit()
finally:
db.close()
def test_coupon_data_status_filter() -> None:
"""状态多选过滤(方案 A):整个视图按选中状态算;None/空=全部。"""
db = SessionLocal()
try:
db.add_all([
_agg_session("st-A", ["meituan-waimai"], ["meituan-waimai"], status="completed"),
_agg_session("st-B", ["meituan-waimai"], ["meituan-waimai"], status="started"),
_agg_session("st-C", ["meituan-waimai"], None, status="failed"),
_agg_session("st-D", ["meituan-waimai"], ["meituan-waimai"], status="abandoned"),
])
db.flush()
base = dict(date_from="2020-01-02", date_to="2020-01-02", app_env="prod")
# None = 全部 4 发起
assert coupon_data_report(db, **base)["summary"]["started_count"] == 4
# 排除 started → 3 发起(整个视图,发起数也随之变)
sub = coupon_data_report(db, **base, statuses=["completed", "failed", "abandoned"])["summary"]
assert sub["started_count"] == 3
assert sub["completed_count"] == 1
# 只 completed → 发起数=1、整单成功率基数=1(该 completed 是整单成功)
comp = coupon_data_report(db, **base, statuses=["completed"])["summary"]
assert comp["started_count"] == 1
assert comp["full_success_rate"] == 1.0
finally:
db.rollback()
db.close()
-137
View File
@@ -1,137 +0,0 @@
"""每券成功率(§13):app_env 落库 + coupon_slot_report 聚合。"""
from __future__ import annotations
from datetime import UTC, date, datetime
from sqlalchemy import delete, select
from app.admin.repositories.coupon_data import coupon_slot_report
from app.db.session import SessionLocal
from app.models.coupon_state import CouponClaimRecord, CouponSession
from app.repositories.coupon_state import record_claims, session_app_env
def test_session_app_env_lookup() -> None:
db = SessionLocal()
trace = "slot-env-1"
try:
db.add(CouponSession(
trace_id=trace, device_id="d-slot", status="started", app_env="dev",
started_at=datetime(2020, 2, 1, tzinfo=UTC), started_date=date(2020, 2, 1),
))
db.commit()
assert session_app_env(db, trace) == "dev"
assert session_app_env(db, "no-such-trace") is None
assert session_app_env(db, None) is None
finally:
db.execute(delete(CouponSession).where(CouponSession.trace_id == trace))
db.commit()
db.close()
def test_record_claims_stamps_app_env() -> None:
db = SessionLocal()
dev = "d-slot-stamp"
try:
record_claims(db, dev, None, "t-stamp",
[{"coupon_id": "mt_x", "status": "success", "name": "美团券"}],
app_env="prod")
row = db.execute(select(CouponClaimRecord).where(
CouponClaimRecord.device_id == dev)).scalar_one()
assert row.app_env == "prod"
# UPDATE 路:app_env=None 不覆盖已有值
record_claims(db, dev, None, "t-stamp",
[{"coupon_id": "mt_x", "status": "already_claimed", "name": "美团券"}],
app_env=None)
db.expire_all()
row = db.execute(select(CouponClaimRecord).where(
CouponClaimRecord.device_id == dev)).scalar_one()
assert row.app_env == "prod"
assert row.status == "already_claimed"
finally:
db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.device_id == dev))
db.commit()
db.close()
def _claim(db, device, coupon_id, status, app_env, name=None, d=date(2020, 2, 2)):
db.add(CouponClaimRecord(
device_id=device, coupon_id=coupon_id, claim_date=d,
status=status, coupon_name=name, app_env=app_env,
))
def test_coupon_slot_report_aggregates() -> None:
"""按 coupon_id 聚合:tried=成功+失败(skipped 排除)、rate、env 过滤、tried 倒序。"""
db = SessionLocal()
try:
# mt_a(prod):dev1 success + dev2 already_claimed + dev3 failed → 2/3;dev4 skipped 不计
_claim(db, "sdev1", "mt_a", "success", "prod", "美团外卖红包天天领")
_claim(db, "sdev2", "mt_a", "already_claimed", "prod", "美团外卖红包天天领")
_claim(db, "sdev3", "mt_a", "failed", "prod", "美团外卖红包天天领")
_claim(db, "sdev4", "mt_a", "skipped", "prod", "美团外卖红包天天领")
_claim(db, "sdev1", "tb_b", "success", "prod", "淘宝券") # 1/1
_claim(db, "sdev9", "mt_a", "failed", "dev", "美团外卖红包天天领") # 仅 dev/全部
db.commit()
prod = coupon_slot_report(
db, date_from="2020-02-02", date_to="2020-02-02", app_env="prod"
)["items"]
by_id = {r["coupon_id"]: r for r in prod}
assert by_id["mt_a"]["tried"] == 3 # skipped 排除
assert by_id["mt_a"]["succeeded"] == 2
assert by_id["mt_a"]["success_rate"] == round(2 / 3, 4)
assert by_id["mt_a"]["platform"] == "meituan-waimai"
assert by_id["mt_a"]["coupon_name"] == "美团外卖红包天天领"
assert by_id["tb_b"]["success_rate"] == 1.0
assert by_id["tb_b"]["platform"] == "taobao-shanguang"
assert [r["coupon_id"] for r in prod] == ["mt_a", "tb_b"] # tried 倒序
dev = coupon_slot_report(
db, date_from="2020-02-02", date_to="2020-02-02", app_env="dev"
)["items"]
assert {r["coupon_id"]: r["tried"] for r in dev} == {"mt_a": 1}
allenv = coupon_slot_report(
db, date_from="2020-02-02", date_to="2020-02-02", app_env=None
)["items"]
assert {r["coupon_id"]: r["tried"] for r in allenv}["mt_a"] == 4
finally:
db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.claim_date == date(2020, 2, 2)))
db.commit()
db.close()
def test_coupon_slots_endpoint() -> None:
from fastapi.testclient import TestClient
from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.admin.security import create_admin_token
db = SessionLocal()
try:
_claim(db, "epdev1", "mt_ep", "success", "prod", "端点券", d=date(2020, 2, 3))
_claim(db, "epdev2", "mt_ep", "failed", "prod", "端点券", d=date(2020, 2, 3))
admin = admin_repo.create_admin(
db, username="slot_admin", password="pass1234", role="super_admin"
)
token, _exp = create_admin_token(admin_id=admin.id, role=admin.role)
db.commit()
finally:
db.close()
try:
c = TestClient(admin_app)
r = c.get(
"/admin/api/coupon-data/coupons",
params={"date_from": "2020-02-03", "date_to": "2020-02-03", "app_env": "prod"},
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200, r.text
row = next(x for x in r.json()["items"] if x["coupon_id"] == "mt_ep")
assert row["tried"] == 2 and row["succeeded"] == 1 and row["success_rate"] == 0.5
assert row["coupon_name"] == "端点券" and row["platform"] == "meituan-waimai"
finally:
db = SessionLocal()
db.execute(delete(CouponClaimRecord).where(CouponClaimRecord.claim_date == date(2020, 2, 3)))
db.commit()
db.close()
+3 -5
View File
@@ -143,15 +143,14 @@ def test_two_accounts_withdraw_independent(client, monkeypatch) -> None:
_reject(r1.json()["out_bill_no"]) # 退回 invite_cash + 结清活跃单
r2 = client.post(
"/api/v1/wallet/withdraw",
# 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
json={"amount_cents": 50, "source": "coin_cash"},
json={"amount_cents": 100, "source": "coin_cash"},
headers=_auth(token),
)
assert r2.json()["status"] == "reviewing"
cash, invite_cash = _balances(client, token)
assert invite_cash == 500 # 已退回
assert cash == 350 # 扣了 cash 50
assert cash == 300 # 扣了 cash 100
def test_invite_me_returns_reward_stats(client) -> None:
@@ -178,8 +177,7 @@ def test_withdraw_orders_source_filter(client, monkeypatch) -> None:
_reject(r1.json()["out_bill_no"]) # 结清,才能提第二笔
client.post(
"/api/v1/wallet/withdraw",
# 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
json={"amount_cents": 50, "source": "coin_cash"},
json={"amount_cents": 100, "source": "coin_cash"},
headers=_auth(token),
)
+1 -2
View File
@@ -139,8 +139,7 @@ def test_coin_cash_withdraw_still_reconciled(client, monkeypatch) -> None:
before = _ledger()
r = client.post(
"/api/v1/wallet/withdraw",
# 50 分 = 0.5 元档(7-9 起 coin_cash 只能提预设档位)
json={"amount_cents": 50, "source": "coin_cash"},
json={"amount_cents": 200, "source": "coin_cash"},
headers=_auth(token),
)
assert r.status_code == 200, r.text
-186
View File
@@ -1,186 +0,0 @@
"""福利页(coin_cash)提现档位规则测试(7-9提现ui对齐)。
规则(2026-07-09 拍板):
- 档位 0.1/0.3(新人,历史一次性,免广告)+ 0.5(日3次)/10/20(日1次)
- 计次口径"发起就算":当天创建的单不论最终状态(含被拒)都占名额;新人档任何状态都算用过
- 常规三档每天只能选一个;新人档不参与该互斥,两个新人档同天可各提一次
- invite_cash 无档位概念:tiers 为空下单不走档位闸(邀请页行为不变)
wxpay 调用全部 monkeypatch;现金余额 DB 直灌( test_withdraw.py 套路)
"""
from __future__ import annotations
from sqlalchemy import select
from app.db.session import SessionLocal
from app.models.user import User
from app.models.wallet import CoinAccount
from app.repositories import wallet as crud_wallet
def _login(client, phone: str) -> str:
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
return r.json()["access_token"]
def _auth(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _seed_balances(client, token: str, phone: str, cash: int = 0, invite_cash: int = 0) -> None:
client.get("/api/v1/wallet/account", headers=_auth(token))
db = SessionLocal()
try:
user = db.execute(select(User).where(User.phone == phone)).scalar_one()
acc = db.get(CoinAccount, user.id)
acc.cash_balance_cents = cash
acc.invite_cash_balance_cents = invite_cash
db.commit()
finally:
db.close()
def _patch_userinfo(monkeypatch, openid: str) -> None:
monkeypatch.setattr(
"app.integrations.wxpay.code_to_userinfo",
lambda code: {"openid": openid, "nickname": "昵称", "avatar_url": None, "raw": {}},
)
def _reject(bill: str) -> None:
"""管理员拒绝:退款结清活跃单(便于同用户继续发起下一笔;名额按'发起就算'仍占)。"""
db = SessionLocal()
try:
crud_wallet.reject_withdraw(db, bill, "test")
finally:
db.close()
def _withdraw(client, token: str, cents: int):
return client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": cents, "source": "coin_cash"},
headers=_auth(token),
)
def _tiers(client, token: str, source: str = "coin_cash") -> list[dict]:
r = client.get(
"/api/v1/wallet/withdraw-info", params={"source": source}, headers=_auth(token)
)
assert r.status_code == 200, r.text
return r.json()["tiers"]
def test_withdraw_info_tiers_full_and_invite_empty(client, monkeypatch) -> None:
"""新用户 coin_cash 下发 5 档(新人角标齐);invite_cash tiers 为空。"""
token = _login(client, "13800006001")
tiers = _tiers(client, token)
assert [t["amount_cents"] for t in tiers] == [10, 30, 50, 1000, 2000]
assert [t["label"] for t in tiers] == ["0.1", "0.3", "0.5", "10", "20"]
assert tiers[0]["badge"] == "新人福利" and tiers[0]["is_newbie"] is True
assert tiers[1]["badge"] == "新人福利" and tiers[1]["is_newbie"] is True
assert all(t["available"] for t in tiers)
assert tiers[2]["remaining_today"] == 3 # 0.5 日 3 次
assert _tiers(client, token, source="invite_cash") == []
def test_newbie_tiers_independent_and_once_forever(client, monkeypatch) -> None:
"""0.1 提过(即使被拒)→ 永久消失;同天 0.3 仍可提;新人档不锁常规档。"""
_patch_userinfo(monkeypatch, "openid_tier_2")
token = _login(client, "13800006002")
_seed_balances(client, token, "13800006002", cash=5000)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = _withdraw(client, token, 10) # 0.1 新人档
assert r.status_code == 200, r.text
_reject(r.json()["out_bill_no"]) # 被拒也算用过("发起就算")
tiers = _tiers(client, token)
amounts = [t["amount_cents"] for t in tiers]
assert 10 not in amounts # 0.1 消失
assert 30 in amounts # 0.3 还在,同天仍可提
# 新人档不参与"选一个额度":常规三档全部仍可提
regular = {t["amount_cents"]: t for t in tiers if not t["is_newbie"]}
assert all(regular[a]["available"] for a in (50, 1000, 2000))
r = _withdraw(client, token, 30) # 同天 0.3 照提
assert r.status_code == 200, r.text
_reject(r.json()["out_bill_no"])
# 0.1 已用过,重提 → 非法金额(档位已消失)
r = _withdraw(client, token, 10)
assert r.status_code == 400, r.text
def test_regular_daily_select_one_tier(client, monkeypatch) -> None:
"""当天提过 0.5 → 10/20 置灰 other_tier_selected,下单 409;0.5 还能继续提(3 次内)。"""
_patch_userinfo(monkeypatch, "openid_tier_3")
token = _login(client, "13800006003")
_seed_balances(client, token, "13800006003", cash=10_000)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = _withdraw(client, token, 50)
assert r.status_code == 200, r.text
_reject(r.json()["out_bill_no"]) # 结清活跃单;当天名额仍占("发起就算")
tiers = {t["amount_cents"]: t for t in _tiers(client, token)}
assert tiers[50]["available"] and tiers[50]["remaining_today"] == 2
assert not tiers[1000]["available"] and tiers[1000]["disabled_reason"] == "other_tier_selected"
assert not tiers[2000]["available"] and tiers[2000]["disabled_reason"] == "other_tier_selected"
r = _withdraw(client, token, 1000) # 选一额度互斥 → 409
assert r.status_code == 409, r.text
assert "今日额度已达上限" in r.json()["detail"]
r = _withdraw(client, token, 50) # 0.5 第 2 次照常
assert r.status_code == 200, r.text
def test_regular_daily_quota_exhausted(client, monkeypatch) -> None:
"""0.5 日 3 次:第 4 次 409;tiers 显示 quota_exhausted。"""
_patch_userinfo(monkeypatch, "openid_tier_4")
token = _login(client, "13800006004")
_seed_balances(client, token, "13800006004", cash=10_000)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
for _ in range(3):
r = _withdraw(client, token, 50)
assert r.status_code == 200, r.text
_reject(r.json()["out_bill_no"])
tiers = {t["amount_cents"]: t for t in _tiers(client, token)}
assert not tiers[50]["available"]
assert tiers[50]["disabled_reason"] == "quota_exhausted"
assert tiers[50]["remaining_today"] == 0
r = _withdraw(client, token, 50)
assert r.status_code == 409, r.text
def test_non_tier_amount_rejected_for_coin_cash(client, monkeypatch) -> None:
"""coin_cash 只能提预设档位:任意其他金额 400(防绕过客户端刷)。"""
_patch_userinfo(monkeypatch, "openid_tier_5")
token = _login(client, "13800006005")
_seed_balances(client, token, "13800006005", cash=10_000)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = _withdraw(client, token, 123)
assert r.status_code == 400, r.text
def test_invite_cash_not_gated_by_tiers(client, monkeypatch) -> None:
"""invite_cash 不走档位闸:非档位金额(200=2元)照常下单——邀请页行为不变。"""
_patch_userinfo(monkeypatch, "openid_tier_6")
token = _login(client, "13800006006")
_seed_balances(client, token, "13800006006", invite_cash=500)
client.post("/api/v1/wallet/bind-wechat", json={"code": "c"}, headers=_auth(token))
r = client.post(
"/api/v1/wallet/withdraw",
json={"amount_cents": 200, "source": "invite_cash"},
headers=_auth(token),
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "reviewing"