Compare commits

...

14 Commits

Author SHA1 Message Date
guke dca1bc0764 chore(alembic): 合并 pages_override 与领券成功率两个迁移 head,修复 upgrade head(单数)多 head 报错
pull main 带入 #126 的 admin_user_pages_override,与本分支领券成功率的
coupon_session_platform_success → coupon_claim_app_env 都从 admin_user_plain_password
分叉,形成两个 alembic head。多 head 会让部署/run.sh 用的 `alembic upgrade head`(单数)
报 "Multiple head revisions are present" 而中断。

本迁移为纯 merge:upgrade/downgrade 均空,不含任何表结构或数据改动,仅把两个 head
收敛为单 head(merge_pages_override_coupon_slot),使 `alembic upgrade head` 恢复正常。
2026-07-09 14:05:02 +08:00
guke f4b22e4eed Merge branch 'main' of https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server into feat/coupon-success-rate 2026-07-09 11:02:01 +08:00
liujiahui 0cf5b3816f feat(coin-history): 信息流广告奖励按点位场景拆流水文案(比价/领券) (#124)
## 改动
`grant_feed_reward` 按 `feed_scene` 落不同 `biz_type`/`remark`,让收益明细里比价等候期看的广告与领券时看的广告文案分开:

- `comparison` → `feed_ad_reward_comparison`(比价奖励)
- `coupon` → `feed_ad_reward_coupon`(领券奖励)
- 其余(welfare / 空 / 旧端不传)→ 维持通用 `feed_ad_reward`(信息流广告奖励)

客户端按 `biz_type` 直显固定文案(见 android 侧 `CoinHistoryViewModel.coinTitle`),`remark` 仅作后台留痕/兜底。

## 附:验收脚手架
`scripts/seed_coinhistory_labels_test.py`(dev-only):往测试号 `11111111111` 塞每种 bizType 各一条金币流水(`ref_id` 前缀 `TESTDOC`),一屏核对收益明细全部新文案;`--clean` 按前缀精确清理,不污染真实数据。

## 联动
客户端配套改动:`shaguabijia-app-android` 同名分支 `7-6ljh`。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #124
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-07-09 10:03:09 +08:00
liujiahui 9e082e6376 后台权限管理支持「自定义」角色(后端) (#126)
admin_user 加 pages_override(可空 JSON)+ Alembic 迁移;role==custom 时可见页由逐页勾选决定,否则跟随角色。
create/update 收 pages_override(切回普通角色自动清空),_validate_role 放行 custom 哨兵角色。
登录/me 下发有效页时优先用 override → 左侧导航按勾选即时生效。补 4 个用例,test_admin_roles 全绿。

配套前端 PR:shaguabijia-admin-web#admin-custom-perms。

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #126
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-07-09 01:09:03 +08:00
guke abd50c0b71 Merge branch 'main' of https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server into feat/coupon-success-rate 2026-07-08 16:02:01 +08:00
guke ffe2ba91fd feat(coupon-data): 领券数据看板加领券状态多选过滤
- coupon_data_report 加 statuses 参(status.in_ 过滤,与 app_env 同级),整视图联动
- 路由 /coupon-data 加多值 status 参(不传=全部);不影响 /coupons 按券表
- 新增 test_coupon_data_status_filter

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:00:59 +08:00
guke f984b07a0c feat(coupon-data): 领券成功率看板(整单/点位/分平台 + 按券)
- coupon_session.platform_success:整单成功率②/点位成功率③ + 分平台点位成功率
- coupon_claim_record.app_env:按 coupon_id「按券成功率」聚合 + 子端点 /admin/api/coupon-data/coupons
- /step 复用 record_claims 同一 SessionLocal 写入(fire-and-forget),pricebot 转发/负载不变
- 迁移 coupon_session_platform_success + coupon_claim_app_env;新增 14 测试
- 设计见 docs/guides/领券成功率指标-设计与埋点.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:26:52 +08:00
zhuzihao 93cb283c1b feat(marquee): 真实条不按用户去重(打乱+去连簇)+ 后台按模式分页浏览可展示记录 (#123)
- get_feed 真实条不再按 user 去重:改「洗牌 + 去连簇」(相邻尽量不同用户、减少单人连刷),
  同一用户可多条露出但被打散、尽量不连续。_shuffle_declustered 支持传入固定种子 rng。
- 新增 GET /admin/api/marquee-seeds/real-records:按**当前模式**分页浏览全部可展示记录(不去重),
  供运营逐页审核——只真实=真实记录;只种子=各启用种子按生成逻辑各出一行;混播=真实+种子。
  固定种子洗牌+去连簇 → 排列恒定、翻页稳定、能翻遍全部;含 OpsRealRecord* schema。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: zzhyyyyy <2685922758@qq.com>
Reviewed-on: #123
Co-authored-by: zhuzihao <zhuzihao@wonderable.ai>
Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
2026-07-08 00:57:00 +08:00
guke a43e082391 fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计) (#125)
世睿之前mr未合并的逻辑
比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

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

---------

Co-authored-by: guke <guke@autohome.com.cn>
Reviewed-on: #125
2026-07-07 18:29:23 +08:00
guke e11ad9449c revert 6ec4cfb4be
revert fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计)

比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:05:33 +08:00
guke 6ec4cfb4be fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计)
比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:02:21 +08:00
guke a52fc1973f revert 58b59c264d
revert fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计)

比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:52:57 +08:00
guke 58b59c264d fix(dashboard): 比价/领券奖励金币按 feed_scene 汇总(修复恒 0 + 激励视频双计)
比价/领券奖励金币此前查 coin_transaction.biz_type in (comparison/coupon...),
但这些 biz_type 全站从未写入——比价/领券信息流广告金币实际记为 feed_ad_reward、
场景区分在 ad_feed_reward_record.feed_scene——故两卡恒 0;领券桶还误含
reward_video/ad_reward,把激励视频金币双计进领券。

改为:comparison/coupon 奖励金币 = biz_type 桶(历史空、留作兜底)+ 按
ad_feed_reward_record.feed_scene 的 granted 实发金币(reward_date 北京自然日窗口);
reward_video/ad_reward 拆成独立 REWARD_VIDEO_BIZ_TYPES,不再混入领券,
REGULAR_TASK_EXCLUDED_BIZ_TYPES 保持不变。

