feat(device): 无障碍保护存活心跳检测 + 掉线终端告警
- 新增 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>
This commit is contained in:
@@ -7,6 +7,7 @@ from app.models.admin import AdminAuditLog, AdminUser # noqa: F401
|
||||
from app.models.app_config import AppConfig # noqa: F401
|
||||
from app.models.comparison import ComparisonRecord # noqa: F401
|
||||
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
|
||||
from app.models.device import Device # noqa: F401
|
||||
from app.models.coupon_state import ( # noqa: F401
|
||||
CouponClaimRecord,
|
||||
CouponDailyCompletion,
|
||||
|
||||
@@ -137,14 +137,23 @@ class CouponDailyCompletion(Base):
|
||||
|
||||
|
||||
class CouponPromptEngagement(Base):
|
||||
"""按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"——弹窗频控源。"""
|
||||
"""按 (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__ = (
|
||||
# 一台设备一天一条:今天 engage 过(领或拒)就不再弹。
|
||||
# 一台设备、一个 App、一天一条(app_package=NULL 的全局兜底行不受唯一约束限制——
|
||||
# SQL 标准里 NULL 互不相等;写入走 select-first upsert,正常不会堆重复行)。
|
||||
UniqueConstraint(
|
||||
"device_id", "engage_date",
|
||||
name="uq_coupon_engage_device_date",
|
||||
"device_id", "app_package", "engage_date",
|
||||
name="uq_coupon_engage_device_app_date",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -152,10 +161,12 @@ class CouponPromptEngagement(Base):
|
||||
|
||||
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)
|
||||
# claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)。仅记录区分,
|
||||
# 判断只看"今天有没有这条",type 不影响弹不弹。
|
||||
# shown(弹窗已对该 App 弹出,频控主判据)/ claim_started(点了一键领取)/
|
||||
# dismissed(点了拒绝/关闭)。判断只看"今天该 App 有没有这条",type 仅记录区分。
|
||||
engage_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""设备表(无障碍保护存活检测 + 极光推送)。
|
||||
|
||||
每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)。
|
||||
客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报
|
||||
registration_id(极光推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、
|
||||
现在心跳超时」的设备,通过极光推送提醒用户重开无障碍。
|
||||
|
||||
liveness_state 状态机(防刷屏,一次掉线只推一条):
|
||||
unknown → alive(收到 service 心跳)→ silent/notified(扫描发现超时并已推送)
|
||||
心跳恢复时 handler 重置回 alive。
|
||||
见 spec: spec/accessibility-liveness-push.md。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Device(Base):
|
||||
__tablename__ = "device"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "device_id", name="uq_device_user_device"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("user.id"), index=True, nullable=False
|
||||
)
|
||||
# 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34)
|
||||
device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False)
|
||||
# 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发)
|
||||
registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android")
|
||||
app_version: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
|
||||
# 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义)
|
||||
ever_protected: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
# 最近一次 service 心跳时间(存活证明);超时即视为保护掉线
|
||||
last_heartbeat_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), index=True, nullable=True
|
||||
)
|
||||
# 最近一次上报的无障碍开关状态(观测用)
|
||||
last_report_protection_on: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
# unknown / alive / silent / notified
|
||||
liveness_state: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="unknown"
|
||||
)
|
||||
# 最近一次推送告警时间
|
||||
notified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=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"<Device id={self.id} user_id={self.user_id} "
|
||||
f"device_id={self.device_id} state={self.liveness_state}>"
|
||||
)
|
||||
Reference in New Issue
Block a user