"""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 = None, push_vendor: str | None = None, push_token: str | None = None, platform: str = "android", app_version: str | None = None, ) -> DeviceLiveness: """注册设备或更新其厂商 push token / 元信息。upsert by (user_id, device_id)。""" normalized_vendor = _normalize_push_vendor(push_vendor) normalized_token = push_token.strip() if push_token else None 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, push_vendor=normalized_vendor, push_token=normalized_token, platform=platform or "android", app_version=app_version, ) db.add(device) else: if registration_id: device.registration_id = registration_id if normalized_vendor: device.push_vendor = normalized_vendor if normalized_token: device.push_token = normalized_token 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 = None, push_vendor: str | None = None, push_token: str | None = 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 normalized_vendor = _normalize_push_vendor(push_vendor) normalized_token = push_token.strip() if push_token else None if normalized_vendor: device.push_vendor = normalized_vendor if normalized_token: device.push_token = normalized_token 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、心跳超时。 即使没有厂商 token 也要检出,后续由 kill_alert_pending 走客户端进 App 后兜底提醒。 """ 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() def has_push_target(device: DeviceLiveness | None) -> bool: """是否已有厂商直推所需的 vendor + token。""" return bool(device and device.push_vendor and device.push_token) def list_push_targets(db: Session, *, user_id: int) -> list[DeviceLiveness]: """该用户全部可用厂商推送目标(push_vendor + push_token 双非空),最近更新在前。 同 (vendor, token) 只留最新一行:同一台手机重装 App 后 device_id 会变、 留下 token 相同的旧行,去重防一次业务事件对同一台手机重复推送。 """ stmt = ( select(DeviceLiveness) .where( DeviceLiveness.user_id == user_id, DeviceLiveness.push_vendor.is_not(None), DeviceLiveness.push_token.is_not(None), ) .order_by(DeviceLiveness.updated_at.desc(), DeviceLiveness.id.desc()) ) seen: set[tuple[str, str]] = set() targets: list[DeviceLiveness] = [] for dev in db.execute(stmt).scalars(): if not dev.push_vendor or not dev.push_token: # 空串兜底(旧数据) continue key = (dev.push_vendor, dev.push_token) if key in seen: continue seen.add(key) targets.append(dev) return targets def _normalize_push_vendor(push_vendor: str | None) -> str | None: if not push_vendor: return None vendor = push_vendor.strip().lower() aliases = { "honor": "honor", "hihonor": "honor", "荣耀": "honor", "vivo": "vivo", "xiaomi": "xiaomi", "mi": "xiaomi", "小米": "xiaomi", "oppo": "oppo", "oneplus": "oppo", "realme": "oppo", } return aliases.get(vendor, vendor)