测试:tests/test_admin_read.py 加 3 个用例(比价/领券按 feed_scene 汇总、
too_short 不计、领券排除激励视频);全量 pytest 除 5 个既有失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:46:09 +08:00
Ghost 0e149c83e7 修复美团 CPS 订单 pay_time 入库为空导致大盘美团收益漏算 (#119)
Co-authored-by: guke <guke@autohome.com.cn>
Co-authored-by: 陈世睿 <2839904623@qq.com>
Reviewed-on: #119
Co-authored-by: Ghost <>
Co-committed-by: Ghost <>
2026-07-07 17:11:00 +08:00
37 changed files with 2062 additions and 67 deletions
@@ -0,0 +1,35 @@
"""admin_user 加 pages_override 列(「自定义」权限:按人存专属可见页 key 列表)
权限管理页新增「自定义」角色:选它时该成员的可见页不跟随任何共享角色,而由逐页勾选决定,
存这个人专属的一份页 key 列表。仅 role == "custom" 时有效;普通角色为 None(可见页跟随角色)。
PG 用 JSONB,SQLite 退化为通用 JSON(同 admin_audit_log.detail / comparison_record.raw_payload)。
Revision ID: admin_user_pages_override
Revises: admin_user_plain_password
Create Date: 2026-07-08 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from alembic import op
revision: str = "admin_user_pages_override"
down_revision: str | Sequence[str] | None = "admin_user_plain_password"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# 与 app/models/admin.py 的 _JSON 一致:PG JSONB / 其它 JSON
_JSON = sa.JSON().with_variant(JSONB(), "postgresql")
def upgrade() -> None:
with op.batch_alter_table("admin_user", schema=None) as batch_op:
batch_op.add_column(sa.Column("pages_override", _JSON, nullable=True))
def downgrade() -> None:
with op.batch_alter_table("admin_user", schema=None) as batch_op:
batch_op.drop_column("pages_override")
+32
View File
@@ -0,0 +1,32 @@
"""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")
@@ -0,0 +1,37 @@
"""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")
@@ -0,0 +1,28 @@
"""合并两个 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 改动。"""
+3
View File
@@ -9,6 +9,9 @@ super_admin 为内建全权角色,恒可见全部页(effective_pages 特判)。
from __future__ import annotations
SUPER_ADMIN_ROLE = "super_admin"
# 「自定义」哨兵角色:不是 admin_role 表里的行,而是标记「这个人的可见页由 pages_override 决定」。
# admin_user.role == CUSTOM_ROLE 时,有效可见页取 admin_user.pages_override(见 auth._admin_out_with_pages)。
CUSTOM_ROLE = "custom"
# 分组镜像前端导航(app/(main)/layout.tsx 的 NAV_GROUPS);key = 路由一级
PERMISSION_CATALOG: list[dict] = [
+22
View File
@@ -385,6 +385,27 @@ def ad_revenue_report(
for k, v in type_map.items()
}
# 分场景小计(按 feed_scene:展示条数 + 预估收益),同 type_stats 基于全量 events——
# 供数据大盘「领券广告 / 比价广告」卡用。此前大盘是在分页 items 里按 feed_scene 现算,
# 2026-07-02 起信息流逐条展示行(唯一带收益 + 场景的行)不再进主表 items,现算恒为 0;
# 改为服务端在全量上聚合下发(也顺带不受 limit 分页截断影响)。feed_scene 为空(激励视频 /
# 旧数据)不计入任何场景桶。
scene_map: dict[str, dict] = {}
for e in events:
sc = e.get("feed_scene")
if not sc:
continue
s = scene_map.get(sc)
if s is None:
s = {"impressions": 0, "revenue_yuan": 0.0}
scene_map[sc] = s
s["impressions"] += e["impressions"]
s["revenue_yuan"] += e["revenue_yuan"]
scene_stats = {
k: {"impressions": v["impressions"], "revenue_yuan": round(v["revenue_yuan"], 6)}
for k, v in scene_map.items()
}
# DAU:复用数据大盘活跃用户口径(登录 + 开始比价 + 开始领券,按用户去重),按所选日期区间
# 统计(含今日),历史 / 多天区间同样有值。ARPU = 区间预估收益 ÷ 区间活跃用户。全局口径,
# 不随 user / ad_type / feed_scene / app_env 筛选变化(活跃用户口径无这些维度)。
@@ -418,6 +439,7 @@ def ad_revenue_report(
"daily": daily,
"hourly": hourly,
"type_stats": type_stats,
"scene_stats": scene_stats,
"dau": dau,
"items": main_rows[offset:offset + limit],
}
+4 -1
View File
@@ -26,14 +26,17 @@ def create_admin(
password: str,
role: str = "operator",
plain_password: str | None = None,
pages_override: list[str] | None = None,
) -> AdminUser:
"""建管理员。plain_password 非空则额外留存明文(后台 UI 建的账号传,供权限管理页复看);
脚本/起后台建账号不传(留 None → 前端不显示密码)。"""
脚本/起后台建账号不传(留 None → 前端不显示密码)。
pages_override:role=="custom" 时传专属可见页 key 列表;普通角色为 None。"""
admin = AdminUser(
username=username,
password_hash=hash_password(password),
role=role,
plain_password=plain_password,
pages_override=pages_override,
)
db.add(admin)
db.commit()
+101 -3
View File
@@ -5,17 +5,20 @@
- 发起数 = 区间内全部 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, date as _date, datetime
from datetime import UTC, datetime
from datetime import date as _date
from sqlalchemy import func, or_, select
from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session
from app.core import rewards
from app.models.coupon_state import CouponSession
from app.models.coupon_state import CouponClaimRecord, CouponSession
from app.models.user import User
from app.repositories.coupon_state import DEFAULT_PLATFORMS, coupon_id_to_platform
def _cn_hour(dt: datetime) -> int:
@@ -42,6 +45,46 @@ 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) -> dict:
"""CouponSession ORM → 明细行 dict(主表「领券数据」与「用户全部领券」抽屉共用)。"""
return {
@@ -84,6 +127,7 @@ 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,
@@ -93,6 +137,8 @@ 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"
@@ -115,6 +161,8 @@ 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())
@@ -131,6 +179,7 @@ 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),
}
# ── 按天趋势(柱=发起/完成数,线=平均耗时)──
@@ -223,3 +272,52 @@ def coupon_user_records(db: Session, *, user_id: int, limit: int = 100) -> dict:
select(func.count()).select_from(CouponSession).where(CouponSession.user_id == user_id)
).scalar_one()
return {"items": [_session_to_row(r) for r in rows], "total": int(total)}
_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}
+21 -5
View File
@@ -51,7 +51,27 @@ def _yuan_to_cents(v: object) -> int | None:
def _ts_to_dt(ts: object) -> datetime | None:
"""秒级时间戳 → tz-aware UTC datetime(绝对时刻,前端按北京展示)。"""
if not ts:
if ts is None:
return None
if isinstance(ts, datetime):
return ts if ts.tzinfo else ts.replace(tzinfo=_BJ_TZ).astimezone(timezone.utc)
s = str(ts).strip()
if not s or s.lower() == "null":
return None
try:
seconds = float(Decimal(s))
except (InvalidOperation, ValueError):
return None
if seconds == 0:
return None
# 美团文档是秒级时间戳,这里顺手兼容毫秒/微秒,避免上游格式变化导致时间再次落空。
if abs(seconds) > 10_000_000_000_000:
seconds /= 1_000_000
elif abs(seconds) > 10_000_000_000:
seconds /= 1_000
try:
return datetime.fromtimestamp(seconds, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
return None
@@ -83,10 +103,6 @@ def _pick(row: dict[str, Any], *keys: str) -> Any:
if key in row and row[key] is not None:
return row[key]
return None
try:
return datetime.fromtimestamp(int(ts), tz=timezone.utc)
except (ValueError, OSError, TypeError):
return None
# ───────────── 群 ─────────────
+116 -4
View File
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
from sqlalchemy import Select, asc, case, desc, func, or_, select
from sqlalchemy.orm import Session
from app.admin.repositories.stats import COMPARE_START_EVENT, COUPON_START_EVENT
from app.core import rewards
from app.core.config import settings
from app.models.ad_feed_reward import AdFeedRewardRecord
@@ -18,6 +19,7 @@ from app.models.ad_reward import AdRewardRecord
from app.models.admin import AdminAuditLog
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponPromptEngagement
from app.models.device import DeviceLiveness
from app.models.feedback import Feedback
from app.models.onboarding import OnboardingCompletion
@@ -31,6 +33,9 @@ from app.models.wallet import (
WithdrawOrder,
)
# 「最近活跃」计入的行为事件(与大盘 DAU/留存活跃口径一致:开始比价 + 开始领券)
_ACTIVE_EVENTS = (COMPARE_START_EVENT, COUPON_START_EVENT)
# 折算成可提现现金时,非广告金币来源的排除集(广告单独统计、人工调整不算"赚取")
_NON_TASK_BIZ_TYPES = ("reward_video", "feed_ad_reward", "admin_grant", "admin_deduct")
@@ -82,6 +87,86 @@ def offset_paginate(
return items, next_cursor, total
def _last_active_parts():
"""「最近活跃」的两个按 user_id 预聚合派生表(最近开始比价/领券事件、最近领券发起)。
活跃口径与大盘 DAU/留存一致(2026-07-05 产品定:进入 App≈登录 last_login_at +
发起比价 real_compare_start + 发起领券 real_coupon_start/claim_started)。
用 LEFT JOIN 预聚合而非相关标量子查询:后者在 PG 上对 users 每行各跑一个 SubPlan
(排序键、range 筛选、offset_paginate 的 count 三处叠加),埋点表大了会拖垮列表接口;
预聚合借 analytics_event.event 索引只扫两类 start 事件,每次查询聚合一次。
"""
ev_agg = (
select(
AnalyticsEvent.user_id.label("user_id"),
func.max(AnalyticsEvent.created_at).label("last_at"),
)
.where(
AnalyticsEvent.user_id.is_not(None),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
)
.group_by(AnalyticsEvent.user_id)
.subquery()
)
eng_agg = (
select(
CouponPromptEngagement.user_id.label("user_id"),
func.max(CouponPromptEngagement.created_at).label("last_at"),
)
.where(
CouponPromptEngagement.user_id.is_not(None),
CouponPromptEngagement.engage_type == "claim_started",
)
.group_by(CouponPromptEngagement.user_id)
.subquery()
)
return ev_agg, eng_agg
def _norm_utc(dt: datetime | None) -> datetime | None:
"""naive 视为 UTC 补 tzinfo(SQLite 读回 naive、PG 读回 aware,混着 max() 会 TypeError)。"""
if dt is None:
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
def _attach_last_active(db: Session, users: list[User]) -> None:
"""给本页用户瞬态挂 last_active_at(非 DB 列,供 AdminUserListItem from_attributes 读)。
口径同 [_last_active_expr];按本页 user_id 批量两次 GROUP BY,防 N+1。
"""
uids = [u.id for u in users]
if not uids:
return
ev_map = dict(
db.execute(
select(AnalyticsEvent.user_id, func.max(AnalyticsEvent.created_at))
.where(
AnalyticsEvent.user_id.in_(uids),
AnalyticsEvent.event.in_(_ACTIVE_EVENTS),
)
.group_by(AnalyticsEvent.user_id)
).all()
)
eng_map = dict(
db.execute(
select(CouponPromptEngagement.user_id, func.max(CouponPromptEngagement.created_at))
.where(
CouponPromptEngagement.user_id.in_(uids),
CouponPromptEngagement.engage_type == "claim_started",
)
.group_by(CouponPromptEngagement.user_id)
).all()
)
for u in users:
candidates = [
_norm_utc(u.last_login_at),
_norm_utc(ev_map.get(u.id)),
_norm_utc(eng_map.get(u.id)),
]
u.last_active_at = max((c for c in candidates if c is not None), default=None)
def list_users(
db: Session,
*,
@@ -93,16 +178,34 @@ def list_users(
created_to: datetime | None = None,
last_login_from: datetime | None = None,
last_login_to: datetime | None = None,
last_active_from: datetime | None = None,
last_active_to: datetime | None = None,
sort_by: str = "id",
sort_order: str = "desc",
limit: int = 20,
cursor: int | None = None,
) -> tuple[list[User], int | None, int]:
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选,
按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
"""用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录·最近活跃
时间范围筛选,按 id·注册时间·最近登录·最近活跃排序;每页附带计算列 last_active_at
(口径见 [_last_active_expr])。**offset 分页**(cursor=offset):任意列排序下游标语义统一,
代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。
日期入参统一转 tz-aware UTC 比较(列为 timestamptz,见 _as_utc)。"""
stmt = select(User)
# 最近活跃 = max(最近登录, 最近行为事件, 最近领券发起)。PG 用 GREATEST;SQLite 标量 max()
# 任一参数 NULL 即返回 NULL,故 LEFT JOIN 未命中侧 coalesce 到 last_login_at 兜底
# (注册即登录,该列恒非空)。派生表 1:1(按 user_id 聚合),outerjoin 不会放大行数,
# offset_paginate 的 count 不受影响。
ev_agg, eng_agg = _last_active_parts()
greatest = func.greatest if db.get_bind().dialect.name == "postgresql" else func.max
last_active = greatest(
User.last_login_at,
func.coalesce(ev_agg.c.last_at, User.last_login_at),
func.coalesce(eng_agg.c.last_at, User.last_login_at),
)
stmt = (
select(User)
.outerjoin(ev_agg, ev_agg.c.user_id == User.id)
.outerjoin(eng_agg, eng_agg.c.user_id == User.id)
)
if phone:
stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配
if register_channel:
@@ -119,16 +222,25 @@ def list_users(
stmt = stmt.where(User.last_login_at >= _as_utc(last_login_from))
if last_login_to is not None:
stmt = stmt.where(User.last_login_at <= _as_utc(last_login_to))
if last_active_from is not None:
stmt = stmt.where(last_active >= _as_utc(last_active_from))
if last_active_to is not None:
stmt = stmt.where(last_active <= _as_utc(last_active_to))
sort_cols = {
"id": User.id,
"created_at": User.created_at,
"last_login_at": User.last_login_at,
"last_active_at": last_active,
}
sort_col = sort_cols.get(sort_by, User.id)
order_fn = asc if sort_order == "asc" else desc
id_order = asc(User.id) if sort_order == "asc" else desc(User.id)
return offset_paginate(db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor)
items, next_cursor, total = offset_paginate(
db, stmt, (order_fn(sort_col), id_order), limit=limit, cursor=cursor
)
_attach_last_active(db, items)
return items, next_cursor, total
def _attach_user_info(db: Session, records: list[ComparisonRecord | Feedback | PriceReport]) -> None:
+167 -23
View File
@@ -5,17 +5,23 @@ user.last_login_at / comparison_record.status / withdraw_order.status)要加索
"""
from __future__ import annotations
from collections import Counter
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal, InvalidOperation
from sqlalchemy import func, select
from sqlalchemy import case, func, select
from sqlalchemy.orm import Session
from app.admin.repositories.coupon_data import _percentile
from app.models.ad_feed_reward import AdFeedRewardRecord
from app.models.ad_reward import AdRewardRecord
from app.models.analytics_event import AnalyticsEvent
from app.models.comparison import ComparisonRecord
from app.models.coupon_state import CouponPromptEngagement
from app.models.coupon_state import (
CouponClaimRecord,
CouponPromptEngagement,
CouponSession,
)
from app.models.cps_order import CpsOrder
from app.models.feedback import Feedback
from app.models.savings import SavingsRecord
@@ -24,11 +30,17 @@ from app.models.user import User
from app.models.wallet import CoinTransaction, WithdrawOrder
_BEIJING = timezone(timedelta(hours=8))
COUPON_REWARD_BIZ_TYPES = ("reward_video", "ad_reward", "coupon", "coupon_reward")
REWARD_VIDEO_BIZ_TYPES = ("reward_video", "ad_reward")
# 领券/比价奖励金币的真实来源是信息流广告发奖(ad_feed_reward_record,按 feed_scene 分场景);
# coin_transaction 里只有扁平的 feed_ad_reward、biz_type 不分 coupon/comparison,故这俩桶历史从未
# 被写入,仅留作未来兜底,实际金额在下方按 feed_scene 汇总 ad_feed_reward_record 得出。reward_video/
# ad_reward 是激励视频,单独成桶、不再混进领券奖励(历史误并会把激励视频金币双计进领券)。
COUPON_REWARD_BIZ_TYPES = ("coupon", "coupon_reward")
COMPARISON_REWARD_BIZ_TYPES = ("comparison", "compare_reward", "comparison_reward")
EXCLUDED_REWARD_BIZ_TYPES = ("invite_inviter", "invite_invitee", "admin_grant")
UNCLASSIFIED_FEED_BIZ_TYPES = ("feed_ad_reward",)
REGULAR_TASK_EXCLUDED_BIZ_TYPES = (
*REWARD_VIDEO_BIZ_TYPES,
*COUPON_REWARD_BIZ_TYPES,
*COMPARISON_REWARD_BIZ_TYPES,
*EXCLUDED_REWARD_BIZ_TYPES,
@@ -299,12 +311,12 @@ def dashboard_overview(
period_from=period_from,
period_to=period_to,
)
period_retained_new_user_ids = period_new_user_ids & period_active_user_ids
period_retention_rate = (
round(len(period_retained_new_user_ids) / len(period_new_user_ids), 4)
if period_new_user_ids
else None
)
# 留存口径(2026-07-05 产品改):次日留存——窗口内每天 D,取 **D-1 日(前日)新增**用户,
# 统计其 D 日活跃(登录/开始比价/开始领券)比例,逐日累加。默认窗口=昨日单天,即
# 「前日新增用户的昨日留存」。原口径(窗口内新增∩窗口内活跃)在单日窗口下≈100% 无意义
# (注册即登录,当天新增必然当天活跃)。逐日 cohort 在下方 trend 循环内顺带累计。
retention_cohort_total = 0
retention_retained_total = 0
trend_points: list[dict] = []
for cur_date in _date_range(period_from, period_to):
day_start_utc, day_end_utc, day_start_local, day_end_local = _period_bounds(
@@ -314,18 +326,26 @@ def dashboard_overview(
ComparisonRecord.created_at >= day_start_local,
ComparisonRecord.created_at < day_end_local,
)
daily_active_user_ids = _period_active_user_ids(
db,
start_utc=day_start_utc,
end_utc=day_end_utc,
period_from=cur_date,
period_to=cur_date,
)
# 次日留存:cohort = 前一日(D-1)新增用户,留存 = 其中当日(D)活跃者(口径见上)。
cohort_ids = _user_id_set(
select(User.id).where(
User.created_at >= day_start_utc - timedelta(days=1),
User.created_at < day_end_utc - timedelta(days=1),
)
)
retention_cohort_total += len(cohort_ids)
retention_retained_total += len(cohort_ids & daily_active_user_ids)
trend_points.append(
{
"date": cur_date,
"active_users": len(
_period_active_user_ids(
db,
start_utc=day_start_utc,
end_utc=day_end_utc,
period_from=cur_date,
period_to=cur_date,
)
),
"active_users": len(daily_active_user_ids),
"new_users": _count(
User,
User.created_at >= day_start_utc,
@@ -334,6 +354,11 @@ def dashboard_overview(
"comparisons": _count(ComparisonRecord, *daily_comparison_conds),
}
)
period_retention_rate = (
round(retention_retained_total / retention_cohort_total, 4)
if retention_cohort_total
else None
)
period_coin_conds = (
CoinTransaction.created_at >= start_local,
@@ -343,7 +368,7 @@ def dashboard_overview(
period_reward_video_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
)
period_feed_ad_coin_total = _sum(
CoinTransaction.amount,
@@ -365,15 +390,29 @@ def dashboard_overview(
*period_coin_conds,
CoinTransaction.biz_type.like("task_%"),
)
# 领券/比价奖励金币 = biz_type 桶(历史空,兜底)+ 该场景信息流广告实发金币
# (ad_feed_reward_record.feed_scene,granted;reward_date 是北京日期串,与 period 同自然日窗口)。
period_coupon_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COUPON_REWARD_BIZ_TYPES),
) + _sum(
AdFeedRewardRecord.coin,
AdFeedRewardRecord.status == "granted",
AdFeedRewardRecord.feed_scene == "coupon",
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
)
period_comparison_reward_coin_total = _sum(
CoinTransaction.amount,
*period_coin_conds,
CoinTransaction.biz_type.in_(COMPARISON_REWARD_BIZ_TYPES),
) + _sum(
AdFeedRewardRecord.coin,
AdFeedRewardRecord.status == "granted",
AdFeedRewardRecord.feed_scene == "comparison",
AdFeedRewardRecord.reward_date >= period_from.isoformat(),
AdFeedRewardRecord.reward_date <= period_to.isoformat(),
)
period_regular_task_coin_total = _sum(
CoinTransaction.amount,
@@ -417,6 +456,98 @@ def dashboard_overview(
else None
)
# ===== 领券核心数据(2026-07-05 产品新增)=====
# 数据源:coupon_session(一次领券一行,started_date 北京自然日)+ coupon_claim_record
# (一券/点位一天一条终态,claim_date 北京自然日)。点位与 session 不按 trace_id 关联——
# record_claims 更新路径不覆盖 trace_id(同设备同券同日重跑归第一次的 trace),按
# (device_id, 自然日) 桶关联才可靠;同桶多次发起共享同一份点位终态。
period_coupon_sessions = db.execute(
select(
CouponSession.device_id,
CouponSession.started_date,
CouponSession.status,
CouponSession.elapsed_ms,
).where(
CouponSession.started_date >= period_from,
CouponSession.started_date <= period_to,
# 只统计正式环境,同「领券数据」页默认口径(防 debug 包调试数据串台;
# 命中 ix_coupon_session_date_env)。点位表无 app_env 列,但点位指标只经
# 下方 prod session 触达的 (device, 日) 桶进入统计,随之收敛到 prod。
CouponSession.app_env == "prod",
)
).all()
coupon_started = len(period_coupon_sessions)
coupon_completed_elapsed = sorted(
s.elapsed_ms
for s in period_coupon_sessions
if s.status == "completed" and s.elapsed_ms is not None
)
# 点位桶:(device, 日) → (点位总数, 成功点位数)。成功口径与「我的」页累计领券一致
# (sum_claimed_count,2026-06-15 产品定):success + already_claimed(已领过=持有券)都算成功。
point_buckets: dict[tuple[str, date], tuple[int, int]] = {
(dev, d): (int(total), int(succ or 0))
for dev, d, total, succ in db.execute(
select(
CouponClaimRecord.device_id,
CouponClaimRecord.claim_date,
func.count(),
func.sum(
case(
(CouponClaimRecord.status.in_(("success", "already_claimed")), 1),
else_=0,
)
),
)
.where(
CouponClaimRecord.claim_date >= period_from,
# 上界放宽一天:跨零点场次(23:5x 发起)的点位 claim_date 落在发起日+1,
# 桶只经下方 session 触达的键参与计数,放宽不会引入无关数据。
CouponClaimRecord.claim_date <= period_to + timedelta(days=1),
)
.group_by(CouponClaimRecord.device_id, CouponClaimRecord.claim_date)
).all()
}
# 全部领成功的次数:completed 且其 (device, 日) 桶内点位全部成功(桶为空不算)。
coupon_all_success = 0
completed_bucket_totals: list[int] = []
session_bucket_keys: set[tuple[str, date]] = set()
for s in period_coupon_sessions:
key = (s.device_id, s.started_date)
if key not in point_buckets:
# 跨零点回退:发起日桶不存在(点位终态全部落在次日)时取 (device, 发起日+1)。
# 仅在发起日桶完全缺失时回退,避免抢占该设备次日 session 自己的桶。
next_key = (s.device_id, s.started_date + timedelta(days=1))
if next_key in point_buckets:
key = next_key
bucket = point_buckets.get(key)
if bucket is not None:
session_bucket_keys.add(key)
if s.status != "completed" or bucket is None:
continue
total, succ = bucket
completed_bucket_totals.append(total)
if total > 0 and succ == total:
coupon_all_success += 1
# 每次发起的应领点位数:取「完成过的领券」实际点位数的众数(done 帧会给所有点位终态,
# 完成场的点位数=当前配置的全量点位数;数据自校准,配置改点位数无需改代码)。本期无完成场
# 时给不出,点位成功率置空。
coupon_points_per_session = (
Counter(completed_bucket_totals).most_common(1)[0][0]
if completed_bucket_totals
else None
)
# 成功点位数:本期 session 触达过的 (device, 日) 桶内成功点位之和(桶级去重,同桶重试不重复计)。
coupon_point_success = sum(point_buckets[k][1] for k in session_bucket_keys)
# 点位成功率 = 成功点位数 / (发起数 × 应领点位数):中途退出未跑到的点位不产生记录,
# 但发起数×点位数把它们计入分母 → 视为失败,符合产品口径;重试会拉低该率(分母按次数计)。
coupon_point_success_rate = (
round(
min(1.0, coupon_point_success / (coupon_started * coupon_points_per_session)), 4
)
if coupon_started and coupon_points_per_session
else None
)
return {
"users": {
"total": _count(User),
@@ -432,7 +563,7 @@ def dashboard_overview(
"reward_video_coin_total": _sum(
CoinTransaction.amount,
CoinTransaction.amount > 0,
CoinTransaction.biz_type.in_(("reward_video", "ad_reward")),
CoinTransaction.biz_type.in_(REWARD_VIDEO_BIZ_TYPES),
),
"reward_video_watch_count": _count(
AdRewardRecord,
@@ -480,11 +611,13 @@ def dashboard_overview(
"users": {
"new": len(period_new_user_ids),
"active": len(period_active_user_ids),
"retained_new_users": len(period_retained_new_user_ids),
"retained_new_users": retention_retained_total,
"retention_cohort": retention_cohort_total,
"retention_rate": period_retention_rate,
"retention_note": (
"口径:登录(last_login_at)+开始比价(real_compare_start)+"
"开始领券(real_coupon_start/claim_started),按用户去重"
"次日留存:窗口内每天取前一日新增用户,统计其当日活跃"
"(登录/开始比价/开始领券,按用户去重)比例,逐日累加;"
"默认窗口=昨日,即前日新增用户的昨日留存"
),
},
"comparison": {
@@ -495,6 +628,17 @@ def dashboard_overview(
"average_duration_ms": period_avg_duration_ms,
"average_saved_cents": period_avg_saved_cents,
},
"coupon": {
"started": coupon_started,
"all_success": coupon_all_success,
"success_rate": (
round(coupon_all_success / coupon_started, 4) if coupon_started else None
),
"point_success": coupon_point_success,
"points_per_session": coupon_points_per_session,
"point_success_rate": coupon_point_success_rate,
"median_elapsed_ms": _percentile(coupon_completed_elapsed, 50),
},
"coins": {
"granted_total": _sum(CoinTransaction.amount, *period_coin_conds),
"reward_video_coin_total": period_reward_video_coin_total,
+1
View File
@@ -91,6 +91,7 @@ def get_ad_revenue_report(
daily=[AdRevenueDaily(**d) for d in result["daily"]],
hourly=[AdRevenueHourly(**h) for h in result["hourly"]],
type_stats={k: AdRevenueTypeStat(**v) for k, v in result["type_stats"].items()},
scene_stats={k: AdRevenueTypeStat(**v) for k, v in result["scene_stats"].items()},
dau=result["dau"],
total=result["total"],
truncated=result["truncated"],
+28 -5
View File
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from app.admin.audit import write_audit
from app.admin.deps import AdminDb, CurrentAdmin, get_client_ip, require_role
from app.admin.permissions import SUPER_ADMIN_ROLE
from app.admin.permissions import CUSTOM_ROLE, SUPER_ADMIN_ROLE, sanitize_pages
from app.admin.repositories import admin_role as role_repo
from app.admin.repositories import admin_user as admin_repo
from app.admin.schemas.admin import AdminCreateRequest, AdminUpdateRequest
@@ -26,14 +26,22 @@ def _active_super_count(db: AdminDb) -> int:
def _validate_role(db: AdminDb, role: str) -> None:
"""角色必须是 super_admin admin_role 表里已存在的角色,否则 400。"""
if role == SUPER_ADMIN_ROLE:
"""角色必须是 super_admin / custom(自定义) / admin_role 表里已存在的角色,否则 400。"""
if role in (SUPER_ADMIN_ROLE, CUSTOM_ROLE):
return
role_repo.ensure_builtin_roles(db) # 空表(测试/全新库)兜底播种,再校验
if role_repo.get_role(db, role) is None:
raise HTTPException(status_code=400, detail=f"角色不存在: {role}")
def _clean_override(role: str, pages_override: list[str] | None) -> list[str] | None:
"""按最终角色算出该存的 pages_override:custom → 勾选集(过滤非法 key,可空列表);
非 custom → None(切回普通角色即清空自定义页)。"""
if role == CUSTOM_ROLE:
return sanitize_pages(pages_override)
return None
@router.get("", response_model=list[AdminOut], summary="管理员列表(含明文密码,super_admin 专属)")
def list_admins(db: AdminDb) -> list[AdminOut]:
out: list[AdminOut] = []
@@ -51,13 +59,16 @@ def create_admin(
if admin_repo.get_by_username(db, body.username) is not None:
raise HTTPException(status_code=409, detail="用户名已存在")
_validate_role(db, body.role)
override = _clean_override(body.role, body.pages_override)
new = admin_repo.create_admin(
db, username=body.username, password=body.password, role=body.role,
plain_password=body.password, # UI 建的账号留存明文,供权限管理页复看
pages_override=override,
)
write_audit(
db, admin, action="admin.create", target_type="admin", target_id=new.id,
detail={"username": new.username, "role": new.role}, ip=get_client_ip(request), commit=True,
detail={"username": new.username, "role": new.role, "pages_override": override},
ip=get_client_ip(request), commit=True,
)
return AdminOut.model_validate(new)
@@ -89,7 +100,8 @@ def update_admin(
_validate_role(db, body.role)
changes: dict = {}
if body.role is not None and body.role != target.role:
role_changed = body.role is not None and body.role != target.role
if role_changed:
changes["role"] = {"before": target.role, "after": body.role}
target.role = body.role
if body.status is not None and body.status != target.status:
@@ -99,6 +111,17 @@ def update_admin(
changes["password"] = "reset"
target.password_hash = hash_password(body.password)
target.plain_password = body.password # 同步留存明文,权限管理页复看保持一致
# 自定义可见页:按「最终角色」(target.role,已应用完角色变更)决定该存什么。
# - 切到/维持 custom 且传了 pages_override → 用勾选集;切到 custom 没传 → 清成空列表。
# - 切回普通角色 → 清空 override(_clean_override 返回 None)。
# - 角色没变、只传 pages_override(编辑现有 custom 用户的勾选)→ 也更新。
if role_changed or body.pages_override is not None:
new_override = _clean_override(target.role, body.pages_override)
if new_override != target.pages_override:
changes["pages_override"] = {"before": target.pages_override, "after": new_override}
target.pages_override = new_override
if not changes:
raise HTTPException(status_code=400, detail="无任何变更字段")
db.commit()
+7 -2
View File
@@ -6,6 +6,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException
from app.admin.deps import AdminDb, CurrentAdmin
from app.admin.permissions import CUSTOM_ROLE, sanitize_pages
from app.admin.repositories import admin_role as role_repo
from app.admin.repositories import admin_user as admin_repo
from app.admin.schemas.auth import AdminLoginRequest, AdminLoginResponse, AdminOut
@@ -19,9 +20,13 @@ router = APIRouter(prefix="/admin/api/auth", tags=["admin-auth"])
def _admin_out_with_pages(admin, db: AdminDb) -> AdminOut: # noqa: ANN001
"""AdminOut + 当前角色有效可见页(前端左侧导航按此过滤)。"""
"""AdminOut + 有效可见页(前端左侧导航按此过滤)。
role=="custom" → 用这个人的 pages_override(按人自定义,过滤悬空 key);其余走角色解析。"""
out = AdminOut.model_validate(admin)
out.pages = role_repo.effective_pages_of(db, admin.role)
if admin.role == CUSTOM_ROLE:
out.pages = sanitize_pages(admin.pages_override)
else:
out.pages = role_repo.effective_pages_of(db, admin.role)
return out
+36 -1
View File
@@ -18,6 +18,8 @@ from app.admin.schemas.coupon_data import (
CouponDataOut,
CouponDataRow,
CouponDataSummary,
CouponSlotRow,
CouponSlotsOut,
CouponUserRecordsOut,
)
from app.core.rewards import cn_today
@@ -52,6 +54,10 @@ 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",
@@ -73,7 +79,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, granularity=granularity,
user=user, app_env=env, statuses=status, granularity=granularity,
limit=limit, offset=offset, sort=sort,
)
return CouponDataOut(
@@ -87,6 +93,35 @@ 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,
+18
View File
@@ -19,6 +19,8 @@ from app.admin.schemas.ops_marquee_seed import (
OpsMarqueeSeedCreate,
OpsMarqueeSeedOut,
OpsMarqueeSeedUpdate,
OpsRealRecordItem,
OpsRealRecordsOut,
OpsSavingsFeedPreviewOut,
)
from app.models.admin import AdminUser
@@ -68,6 +70,22 @@ def preview_feed(
return OpsSavingsFeedPreviewOut(items=ops_marquee.get_feed(db, limit=limit, mode=mode))
@router.get("/real-records", response_model=OpsRealRecordsOut, summary="分页浏览当前模式下可展示的记录(审核用)")
def list_real_records(
db: AdminDb,
mode: Annotated[str | None, Query(description="mixed/real/seed;不传=当前持久化模式")] = None,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=50)] = 8,
) -> OpsRealRecordsOut:
"""分页列出**当前模式**下可在 app 轮播展示的全部记录(**不去重**):只真实=真实记录;只种子=各启用
种子按生成逻辑各出一行;混播=真实+种子。与 app 同口径洗牌+去连簇,固定种子→翻页稳定、能翻遍全部。
item.user_id=0 表示种子行。"""
if mode is not None and mode not in ops_marquee.FEED_MODES:
raise HTTPException(status_code=400, detail="mode 需为 mixed / real / seed")
items, total = ops_marquee.list_real_records(db, mode=mode, offset=offset, limit=limit)
return OpsRealRecordsOut(items=[OpsRealRecordItem(**it) for it in items], total=total)
# 注:/mode 两个端点须在 /{seed_id} 之前注册,否则 PATCH /mode 会被 /{seed_id} 抢先按 id 解析。
@router.get("/mode", summary="首页轮播数据源模式(mixed/real/seed)")
def get_feed_mode(db: AdminDb) -> dict:
+7 -1
View File
@@ -42,7 +42,12 @@ def list_users(
created_to: Annotated[datetime | None, Query()] = None,
last_login_from: Annotated[datetime | None, Query()] = None,
last_login_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id",
# 最近活跃(登录/发起比价/发起领券取最大,见 queries._last_active_expr)筛选与排序
last_active_from: Annotated[datetime | None, Query()] = None,
last_active_to: Annotated[datetime | None, Query()] = None,
sort_by: Annotated[
str, Query(pattern="^(id|created_at|last_login_at|last_active_at)$")
] = "id",
sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc",
limit: Annotated[int, Query(ge=1, le=100)] = 20,
cursor: Annotated[int | None, Query()] = None,
@@ -51,6 +56,7 @@ def list_users(
db, phone=phone, register_channel=register_channel, status=status,
nickname=nickname, created_from=created_from, created_to=created_to,
last_login_from=last_login_from, last_login_to=last_login_to,
last_active_from=last_active_from, last_active_to=last_active_to,
sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor,
)
return CursorPage(
+5
View File
@@ -136,6 +136,11 @@ class AdRevenueReportOut(BaseModel):
default_factory=dict,
description="按广告类型(ad_type)小计 {ad_type: {impressions, revenue_yuan}};前端取 draw / reward_video 做分类大盘",
)
scene_stats: dict[str, AdRevenueTypeStat] = Field(
default_factory=dict,
description="按信息流场景(feed_scene)小计 {comparison/coupon/welfare: {impressions, revenue_yuan}};"
"全量统计(不受分页截断),供数据大盘「领券广告 / 比价广告」卡;feed_scene 为空的事件不计入",
)
dau: int | None = Field(
None,
description="所选日期区间的去重活跃用户数(口径同数据大盘 period.users.active:登录 + 开始比价 + "
+5 -1
View File
@@ -13,14 +13,18 @@ class AdminCreateRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=8, max_length=72) # bcrypt ≤72 字节
role: str = Field("operator", min_length=1, max_length=32)
# 仅当 role == "custom":这个人专属可见页 key 列表(逐页勾选结果)。其余角色不传/忽略。
pages_override: list[str] | None = None
class AdminUpdateRequest(BaseModel):
"""改角色 / 启用禁用 / 重置密码,字段都可选(只改传了的)。"""
"""改角色 / 启用禁用 / 重置密码 / 自定义可见页,字段都可选(只改传了的)。"""
role: str | None = Field(None, min_length=1, max_length=32)
status: Literal["active", "disabled"] | None = None
password: str | None = Field(None, min_length=8, max_length=72)
# 改成/更新「自定义」可见页;role 切回普通角色时后端会清空 override(见路由)。
pages_override: list[str] | None = None
class AdminAuditLogOut(BaseModel):
+4 -1
View File
@@ -21,8 +21,11 @@ class AdminOut(BaseModel):
created_at: datetime
last_login_at: datetime | None = None
# 该管理员当前角色的有效可见页(= 左侧导航项 key);仅登录 / /me 填充,列表接口默认空。
# 前端据此过滤左侧导航(super_admin = 全部页)。见 app/admin/permissions.py。
# 前端据此过滤左侧导航(super_admin = 全部页;role=="custom" = pages_override)。见 permissions.py。
pages: list[str] = []
# 「自定义」可见页原始勾选集(role=="custom" 时非空);列表接口下发,供权限管理页编辑回填勾选。
# 普通角色为 None。from_attributes 自动从 ORM 列取。
pages_override: list[str] | None = None
# 明文登录密码:仅「管理员账号列表」(super_admin 专属路由)填充,供权限管理页编辑时复看;
# 无留存(脚本建的超管 / 旧账号)为 None → 前端不显示。登录 / /me 不下发(保持 None)。
password: str | None = None
+29
View File
@@ -19,6 +19,16 @@ 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):
@@ -81,3 +91,22 @@ 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]
+22
View File
@@ -43,7 +43,10 @@ class DashboardComparison(BaseModel):
class DashboardPeriodUsers(BaseModel):
new: int
active: int
# 次日留存(2026-07-05 起):retained_new_users = 窗口内逐日「前一日新增且当日活跃」用户数之和,
# retention_cohort = 对应的前一日新增基数之和,retention_rate = 两者之比。
retained_new_users: int
retention_cohort: int = 0
retention_rate: float | None = None
retention_note: str
@@ -57,6 +60,24 @@ class DashboardPeriodComparison(BaseModel):
average_saved_cents: int | None = None
class DashboardPeriodCoupon(BaseModel):
"""领券核心数据(2026-07-05 产品新增)。点位=一张券(coupon_claim_record 一天一条终态);
成功口径 success+already_claimed(我的页累计领券一致)"""
started: int = 0
# 全部领成功的次数:completed 且当日该设备全部点位成功
all_success: int = 0
success_rate: float | None = None
# 本期 session 触达的点位中成功的条数(同设备同日去重)
point_success: int = 0
# 每次发起的应领点位数(本期完成场实际点位数的众数;无完成场为空)
points_per_session: int | None = None
# 点位成功率 = point_success / (started × points_per_session);未跑到的点位计入分母视为失败
point_success_rate: float | None = None
# 耗时中位数(仅 completed 的 elapsed_ms,同「领券数据」页口径)
median_elapsed_ms: int | None = None
class DashboardPeriodCoins(BaseModel):
granted_total: int
reward_video_coin_total: int = 0
@@ -85,6 +106,7 @@ class DashboardPeriod(BaseModel):
date_to: date
users: DashboardPeriodUsers
comparison: DashboardPeriodComparison
coupon: DashboardPeriodCoupon = DashboardPeriodCoupon()
coins: DashboardPeriodCoins
cash: DashboardPeriodCash
trend: list[DashboardTrendPoint] = []
+13
View File
@@ -62,3 +62,16 @@ class OpsSavingsFeedPreviewItem(BaseModel):
class OpsSavingsFeedPreviewOut(BaseModel):
"""运营预览:实际混播出来的 feed(真实记录会插队,与客户端一致)。"""
items: list[OpsSavingsFeedPreviewItem]
class OpsRealRecordItem(BaseModel):
masked_user: str # 脱敏后展示名(与 app 一致)
saved_amount_cents: int # 节省金额(分)
created_at: str # 比价记录时间(YYYY-MM-DD HH:MM)
user_id: int # 真实 user_id(供运营核对,不下发客户端)
class OpsRealRecordsOut(BaseModel):
"""分页浏览「全部可展示的真实记录」(success+省>0,不去重、稳定顺序)。"""
items: list[OpsRealRecordItem]
total: int # 满足条件的真实记录总数(算页数用)
+3
View File
@@ -20,6 +20,9 @@ class AdminUserListItem(BaseModel):
wechat_nickname: str | None = None
created_at: datetime
last_login_at: datetime
# 最近活跃 = max(最近登录, 最近发起比价, 最近发起领券);列表页由 queries._attach_last_active
# 瞬态挂上。其他复用本 schema 的入口(用户 360 等)没挂该属性 → None(前端显示 '-')。
last_active_at: datetime | None = None
class AdminUserOverview(BaseModel):
+10 -1
View File
@@ -81,7 +81,16 @@ def _record_claims_blocking(
device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
) -> None:
with SessionLocal() as db:
coupon_repo.record_claims(db, device_id, user_id, trace_id, results)
# 取本次 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)
)
def _mark_completed_blocking(
+4 -1
View File
@@ -28,8 +28,11 @@ class AdminUser(Base):
# 明文登录密码:仅「后台 UI 创建/重置」的管理员留存,供超管在权限管理页复看转交。
# 脚本/起后台时建的超管账号不写(为 None → 前端「不显示密码」)。⚠️ 内部工具便利取舍,见 create/list。
plain_password: Mapped[str | None] = mapped_column(String(128), nullable=True)
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)
# super_admin(全权+管账号)/ finance(钱:提现+金币)/ operator(用户+反馈+大盘)/ custom(按人自定义)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="operator")
# 「自定义」权限:仅当 role == "custom" 时有效,存这个人专属的可见页 key 列表(不共享给他人)。
# 非 custom 用户为 None → 可见页跟随角色。登录/`/me` 下发有效页时,非空即优先用它(见 auth._admin_out_with_pages)。
pages_override: Mapped[list | None] = mapped_column(_JSON, nullable=True)
# active / disabled
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
+7
View File
@@ -66,6 +66,9 @@ 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)
@@ -240,6 +243,10 @@ 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)
+11 -2
View File
@@ -176,10 +176,19 @@ 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="feed_ad_reward", ref_id=client_event_id,
remark="信息流广告奖励",
biz_type=reward_biz, ref_id=client_event_id,
remark=reward_remark,
)
rec = AdFeedRewardRecord(
client_event_id=client_event_id,
+83 -1
View File
@@ -147,12 +147,22 @@ 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:
"""一批券领取结果幂等写入,返回写入(新增 + 更新)条数。
@@ -186,12 +196,15 @@ 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, vendor=r.get("vendor"), coupon_name=r.get("name"),
status=status, app_env=app_env,
vendor=r.get("vendor"), coupon_name=r.get("name"),
claimed_count=count, trace_id=trace_id, reason=r.get("reason"),
extra=r,
))
@@ -235,6 +248,47 @@ 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(
@@ -321,3 +375,31 @@ 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()
+93 -11
View File
@@ -217,12 +217,28 @@ def _recent_real_rows(db: Session) -> list[tuple[int, int, str | None]]:
return out
def _shuffle_declustered(rows: list, rng: random.Random | None = None) -> list:
"""洗牌 + 「去连簇」:先洗牌,再贪心重排让相邻两条尽量不是同一 user_id(元素 [0] 即 user_id)。
rng=None 用全局 _rng(feed 每次新随机);传入 rng(如固定种子 Random) 排列确定(admin 稳定分页)
减少同一用户连续出现;只有少数几个用户时 best-effort"""
rng = rng or _rng
pool = list(rows)
rng.shuffle(pool)
result: list = []
while pool:
prev_uid = result[-1][0] if result else None
# 优先挑与上一条不同 user 的;挑不到(只剩同 user)才取第一个
idx = next((i for i, r in enumerate(pool) if r[0] != prev_uid), 0)
result.append(pool.pop(idx))
return result
def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]:
"""返回最多 limit 条 {masked_user, saved_amount_cents, time(HH:MM:SS 北京)}。
mode:显式传入(admin 预览指定模式)则用它**不改持久化配置**;不传(客户端 /savings-feed)
持久化的 marquee_feed_mode;非法值一律回退到持久化模式
真实条:success 0 < saved 上限, user 去重(同一用户只取最新一条,避免单人刷屏)
真实条:success 0 < saved 上限,** user 去重**(打乱 + 去连簇:相邻尽量不同用户减少单人连刷)后取前 limit
不足用启用的种子补齐**公平随机抽取** need (而非固定取前 N),让所有种子都有机会露出;
种子用户名留空则随机合成(避开撞名),金额取**长尾随机**(小额居多偶尔大额,更像真实分布)
真实 + 种子仍不满 limit 内置合成条**补满**,保证轮播既不空也不稀疏
@@ -235,19 +251,13 @@ def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]
items: list[dict] = []
used_names: set[str] = set()
# 真实条(mixed / real):取较多近期记录(带 ~30s 缓存)后按 user 去重;金额超上限的异常值已在查询剔除。
# 真实条(mixed / real):**不按 user 去重**——打乱 + 去连簇(相邻尽量不同用户、减少单人连刷)后取前
# limit;金额超上限的异常值已在查询剔除。同一用户可多条露出,但被打散、尽量不连续。
if mode != "seed":
rows = _recent_real_rows(db)
seen_users: set[int] = set()
for uid, sc, nick in rows:
if uid in seen_users:
continue
seen_users.add(uid)
for uid, sc, nick in _shuffle_declustered(_recent_real_rows(db))[:limit]:
name = _mask_real(nick, uid)
used_names.add(name) # 真实名按昵称/id 稳定;偶发撞名可接受
used_names.add(name) # 同一用户可多条,名字重复无害(used_names 仅供种子避重)
items.append({"masked_user": name, "saved_amount_cents": int(sc)})
if len(items) >= limit:
break
# 种子补位 + 合成兜底(mixed / seed):mixed 下补真实不足的部分,seed 下全量用种子/合成。
# real 模式**跳过**——只出真实,不掺任何假数据(真实不足则少于 limit,为 0 时返回空)。
@@ -300,6 +310,78 @@ def get_feed(db: Session, limit: int = 8, mode: str | None = None) -> list[dict]
return items
# ===== admin 侧:分页浏览「当前模式下可展示的记录」(审核用,不去重) =====
_REAL_BROWSE_CAP = 1000 # 真实记录一次最多纳入这么多去洗牌+分页(足够审核;防超大库全量洗牌)
_BROWSE_SEED = 20260707 # 固定洗牌种子:同一批数据下排列恒定 → 翻页稳定、能翻遍全部
def _seed_browse_row(seed: OpsMarqueeSeed) -> dict:
"""把一条种子按其「生成逻辑」**确定性**生成一行浏览项(名字/金额按 seed.id 派生固定种子 → 翻页稳定)。
名字:运营手填的非模板名原样用,否则本地合成;金额:区间内确定性长尾取值user_id=0(种子无真实用户)"""
r = random.Random(_BROWSE_SEED * 1_000_003 + int(seed.id))
fixed = (seed.masked_user or "").strip()
name = fixed if (fixed and not fixed.startswith("用户****")) else _synth_name(r)
lo = max(0, int(seed.min_cents))
hi = max(lo, int(seed.max_cents))
amt = lo if hi <= lo else lo + int(round((hi - lo) * (r.random() ** 2.2)))
return {"masked_user": name, "saved_amount_cents": amt, "created_at": "", "user_id": 0}
def list_real_records(
db: Session, mode: str | None = None, offset: int = 0, limit: int = 8
) -> tuple[list[dict], int]:
"""分页浏览「**当前模式**下可在 app 轮播展示的全部记录」,供 admin 逐页审核(**不去重**):
- 只真实:全部 success+>0 的真实记录;
- 只种子:每条启用种子按生成逻辑各出一行;
- 混播:真实 + 种子 全部合在一起
app 轮播同口径:先洗牌 + 去连簇(相邻尽量不同 user;种子各自独立不算同 user);**固定种子**
同批数据下排列恒定,翻页不跳能翻遍全部返回 (items, total);item.user_id=0 表示种子"""
mode = mode if mode in FEED_MODES else get_feed_mode(db)
# pool: [(cluster_key, item)];cluster_key 供去连簇——真实=user_id、种子=各自唯一负数(互不聚簇)
pool: list[tuple[int, dict]] = []
if mode != "seed":
rows = db.execute(
select(
ComparisonRecord.user_id,
ComparisonRecord.saved_amount_cents,
User.nickname,
ComparisonRecord.created_at,
)
.join(User, User.id == ComparisonRecord.user_id)
.where(
ComparisonRecord.status == "success",
ComparisonRecord.saved_amount_cents > 0,
ComparisonRecord.saved_amount_cents <= _REAL_MAX_CENTS,
)
.order_by(ComparisonRecord.created_at.desc())
.limit(_REAL_BROWSE_CAP)
).all()
for uid, sc, nick, ca in rows:
pool.append((
int(uid),
{
"masked_user": _mask_real(nick, int(uid)),
"saved_amount_cents": int(sc),
"created_at": str(ca)[:16] if ca is not None else "",
"user_id": int(uid),
},
))
if mode != "real":
seeds = (
db.execute(select(OpsMarqueeSeed).where(OpsMarqueeSeed.enabled.is_(True)))
.scalars()
.all()
)
for i, s in enumerate(seeds):
pool.append((-(i + 1), _seed_browse_row(s))) # 每个种子唯一 key → 互不聚簇
total = len(pool)
# 每次用同一固定种子新建 Random → 同批数据排列恒定(翻页稳定);同时相邻尽量不同 user。
ordered = _shuffle_declustered(pool, random.Random(_BROWSE_SEED))
off = max(0, offset)
items = [item for _key, item in ordered[off : off + limit]]
return items, total
# ===== 运营侧:种子 CRUD =====
def list_seeds(db: Session) -> list[OpsMarqueeSeed]:
return list(
+48 -4
View File
@@ -1,4 +1,4 @@
# coupon_state — 领券今日状态三张表(弹窗频控 / 首页置灰 / 领券记录)
# coupon_state — 领券状态表(今日状态三张 + 领券流水 coupon_session)
> 模型 `app/models/coupon_state.py` · 仓库 `app/repositories/coupon_state.py` · 接口 `app/api/v1/coupon.py`(prefix `/api/v1/coupon`) · [← 索引](./README.md) · [总览](./OVERVIEW.md)
@@ -7,8 +7,9 @@
- **`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` 可空**:领券登录态有就记(资产/画像),可空、**不进唯一键、不阻塞判断**。
@@ -90,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****当前无读取端点**(纯写入沉淀,未来做去重/归因/画像时再用)
- **R**`GET /admin/api/coupon-data/coupons``coupon_slot_report`)—— admin「按券成功率」表,按 `coupon_id` 聚合 成功/(成功+失败)`skipped` 排除,设备-天口径,按 `app_env` 过滤)。见设计 §13
### 字段
| 列 | 类型 | 约束 / 默认 | 说明(取值 / join) |
@@ -101,6 +102,7 @@
| `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` |
@@ -120,7 +122,49 @@
---
## 三表共性小结
## 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))。
@@ -0,0 +1,294 @@
# 领券「整单成功率」与「点位成功率」指标 — 设计与埋点
- 日期: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)。与主明细表按用户过滤的行为不同,属有意为之。
+166
View File
@@ -0,0 +1,166 @@
"""收益明细「金币记录」文案验证脚手架(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()
+89
View File
@@ -320,3 +320,92 @@ def test_read_apis_require_auth(admin_client: TestClient) -> None:
"/admin/api/feedbacks",
]:
assert admin_client.get(path).status_code == 401, path
def test_period_comparison_reward_coin_from_feed_scene(
admin_client: TestClient, admin_token: str
) -> None:
"""比价奖励金币口径:按 ad_feed_reward_record.feed_scene='comparison' 的实发金币
(status=granted)汇总,而非查从不写入的 biz_type (修复大盘该卡恒 0)
too_short(未发奖)不计"""
from app.models.ad_feed_reward import AdFeedRewardRecord
d = "2021-06-15" # 独立历史日,隔离其它用例数据
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800008801", register_channel="sms").id
db.add(AdFeedRewardRecord(
client_event_id="cmp-fs-granted", user_id=uid, reward_date=d,
ecpm_raw="0", coin=123, feed_scene="comparison", status="granted",
))
db.add(AdFeedRewardRecord(
client_event_id="cmp-fs-tooshort", user_id=uid, reward_date=d,
ecpm_raw="0", coin=99, feed_scene="comparison", status="too_short",
))
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
assert r.json()["period"]["coins"]["comparison_reward_coin_total"] == 123
def test_period_coupon_reward_coin_from_feed_scene(
admin_client: TestClient, admin_token: str
) -> None:
"""领券奖励金币口径:按 ad_feed_reward_record.feed_scene='coupon' 的实发金币汇总。"""
from app.models.ad_feed_reward import AdFeedRewardRecord
d = "2021-06-16"
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800008802", register_channel="sms").id
db.add(AdFeedRewardRecord(
client_event_id="cpn-fs-granted", user_id=uid, reward_date=d,
ecpm_raw="0", coin=456, feed_scene="coupon", status="granted",
))
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
assert r.json()["period"]["coins"]["coupon_reward_coin_total"] == 456
def test_period_coupon_reward_excludes_reward_video(
admin_client: TestClient, admin_token: str
) -> None:
"""领券奖励金币不再把激励视频金币算进来(reward_video/ad_reward 从领券桶拆出);
激励视频仍单独计入 reward_video_coin_total"""
from datetime import datetime
from app.models.wallet import CoinTransaction
d = "2021-06-17"
db = SessionLocal()
try:
uid = user_repo.upsert_user_for_login(db, phone="13800008803", register_channel="sms").id
db.add(CoinTransaction(
user_id=uid, amount=50, balance_after=50, biz_type="reward_video",
ref_id="rv-split-1", created_at=datetime(2021, 6, 17, 12, 0, 0),
))
db.commit()
finally:
db.close()
r = admin_client.get(
"/admin/api/stats/overview", params={"date_from": d, "date_to": d},
headers=_auth(admin_token),
)
assert r.status_code == 200, r.text
coins = r.json()["period"]["coins"]
assert coins["coupon_reward_coin_total"] == 0 # 激励视频不计入领券奖励
assert coins["reward_video_coin_total"] == 50 # 仍计入激励视频卡
+77
View File
@@ -150,3 +150,80 @@ def test_create_admin_rejects_unknown_role(admin_client, super_token) -> None:
headers=_auth(super_token),
)
assert r.status_code == 400
def _login_pages(username: str, password: str = "pass1234") -> list[str]:
"""以某账号登录,取下发的有效可见页(左侧导航过滤依据)。"""
c = TestClient(admin_app)
return c.post(
"/admin/api/auth/login", json={"username": username, "password": password}
).json()["admin"]["pages"]
def test_custom_role_create_pages_take_effect(admin_client, super_token) -> None:
# 建「自定义」账号:role=custom + 勾选页(含一个非法 key,应被过滤)
r = admin_client.post(
"/admin/api/admins",
json={
"username": "cust_user", "password": "pass1234", "role": "custom",
"pages_override": ["dashboard", "withdraws", "xx-bad"],
},
headers=_auth(super_token),
)
assert r.status_code == 200, r.text
# 登录下发的 pages == 勾选集(非法 key 过滤),真正驱动左侧导航
assert set(_login_pages("cust_user")) == {"dashboard", "withdraws"}
# 账号列表回显原始勾选集(供编辑回填),同样已过滤
admins = {a["username"]: a for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()}
assert set(admins["cust_user"]["pages_override"]) == {"dashboard", "withdraws"}
assert admins["cust_user"]["role"] == "custom"
def test_custom_role_update_and_switch_back_clears_override(admin_client, super_token) -> None:
admin_client.post(
"/admin/api/admins",
json={
"username": "cust_sw", "password": "pass1234", "role": "custom",
"pages_override": ["dashboard"],
},
headers=_auth(super_token),
)
aid = next(
a["id"] for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()
if a["username"] == "cust_sw"
)
# 只改勾选集(角色不变)→ 生效
u = admin_client.patch(
f"/admin/api/admins/{aid}", json={"pages_override": ["dashboard", "feedbacks"]},
headers=_auth(super_token),
)
assert u.status_code == 200, u.text
assert set(_login_pages("cust_sw")) == {"dashboard", "feedbacks"}
# 切回普通角色 operator → override 清空,pages 跟随角色(运营页集,且不含 admins)
u2 = admin_client.patch(
f"/admin/api/admins/{aid}", json={"role": "operator"}, headers=_auth(super_token)
)
assert u2.status_code == 200, u2.text
admins = {a["username"]: a for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()}
assert admins["cust_sw"]["pages_override"] is None
pages = set(_login_pages("cust_sw"))
assert "feedbacks" in pages and "admins" not in pages # 运营口径,自定义页已不生效
def test_switch_operator_to_custom_sets_override(admin_client, super_token) -> None:
admin_client.post(
"/admin/api/admins",
json={"username": "op2cust", "password": "pass1234", "role": "operator"},
headers=_auth(super_token),
)
aid = next(
a["id"] for a in admin_client.get("/admin/api/admins", headers=_auth(super_token)).json()
if a["username"] == "op2cust"
)
u = admin_client.patch(
f"/admin/api/admins/{aid}",
json={"role": "custom", "pages_override": ["config"]},
headers=_auth(super_token),
)
assert u.status_code == 200, u.text
assert set(_login_pages("op2cust")) == {"config"}
+299
View File
@@ -0,0 +1,299 @@
"""领券「平台成功率」埋点: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
@@ -0,0 +1,137 @@
"""每券成功率(§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()