Files
shaguabijia-app-server/app/models/coupon_state.py
T
liujiahui 8fa55eec3e fix(coupon): 领券弹窗频控按 App 独立 + debug 全重置补全 (#52)
bug: 弹窗 engagement 表只按 (device, 日) 全局记一条,美团弹过/领过就把整台设备当天
标记 engage,淘宝/京东被压住不弹。需求是美团/淘宝/京东各自独立、每日各弹一次。

改动:
- model: CouponPromptEngagement 加 package 列,唯一约束 (device,日) → (device,package,日)
- alembic: 新增迁移 coupon_engage_per_package(加列 + 改唯一约束, batch_alter_table)
- repository: has_engaged_today / mark_engagement 加 package 维度;新增 reset_today_completion
- api: should-show / dismiss 接收 package;coupon_step(step=0) 按 App 记 engagement;
  补 /prompt/shown 接口(客户端一直在调但后端缺失, 原 404);
  补 /completed-today/reset(开发设置全重置用, 解首页卡置灰)

验证: curl 端到端 —— 美团弹过后 should-show 美团=false 淘宝/京东=true;
shown/reset/completion-reset 端点全 ok。

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

---------

Co-authored-by: no_gen_mu <liujianhishen@gmail.com>
Reviewed-on: #52
Co-authored-by: liujiahui <liujiahui@wonderable.ai>
Co-committed-by: liujiahui <liujiahui@wonderable.ai>
2026-06-14 23:41:54 +08:00

184 lines
8.3 KiB
Python

"""领券今日状态(弹窗频控 + 领券记录)两张表。
- `coupon_prompt_engagement`:按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"
——点「一键领取」(claim_started) 或 点拒绝/关闭 (dismissed) 都算。切到外卖 App 时
据此决定弹不弹:今天 engage 过就不再弹。判断维度是 **device_id**——券发到的是设备上
登录的那个外卖账号,device 比 user 更贴近"哪个登录环境",且 device_id 全链路现成、
不依赖领券鉴权。
- `coupon_claim_record`:按 (device, 券, 自然日) 记每张券的领取结果,纯沉淀(资产/画像/
排查)。当前**不参与**"要不要领"的判断(MVP 先不去重:今天 engage 过就不弹,A 路径
主动领则全跑)。留作以后做按券去重 / CPS 归因 / 用户画像的数据源。
口径:
- 日期 = Asia/Shanghai 的自然日(claim_date / engage_date)。
- user_id 领券登录态有就记(资产),可空、不进唯一键、不阻塞判断。
- device_id 客户端生成存 SP,卸载重装会变 → 重装当新设备重新弹一次(产品预期)。
"""
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import (
JSON,
Date,
DateTime,
Index,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
# PG 上用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 price_observation / comparison)。
_JSON = JSON().with_variant(JSONB(), "postgresql")
class CouponClaimRecord(Base):
"""单张券一天一条领取记录(资产层,当前不做去重判断)。"""
__tablename__ = "coupon_claim_record"
__table_args__ = (
# 同设备、同券、同一天只一条:领券每帧 last_coupon_result + done 帧 coupon_results
# 会重复上报同一张券,靠它幂等 upsert。
UniqueConstraint(
"device_id", "coupon_id", "claim_date",
name="uq_coupon_claim_device_coupon_date",
),
# 去重/统计查询按 (device, 日) 取一天所有券。
Index("ix_coupon_claim_device_date", "device_id", "claim_date"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 判断/聚合维度。client getOrCreateDeviceId 生成,重装会变。
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
# 登录态有就记(资产/画像),可空、不进唯一键。
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
coupon_id: Mapped[str] = mapped_column(String(64), nullable=False)
# Asia/Shanghai 自然日。每日可领的券(签到/天天红包)靠这天然每天一条。
claim_date: Mapped[date] = mapped_column(Date, nullable=False)
# success / already_claimed / failed / skipped(原样取 pricebot coupon 结果)
status: Mapped[str] = mapped_column(String(24), nullable=False)
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)
claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 哪次任务领的,回指 pricebot work_logs / 排查
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
# failed / skipped 原因
reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
# 杂项兜底:券的结构化信息(面额/入口/关键节点摘要等),免得加字段就迁移。
# ⚠️ 别塞原始无障碍树(几十 KB → 行膨胀);原始大树看 trace_id 指过去的 work_logs。
extra: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<CouponClaimRecord device={self.device_id} coupon={self.coupon_id} "
f"date={self.claim_date} status={self.status}>"
)
class CouponDailyCompletion(Base):
"""按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。
与 engagement 区别:engagement = 用户表达过意向(点了一键领取/拒绝即记,不管跑没跑完);
completion = 这一轮真跑到了 done(整套流程走完)。首页「去领取」卡据此置灰:跑完了
今天就不能再领。判断口径(用户决策 2026-06-10 A 方案):**到 done 即算**,不管单券
成败(失败/跳过常是无障碍/环境问题,重复点也补不回来)。判断维度 device_id,与
engagement/claim 一致。
"""
__tablename__ = "coupon_daily_completion"
__table_args__ = (
# 一台设备一天一条:今天跑完过就置灰。
UniqueConstraint(
"device_id", "complete_date",
name="uq_coupon_completion_device_date",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# Asia/Shanghai 自然日。
complete_date: Mapped[date] = mapped_column(Date, nullable=False)
# 哪次任务跑到 done,回指 pricebot work_logs / 排查。
trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<CouponDailyCompletion device={self.device_id} "
f"date={self.complete_date}>"
)
class CouponPromptEngagement(Base):
"""按 (device, **App**, 自然日) 记"今天这个 App 是否对领券引导窗表达过意向"——弹窗频控源。
2026-06-14:频控维度从 (device, 日) 改为 (device, package, 日)。需求是美团/淘宝/京东
各自独立——在美团弹过/领过,不影响淘宝、京东今天仍各弹一次。原来缺 package → 任一 App
弹过就把整台设备当天标记 engage,其余 App 被压住不弹(bug)。
"""
__tablename__ = "coupon_prompt_engagement"
__table_args__ = (
# 一台设备、一个 App、一天一条:今天**这个 App** engage 过(领或拒)才不再弹该 App。
UniqueConstraint(
"device_id", "package", "engage_date",
name="uq_coupon_engage_device_pkg_date",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[str] = mapped_column(String(64), nullable=False)
# 触发弹窗的目标 App 包名(com.sankuai.meituan / com.taobao.taobao / com.jingdong.app.mall)。
# 频控维度,各 App 独立。旧行(改造前)无此值 → 迁移用占位 "" 填,不影响新逻辑判断。
package: Mapped[str] = mapped_column(
String(64), nullable=False, server_default=""
)
user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
# Asia/Shanghai 自然日。
engage_date: Mapped[date] = mapped_column(Date, nullable=False)
# claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)/ shown(自动弹出即记)。
# 仅记录区分,判断只看"今天这个 App 有没有这条",type 不影响弹不弹。
engage_type: Mapped[str] = mapped_column(String(16), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
nullable=False,
)
def __repr__(self) -> str: # pragma: no cover
return (
f"<CouponPromptEngagement device={self.device_id} "
f"date={self.engage_date} type={self.engage_type}>"
)