"""device 表读写(设备注册 / 心跳 / 超时扫描)。""" from __future__ import annotations from datetime import datetime, timedelta, timezone from sqlalchemy import select from sqlalchemy.orm import Session from app.models.device import DeviceLiveness def _get(db: Session, *, user_id: int, device_id: str) -> DeviceLiveness | None: stmt = select(DeviceLiveness).where( DeviceLiveness.user_id == user_id, DeviceLiveness.device_id == device_id ) return db.execute(stmt).scalar_one_or_none() def register_or_update( db: Session, *, user_id: int, device_id: str, registration_id: str | None, platform: str = "android", app_version: str | None = None, ) -> DeviceLiveness: """注册设备或更新其 registration_id / 元信息。upsert by (user_id, device_id)。""" device = _get(db, user_id=user_id, device_id=device_id) if device is None: device = DeviceLiveness( user_id=user_id, device_id=device_id, registration_id=registration_id, platform=platform or "android", app_version=app_version, ) db.add(device) else: if registration_id: device.registration_id = registration_id if platform: device.platform = platform if app_version: device.app_version = app_version db.commit() db.refresh(device) return device def touch_heartbeat( db: Session, *, user_id: int, device_id: str, accessibility_enabled: bool, registration_id: str | None, ) -> DeviceLiveness: """处理一次心跳(心跳也能自注册)。 service 心跳或 accessibility_enabled=true 时,刷新存活并把状态机重置回 alive、 清掉 notified_at(掉线恢复 → 下次再断才会再推一条)。 """ now = datetime.now(timezone.utc) device = _get(db, user_id=user_id, device_id=device_id) if device is None: device = DeviceLiveness(user_id=user_id, device_id=device_id) db.add(device) if registration_id: device.registration_id = registration_id device.last_report_protection_on = accessibility_enabled if accessibility_enabled: if not device.ever_protected: device.first_protected_at = now # 首次开无障碍记一次,后续心跳不覆盖 device.last_heartbeat_at = now device.ever_protected = True device.liveness_state = "alive" device.notified_at = None db.commit() db.refresh(device) return device def list_overdue(db: Session, *, timeout_minutes: int) -> list[DeviceLiveness]: """掉线设备:曾经保护过、当前 alive、心跳超时。 本期只做终端打印检测、不推送 → 不再要求有 registration_id(没接极光 token 的设备也要检出)。 """ cutoff = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes) stmt = select(DeviceLiveness).where( DeviceLiveness.ever_protected.is_(True), DeviceLiveness.liveness_state == "alive", DeviceLiveness.last_heartbeat_at.is_not(None), DeviceLiveness.last_heartbeat_at < cutoff, ) return list(db.execute(stmt).scalars().all()) def mark_notified(db: Session, *, device_id_pk: int) -> None: """标记掉线已检出(状态机进入 notified,避免每轮重复处理)+ 置「待客户端提醒」标记。 kill_alert_pending 与 state 解耦(见 spec accessibility-liveness-pull-prompt.md): 心跳恢复(touch_heartbeat)只重置 state、不动此标记,故客户端下次进 App 必能拉到这次掉线。 """ device = db.get(DeviceLiveness, device_id_pk) if device is not None: device.liveness_state = "notified" device.notified_at = datetime.now(timezone.utc) device.kill_alert_pending = True db.commit() def get_device(db: Session, *, user_id: int, device_id: str) -> DeviceLiveness | None: """按 (user_id, device_id) 取设备(liveness 查询用)。""" return _get(db, user_id=user_id, device_id=device_id) def ack_kill_alert(db: Session, *, user_id: int, device_id: str) -> None: """客户端已弹过「开启自启动」引导 → 清「待提醒」标记(幂等; 下次真掉线 worker 再置)。""" device = _get(db, user_id=user_id, device_id=device_id) if device is not None and device.kill_alert_pending: device.kill_alert_pending = False db.commit()