Files
shaguabijia-app-server/app/models/device.py
T
陈世睿 8adad30ff2 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>
2026-06-15 22:16:39 +08:00

84 lines
3.2 KiB
Python

"""设备表(无障碍保护存活检测 + 极光推送)。
每条 = 一个用户的一台设备(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}>"
)