feat(device): 无障碍掉线后置检测 — kill_alert_pending + liveness 查询/ack 端点
推送暂缓改 pull:worker 检出掉线即置 kill_alert_pending(与 liveness_state 解耦,心跳恢复 touch_heartbeat 不清,规避竞态),客户端进 App 拉 GET /device/liveness 命中→弹自启动引导,POST /device/liveness/ack 清。含 alembic 加列(临时 sqlite 验通)。spec: accessibility-liveness-pull-prompt.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
"""device.kill_alert_pending (无障碍掉线「后置检测」待提醒标记)
|
||||
|
||||
Revision ID: device_kill_alert_pending
|
||||
Revises: merge_cps_device_liveness
|
||||
Create Date: 2026-06-19
|
||||
|
||||
后置检测 pull 版(spec/accessibility-liveness-pull-prompt.md):worker 检出心跳掉线即置此标记,
|
||||
客户端下次进 App 拉取到 → 弹「开启自启动」引导,交互后 ack 清回。与 liveness_state 解耦,故单列。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'device_kill_alert_pending'
|
||||
down_revision: Union[str, Sequence[str], None] = 'merge_cps_device_liveness'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# server_default=sa.false() 跨方言: SQLite 渲染 0 / PG 渲染 false(别用 sa.text("0"),PG 布尔严格会报错),
|
||||
# 给存量行回填 False。
|
||||
with op.batch_alter_table('device', schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
'kill_alert_pending',
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table('device', schema=None) as batch_op:
|
||||
batch_op.drop_column('kill_alert_pending')
|
||||
@@ -19,6 +19,8 @@ from app.schemas.device import (
|
||||
DeviceOut,
|
||||
DeviceRegisterRequest,
|
||||
HeartbeatRequest,
|
||||
LivenessAckRequest,
|
||||
LivenessOut,
|
||||
OkResponse,
|
||||
)
|
||||
|
||||
@@ -64,3 +66,31 @@ def report_heartbeat(
|
||||
registration_id=req.registration_id,
|
||||
)
|
||||
return OkResponse()
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
@@ -88,7 +88,7 @@ def _scan_once(timeout_minutes: int) -> dict:
|
||||
silent = _silent_seconds(device.last_heartbeat_at)
|
||||
logger.warning(
|
||||
"🔴 [掉线检测] user_id=%s device_id=%s 已 %s 秒无心跳(阈值 %d 分钟)"
|
||||
" → 判定 App 已被杀/无障碍已停。【本应推送通知提醒用户重开;推送暂未接,先终端打印代替】",
|
||||
" → 判定 App 已被杀/无障碍已停。【已置 kill_alert_pending: 用户下次进 App 将弹「开启自启动」引导(后置检测);推送本期未接】",
|
||||
device.user_id,
|
||||
device.device_id,
|
||||
silent if silent is not None else "?",
|
||||
|
||||
@@ -65,6 +65,12 @@ class Device(Base):
|
||||
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
|
||||
|
||||
@@ -98,9 +98,27 @@ def list_overdue(db: Session, *, timeout_minutes: int) -> list[Device]:
|
||||
|
||||
|
||||
def mark_notified(db: Session, *, device_id_pk: int) -> None:
|
||||
"""标记已推送告警(状态机进入 notified,避免重复推送)。"""
|
||||
"""标记掉线已检出(状态机进入 notified,避免每轮重复处理)+ 置「待客户端提醒」标记。
|
||||
|
||||
kill_alert_pending 与 state 解耦(见 spec accessibility-liveness-pull-prompt.md):
|
||||
心跳恢复(touch_heartbeat)只重置 state、不动此标记,故客户端下次进 App 必能拉到这次掉线。
|
||||
"""
|
||||
device = db.get(Device, 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) -> Device | 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()
|
||||
|
||||
@@ -34,3 +34,17 @@ class DeviceOut(BaseModel):
|
||||
|
||||
class OkResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class LivenessOut(BaseModel):
|
||||
"""本机掉线告警状态(后置检测 pull 版)。设备从未注册过 → 全默认(= 无告警)。"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
kill_alert_pending: bool = False
|
||||
liveness_state: str = "unknown"
|
||||
ever_protected: bool = False
|
||||
last_heartbeat_at: datetime | None = None
|
||||
|
||||
|
||||
class LivenessAckRequest(BaseModel):
|
||||
device_id: str
|
||||
|
||||
Reference in New Issue
Block a user