"""设备注册 / 心跳 endpoint(无障碍保护存活检测)。 路由前缀 /api/v1/device,需 Bearer 鉴权(设备绑登录用户)。 POST /register 注册设备 / 更新厂商 push token(App 前台、拿到 push token 时调) POST /heartbeat 上报心跳(无障碍服务存活时周期调,刷新存活) POST /push-test 开发验收:延迟发送厂商通道测试推送 后端 heartbeat_monitor_worker 据此发现心跳超时的设备并厂商直推告警。 见 spec: spec/accessibility-liveness-push.md。 """ from __future__ import annotations import logging import time from fastapi import APIRouter, BackgroundTasks, HTTPException, status from app.api.deps import CurrentUser, DbSession from app.integrations import vendor_push from app.repositories import device as device_repo from app.schemas.device import ( DeviceOut, DeviceRegisterRequest, HeartbeatRequest, LivenessAckRequest, LivenessOut, OkResponse, PushTestOut, PushTestRequest, ) logger = logging.getLogger("shagua.device") router = APIRouter(prefix="/api/v1/device", tags=["device"]) def _send_push_test_after_delay( push_vendor: str, push_token: str, delay_seconds: int, user_id: int, device_id: str, ) -> None: if delay_seconds > 0: time.sleep(delay_seconds) try: vendor_push.send_accessibility_disabled( push_vendor, push_token, title="测试推送", alert="这是一条厂商通道测试推送。收到它说明 App 被划掉后仍可通过系统通知栏触达。", ) logger.info( "push test sent user_id=%d device_id=%s delay=%ds", user_id, device_id, delay_seconds, ) except vendor_push.VendorPushError as e: logger.warning( "push test failed user_id=%d device_id=%s error=%s", user_id, device_id, e, ) @router.post("/register", response_model=DeviceOut, summary="注册设备/更新推送token") def register_device( req: DeviceRegisterRequest, user: CurrentUser, db: DbSession, ) -> DeviceOut: device = device_repo.register_or_update( db, user_id=user.id, device_id=req.device_id, registration_id=req.registration_id, push_vendor=req.push_vendor, push_token=req.push_token, platform=req.platform, app_version=req.app_version, ) logger.info( "device register user_id=%d device_id=%s vendor=%s token=%s legacy_reg=%s", user.id, req.device_id, req.push_vendor, bool(req.push_token), bool(req.registration_id), ) return DeviceOut.model_validate(device) @router.post("/heartbeat", response_model=OkResponse, summary="上报心跳") def report_heartbeat( req: HeartbeatRequest, user: CurrentUser, db: DbSession, ) -> OkResponse: device_repo.touch_heartbeat( db, user_id=user.id, device_id=req.device_id, accessibility_enabled=req.accessibility_enabled, registration_id=req.registration_id, push_vendor=req.push_vendor, push_token=req.push_token, ) return OkResponse() @router.post("/push-test", response_model=PushTestOut, summary="延迟发送厂商通道测试推送") def request_push_test( req: PushTestRequest, background_tasks: BackgroundTasks, user: CurrentUser, db: DbSession, ) -> PushTestOut: """开发验收用:App 内点一次,服务端延迟发厂商直推,验证离线通道。""" push_vendor = req.push_vendor.strip() if req.push_vendor else None push_token = req.push_token.strip() if req.push_token else None if push_vendor and push_token: device_repo.register_or_update( db, user_id=user.id, device_id=req.device_id, registration_id=req.registration_id, push_vendor=push_vendor, push_token=push_token, ) else: device = device_repo.get_device(db, user_id=user.id, device_id=req.device_id) push_vendor = device.push_vendor if device is not None else None push_token = device.push_token if device is not None else None if not push_vendor or not push_token: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="push vendor token not ready", ) background_tasks.add_task( _send_push_test_after_delay, push_vendor, push_token, req.delay_seconds, user.id, req.device_id, ) logger.info( "push test scheduled user_id=%d device_id=%s delay=%ds", user.id, req.device_id, req.delay_seconds, ) return PushTestOut(delay_seconds=req.delay_seconds, has_push_token=True) @router.get("/liveness", response_model=LivenessOut, summary="查询本机掉线告警(后置检测)") def get_liveness( device_id: str, user: CurrentUser, db: DbSession, ) -> LivenessOut: """客户端进 App 时拉取: 本机是否被判定掉线过(kill_alert_pending)。 设备从未注册过 → 返回默认(无告警)。命中 → 客户端弹「开启自启动」引导, 之后调 /liveness/ack 清。 见 spec: spec/accessibility-liveness-pull-prompt.md。 """ device = device_repo.get_device(db, user_id=user.id, device_id=device_id) if device is None: return LivenessOut() return LivenessOut.model_validate(device) @router.post("/liveness/ack", response_model=OkResponse, summary="确认已提醒(清掉线告警)") def ack_liveness( req: LivenessAckRequest, user: CurrentUser, db: DbSession, ) -> OkResponse: """客户端已弹过「开启自启动」引导 → 清掉「待提醒」标记(下次真掉线 worker 再置)。""" device_repo.ack_kill_alert(db, user_id=user.id, device_id=req.device_id) return OkResponse()