refactor(device): device 表→device_liveness + liveness 接口只返 bool(review 意见)

- 表/类改名 device→device_liveness / Device→DeviceLiveness:该表记的是无障碍存活监控状态
  (心跳/liveness_state/kill_alert_pending/推送目标),非设备信息(品牌/型号),原名误导。同步改
  建表迁移表名/索引/约束(未部署→直接改建表迁移,非加 rename);API 路径 /api/v1/device 不变。
- GET /device/liveness 的 LivenessOut 只返 kill_alert_pending 一个布尔(原返回 liveness_state/
  ever_protected/last_heartbeat_at 等设备详情);前端本就只读 kill_alert_pending,不受影响。
This commit is contained in:
陈世睿
2026-06-23 10:20:02 +08:00
parent f4f1ad6f22
commit 0964d81898
6 changed files with 38 additions and 38 deletions
@@ -23,7 +23,7 @@ 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:
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
'kill_alert_pending',
@@ -35,5 +35,5 @@ def upgrade() -> None:
def downgrade() -> None:
with op.batch_alter_table('device', schema=None) as batch_op:
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.drop_column('kill_alert_pending')
+11 -11
View File
@@ -20,7 +20,7 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'device',
'device_liveness',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('device_id', sa.String(length=128), nullable=False),
@@ -36,18 +36,18 @@ def upgrade() -> None:
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'device_id', name='uq_device_user_device'),
sa.UniqueConstraint('user_id', 'device_id', name='uq_device_liveness_user_device'),
)
with op.batch_alter_table('device', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_device_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_device_device_id'), ['device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_device_last_heartbeat_at'), ['last_heartbeat_at'], unique=False)
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_device_liveness_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_device_liveness_device_id'), ['device_id'], unique=False)
batch_op.create_index(batch_op.f('ix_device_liveness_last_heartbeat_at'), ['last_heartbeat_at'], unique=False)
def downgrade() -> None:
with op.batch_alter_table('device', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_device_last_heartbeat_at'))
batch_op.drop_index(batch_op.f('ix_device_device_id'))
batch_op.drop_index(batch_op.f('ix_device_user_id'))
with op.batch_alter_table('device_liveness', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_device_liveness_last_heartbeat_at'))
batch_op.drop_index(batch_op.f('ix_device_liveness_device_id'))
batch_op.drop_index(batch_op.f('ix_device_liveness_user_id'))
op.drop_table('device')
op.drop_table('device_liveness')
+1 -1
View File
@@ -12,7 +12,7 @@ from app.models.cps_link import CpsClick, CpsLink # noqa: F401
from app.models.cps_order import CpsOrder # noqa: F401
from app.models.cps_wx_user import CpsWxUser # noqa: F401
from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401
from app.models.device import Device # noqa: F401
from app.models.device import DeviceLiveness # noqa: F401
from app.models.coupon_state import ( # noqa: F401
CouponClaimRecord,
CouponDailyCompletion,
+6 -4
View File
@@ -28,10 +28,12 @@ from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Device(Base):
__tablename__ = "device"
class DeviceLiveness(Base):
# 表名不叫 device:device 易被当成「设备信息(品牌/型号/系统)」表;本表实为**无障碍存活监控状态**
# (心跳 last_heartbeat_at + liveness_state + kill_alert_pending + 推送目标 registration_id),故名 device_liveness。
__tablename__ = "device_liveness"
__table_args__ = (
UniqueConstraint("user_id", "device_id", name="uq_device_user_device"),
UniqueConstraint("user_id", "device_id", name="uq_device_liveness_user_device"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -84,6 +86,6 @@ class Device(Base):
def __repr__(self) -> str: # pragma: no cover
return (
f"<Device id={self.id} user_id={self.user_id} "
f"<DeviceLiveness id={self.id} user_id={self.user_id} "
f"device_id={self.device_id} state={self.liveness_state}>"
)
+16 -16
View File
@@ -6,12 +6,12 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.device import Device
from app.models.device import DeviceLiveness
def _get(db: Session, *, user_id: int, device_id: str) -> Device | None:
stmt = select(Device).where(
Device.user_id == user_id, Device.device_id == device_id
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()
@@ -24,11 +24,11 @@ def register_or_update(
registration_id: str | None,
platform: str = "android",
app_version: str | None = None,
) -> Device:
) -> 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 = Device(
device = DeviceLiveness(
user_id=user_id,
device_id=device_id,
registration_id=registration_id,
@@ -55,7 +55,7 @@ def touch_heartbeat(
device_id: str,
accessibility_enabled: bool,
registration_id: str | None,
) -> Device:
) -> DeviceLiveness:
"""处理一次心跳(心跳也能自注册)。
service 心跳或 accessibility_enabled=true 时,刷新存活并把状态机重置回 alive、
@@ -64,7 +64,7 @@ def touch_heartbeat(
now = datetime.now(timezone.utc)
device = _get(db, user_id=user_id, device_id=device_id)
if device is None:
device = Device(user_id=user_id, device_id=device_id)
device = DeviceLiveness(user_id=user_id, device_id=device_id)
db.add(device)
if registration_id:
@@ -82,17 +82,17 @@ def touch_heartbeat(
return device
def list_overdue(db: Session, *, timeout_minutes: int) -> list[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(Device).where(
Device.ever_protected.is_(True),
Device.liveness_state == "alive",
Device.last_heartbeat_at.is_not(None),
Device.last_heartbeat_at < cutoff,
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())
@@ -103,7 +103,7 @@ def mark_notified(db: Session, *, device_id_pk: int) -> None:
kill_alert_pending 与 state 解耦(见 spec accessibility-liveness-pull-prompt.md):
心跳恢复(touch_heartbeat)只重置 state、不动此标记,故客户端下次进 App 必能拉到这次掉线。
"""
device = db.get(Device, device_id_pk)
device = db.get(DeviceLiveness, device_id_pk)
if device is not None:
device.liveness_state = "notified"
device.notified_at = datetime.now(timezone.utc)
@@ -111,7 +111,7 @@ def mark_notified(db: Session, *, device_id_pk: int) -> None:
db.commit()
def get_device(db: Session, *, user_id: int, device_id: str) -> Device | None:
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)
+2 -4
View File
@@ -37,13 +37,11 @@ class OkResponse(BaseModel):
class LivenessOut(BaseModel):
"""本机掉线告警状态(后置检测 pull 版)。设备从未注册过 → 全默认(= 无告警)。"""
"""本机掉线告警状态(后置检测 pull 版)。客户端只需这一个布尔判断要不要弹「开启自启动」引导,
故只返回 kill_alert_pending(不暴露设备详情 / 内部 liveness_state 等)。从未注册过 → 默认 False(无告警)。"""
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):