"""设备表(无障碍保护存活检测 + 极光推送)。 每条 = 一个用户的一台设备(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 DeviceLiveness(Base): # 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态** # (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。 __tablename__ = "device_liveness" __table_args__ = ( UniqueConstraint("user_id", "device_id", name="uq_device_liveness_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 ) # 首次开无障碍(首次收到 accessibility_enabled 心跳)的时刻;ever_protected 第一次翻 true 时记一次, # 后续心跳不覆盖。老设备(迁移前已 protected)无此值 → NULL。 first_protected_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) # 最近一次 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 ) # 掉线告警「待客户端提醒」标记(后置检测 pull 版, 见 spec accessibility-liveness-pull-prompt.md)。 # 与 liveness_state 解耦: worker 检出掉线即置 True; touch_heartbeat(心跳恢复)不动它 # → 规避「服务随 App 重启先发心跳、state 被重置回 alive → 客户端进 App 漏看」竞态; 只由客户端 ack 清。 kill_alert_pending: Mapped[bool] = mapped_column( Boolean, nullable=False, default=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"" )