8adad30ff2
- 新增 device 表 + /api/v1/device/{register,heartbeat} + 迁移 device_table
- heartbeat_monitor_worker 周期扫描心跳超时(App 被杀/无障碍停)→ 服务器终端打印告警
(推送本期未接,先用 logger 终端打印代替真实通知;integrations/jpush.py 已备,后续直接替换)
- config / .env.example 增 JPUSH_* / HEARTBEAT_*
- 见 spec(仓库外 e:\codes\spec\accessibility-liveness-push.md)
注:本提交同时快照了并行 session 未提交的 coupon_state 改动 + 相关 migration
(与本功能同迁移链耦合,无法单独拆分,经确认一并提交)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
185 lines
8.6 KiB
Python
185 lines
8.6 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 是否弹过/engage 过领券引导窗"——弹窗频控源。
|
|
|
|
2026-06-12 改为**按 App 为单位**(对齐方案文档「一个 app 一天只能弹出弹窗一次」+ 产品确认:
|
|
没领完就进其他平台要再弹,彻底领完才全局不弹——"彻底领完"由 coupon_daily_completion 负责):
|
|
- app_package 非空:该 App 当天的频控行(shown/dismissed 由客户端带包名上报)。
|
|
- app_package 为 NULL:全局兜底行(step=0 透传链路自动记的 claim_started 拿不到包名 +
|
|
旧版客户端不带包名)。**按 App 查询时忽略 NULL 行**(领券中途终止 ≠ 其他 App engage 过);
|
|
不带包名的旧式查询仍把 NULL 行算作"今天 engage 过"(旧客户端行为不变)。
|
|
"""
|
|
|
|
__tablename__ = "coupon_prompt_engagement"
|
|
__table_args__ = (
|
|
# 一台设备、一个 App、一天一条(app_package=NULL 的全局兜底行不受唯一约束限制——
|
|
# SQL 标准里 NULL 互不相等;写入走 select-first upsert,正常不会堆重复行)。
|
|
UniqueConstraint(
|
|
"device_id", "app_package", "engage_date",
|
|
name="uq_coupon_engage_device_app_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)
|
|
# 在哪个外卖 App 弹的窗(Android 包名,如 com.sankuai.meituan)。NULL = 全局兜底行(见类注释)。
|
|
app_package: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
# Asia/Shanghai 自然日。
|
|
engage_date: Mapped[date] = mapped_column(Date, nullable=False)
|
|
# shown(弹窗已对该 App 弹出,频控主判据)/ claim_started(点了一键领取)/
|
|
# dismissed(点了拒绝/关闭)。判断只看"今天该 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}>"
|
|
)
|