diff --git a/alembic/versions/bb47051068c8_device_table.py b/alembic/versions/bb47051068c8_device_table.py new file mode 100644 index 0000000..d027e37 --- /dev/null +++ b/alembic/versions/bb47051068c8_device_table.py @@ -0,0 +1,59 @@ +"""device table(设备档案 / 终端注册) + +Revision ID: bb47051068c8 +Revises: comparison_llm_cost +Create Date: 2026-07-16 14:22:17.770307 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'bb47051068c8' +down_revision: Union[str, Sequence[str], None] = 'comparison_llm_cost' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'device', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('device_id', sa.String(length=128), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('platform', sa.String(length=16), nullable=False), + sa.Column('oem', sa.String(length=32), nullable=True), + sa.Column('model', sa.String(length=64), nullable=True), + sa.Column('os_version', sa.String(length=32), nullable=True), + sa.Column('app_version', sa.String(length=32), nullable=True), + sa.Column('channel', sa.String(length=32), nullable=True), + sa.Column('screen', sa.String(length=32), nullable=True), + sa.Column('network', sa.String(length=16), nullable=True), + sa.Column('timezone', sa.String(length=64), nullable=True), + sa.Column('is_emulator', sa.Boolean(), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('last_ip', sa.String(length=64), nullable=True), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('last_active_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), + 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'), + ) + with op.batch_alter_table('device', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_device_device_id'), ['device_id'], unique=True) + batch_op.create_index(batch_op.f('ix_device_last_active_at'), ['last_active_at'], unique=False) + batch_op.create_index(batch_op.f('ix_device_user_id'), ['user_id'], 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_user_id')) + batch_op.drop_index(batch_op.f('ix_device_last_active_at')) + batch_op.drop_index(batch_op.f('ix_device_device_id')) + + op.drop_table('device') diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index 501dba9..61d791a 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -20,7 +20,7 @@ from app.models.admin import AdminAuditLog from app.models.analytics_event import AnalyticsEvent from app.models.comparison import ComparisonRecord from app.models.coupon_state import CouponPromptEngagement -from app.models.device import DeviceLiveness +from app.models.device_liveness import DeviceLiveness from app.models.feedback import Feedback from app.models.onboarding import OnboardingCompletion from app.models.price_report import PriceReport diff --git a/app/api/v1/device_profile.py b/app/api/v1/device_profile.py new file mode 100644 index 0000000..788c5d7 --- /dev/null +++ b/app/api/v1/device_profile.py @@ -0,0 +1,51 @@ +"""设备档案上报 endpoint(device 表)。 + +POST /api/v1/device/report — 客户端上报设备当前信息,按 device_id upsert 到 device 表。 +软鉴权:带合法 Bearer → 设备关联到该用户;游客态(无 token)也接受,user_id 暂空。 + +注意与 /api/v1/device/register(app/api/v1/device.py)区分:那个是无障碍存活注册,写 +device_liveness 表、硬鉴权;本端点是设备信息档案,写 device 表、软鉴权。 +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Request + +from app.api.deps import DbSession, OptionalUser +from app.repositories import device_profile as device_profile_repo +from app.schemas.device_profile import DeviceReportOut, DeviceReportRequest + +logger = logging.getLogger("shagua.device_profile") + +router = APIRouter(prefix="/api/v1/device", tags=["device"]) + + +def _client_ip(request: Request) -> str | None: + """客户端 IP:生产经 nginx 反代优先 X-Forwarded-For 第一段,否则直连 IP。""" + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else None + + +@router.post("/report", response_model=DeviceReportOut, summary="上报设备信息(设备档案 upsert)") +def report_device( + req: DeviceReportRequest, + request: Request, + user: OptionalUser, + db: DbSession, +) -> DeviceReportOut: + device = device_profile_repo.upsert_device( + db, + req, + user_id=user.id if user else None, + last_ip=_client_ip(request), + ) + logger.info( + "device report device_id=%s user_id=%s has_loc=%s", + req.device_id, + device.user_id, + req.latitude is not None, + ) + return DeviceReportOut() diff --git a/app/main.py b/app/main.py index 3b3b6ff..c644c28 100644 --- a/app/main.py +++ b/app/main.py @@ -28,6 +28,7 @@ from app.api.v1.compare_record import router as compare_record_router from app.api.v1.coupon import router as coupon_router from app.api.v1.cps_redirect import router as cps_redirect_router from app.api.v1.device import router as device_router +from app.api.v1.device_profile import router as device_profile_router from app.api.v1.feedback import router as feedback_router from app.api.v1.invite import router as invite_router from app.api.v1.meituan import router as meituan_router @@ -120,6 +121,7 @@ app.include_router(analytics_router) app.include_router(invite_router) app.include_router(coupon_router) app.include_router(device_router) +app.include_router(device_profile_router) app.include_router(compare_router) app.include_router(compare_record_router) app.include_router(compare_milestone_router) diff --git a/app/models/__init__.py b/app/models/__init__.py index 694aa00..eb5dd9d 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -19,7 +19,8 @@ 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 DeviceLiveness # noqa: F401 +from app.models.device import Device # noqa: F401 +from app.models.device_liveness import DeviceLiveness # noqa: F401 from app.models.coupon_state import ( # noqa: F401 CouponClaimRecord, CouponDailyCompletion, diff --git a/app/models/device.py b/app/models/device.py index e1d1261..7aae66d 100644 --- a/app/models/device.py +++ b/app/models/device.py @@ -1,83 +1,71 @@ -"""设备表(无障碍保护存活检测 + 极光推送)。 +"""设备表(设备档案 / 终端注册)。 -每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)。 -客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报 -registration_id(极光推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、 -现在心跳超时」的设备,通过极光推送提醒用户重开无障碍。 +每条 = 一台设备,以客户端生成的 device_id 唯一标识(格式 device__<8hex>, +per-install;见 pricebot 客户端 PriceBotService.getOrCreateDeviceId)。登录前(游客态) +即可建档,登录后回填 user_id ——单表只记「最近一个」登录用户,不保留一机多号历史。 -liveness_state 状态机(防刷屏,一次掉线只推一条): - unknown → alive(收到 service 心跳)→ silent/notified(扫描发现超时并已推送) - 心跳恢复时 handler 重置回 alive。 -见 spec: spec/accessibility-liveness-push.md。 +字段采集来源(逐列见注释): + 已在埋点 analytics_event 采集 : oem / os_version / model / app_version / channel / network + 服务端补 : last_ip(X-Forwarded-For)、last_active_at + 客户端需「新增」上报(无需权限) : timezone、is_emulator + 客户端需「新增」上报 + 定位权限 + 隐私合规(PIPL 敏感信息): latitude / longitude + +与 device_liveness(无障碍存活/极光推送,见 device_liveness.py)是两张相互独立的表, +靠同一个 device_id 关联,不要合并。 """ from __future__ import annotations from datetime import datetime -from sqlalchemy import ( - Boolean, - DateTime, - ForeignKey, - Integer, - String, - UniqueConstraint, - func, -) +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base -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_liveness_user_device"), - ) +class Device(Base): + __tablename__ = "device" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("user.id"), index=True, nullable=False - ) - # 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34) - device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False) - # 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发) - registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True) - platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android") - app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) - # 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义) - ever_protected: Mapped[bool] = mapped_column( - Boolean, nullable=False, default=False + # 客户端生成的设备唯一 ID(全局唯一);与 analytics_event / device_liveness 用同一个值 + device_id: Mapped[str] = mapped_column( + String(128), unique=True, index=True, nullable=False ) - # 首次开无障碍(首次收到 accessibility_enabled 心跳)的时刻;ever_protected 第一次翻 true 时记一次, - # 后续心跳不覆盖。老设备(迁移前已 protected)无此值 → NULL。 - first_protected_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True + # 关联用户:登录后回填「最近一次登录」的 user;游客态为空(可空 → 不阻塞未登录建档) + user_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=True ) - # 最近一次 service 心跳时间(存活证明);超时即视为保护掉线 - last_heartbeat_at: Mapped[datetime | None] = mapped_column( + + # ---- 设备 / 系统信息(埋点已采集,注册/上报接口带过来即可) ---- + platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android") # android/ios/harmony + oem: Mapped[str | None] = mapped_column(String(32), nullable=True) # 厂商 Build.MANUFACTURER:xiaomi/huawei… + model: Mapped[str | None] = mapped_column(String(64), nullable=True) # 型号 Build.MODEL,如 PJF110 + os_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # 系统版本,如 "Android 13" + app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) # app 版本 + channel: Mapped[str | None] = mapped_column(String(32), nullable=True) # 安装渠道(应用市场) + screen: Mapped[str | None] = mapped_column(String(32), nullable=True) # 分辨率 "1080x2400" + network: Mapped[str | None] = mapped_column(String(16), nullable=True) # 最近网络类型 wifi/4g/5g + + # ---- 需客户端「新增」上报的字段 ---- + # 设备时区 TimeZone.getDefault().id,如 "Asia/Shanghai"(客户端需新增上报,无需权限) + timezone: Mapped[str | None] = mapped_column(String(64), nullable=True) + # 是否模拟器(客户端 Build 指纹判断后上报;NULL=未知,无需权限) + is_emulator: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + # 位置(经纬度):App 会申请定位权限,但可能拿不到(首次运行未授权 / 用户拒绝 / 关了 GPS)。 + # 规则(leader 定):每次上报以实际为准——能拿到就写,拿不到就置 NULL 清空旧值。 + # ⚠️ upsert 时 lat/lng 必须「整字段覆盖(含 None)」,不能像 model/oem 那样 COALESCE 保留旧值, + # 否则会留下一条早已离开该位置的陈旧坐标。 + latitude: Mapped[float | None] = mapped_column(Float, nullable=True) + longitude: Mapped[float | None] = mapped_column(Float, nullable=True) + + # ---- 上下文 / 状态(服务端维护) ---- + last_ip: Mapped[str | None] = mapped_column(String(64), nullable=True) # 最近一次上报 IP(服务端从 X-Forwarded-For 取) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="normal") # normal/banned(风控封设备) + # 最近活跃时间(设备维度 DAU / 留存统计用;updated_at 只在字段真变化时跳,故单列一个) + last_active_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), index=True, nullable=True ) - # 最近一次上报的无障碍开关状态(观测用) - last_report_protection_on: Mapped[bool] = mapped_column( - Boolean, nullable=False, default=False - ) - # unknown / alive / silent / notified - liveness_state: Mapped[str] = mapped_column( - String(16), nullable=False, default="unknown" - ) - # 最近一次推送告警时间 - 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 @@ -90,7 +78,4 @@ class DeviceLiveness(Base): ) def __repr__(self) -> str: # pragma: no cover - return ( - f"" - ) + return f"" diff --git a/app/models/device_liveness.py b/app/models/device_liveness.py new file mode 100644 index 0000000..e1d1261 --- /dev/null +++ b/app/models/device_liveness.py @@ -0,0 +1,96 @@ +"""设备表(无障碍保护存活检测 + 极光推送)。 + +每条 = 一个用户的一台设备(per-install,device_id 由客户端 DeviceId.get() 生成)。 +客户端的无障碍服务存活时周期上报心跳刷新 last_heartbeat_at;App 前台/登录时上报 +registration_id(极光推送目标)。后端 heartbeat_monitor_worker 扫描「曾经保护过、 +现在心跳超时」的设备,通过极光推送提醒用户重开无障碍。 + +liveness_state 状态机(防刷屏,一次掉线只推一条): + unknown → alive(收到 service 心跳)→ silent/notified(扫描发现超时并已推送) + 心跳恢复时 handler 重置回 alive。 +见 spec: spec/accessibility-liveness-push.md。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + Integer, + String, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +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_liveness_user_device"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("user.id"), index=True, nullable=False + ) + # 客户端 DeviceId.get() 生成的 per-install id(如 device_Pixel_ab12cd34) + device_id: Mapped[str] = mapped_column(String(128), index=True, nullable=False) + # 极光推送 registration id;拿到才填(JCollectionAuth 同意后才下发) + registration_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + platform: Mapped[str] = mapped_column(String(16), nullable=False, default="android") + app_version: Mapped[str | None] = mapped_column(String(32), nullable=True) + + # 收到过 service 心跳即 true(=该设备开过无障碍,功能对它有意义) + ever_protected: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) + # 首次开无障碍(首次收到 accessibility_enabled 心跳)的时刻;ever_protected 第一次翻 true 时记一次, + # 后续心跳不覆盖。老设备(迁移前已 protected)无此值 → NULL。 + first_protected_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + # 最近一次 service 心跳时间(存活证明);超时即视为保护掉线 + last_heartbeat_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), index=True, nullable=True + ) + # 最近一次上报的无障碍开关状态(观测用) + last_report_protection_on: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) + # unknown / alive / silent / notified + liveness_state: Mapped[str] = mapped_column( + String(16), nullable=False, default="unknown" + ) + # 最近一次推送告警时间 + 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 + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + def __repr__(self) -> str: # pragma: no cover + return ( + f"" + ) diff --git a/app/repositories/device.py b/app/repositories/device.py index c3e9047..77dc584 100644 --- a/app/repositories/device.py +++ b/app/repositories/device.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta, timezone from sqlalchemy import select from sqlalchemy.orm import Session -from app.models.device import DeviceLiveness +from app.models.device_liveness import DeviceLiveness def _get(db: Session, *, user_id: int, device_id: str) -> DeviceLiveness | None: diff --git a/app/repositories/device_profile.py b/app/repositories/device_profile.py new file mode 100644 index 0000000..83c31a9 --- /dev/null +++ b/app/repositories/device_profile.py @@ -0,0 +1,66 @@ +"""device 表(设备档案)读写:按 device_id upsert。 + +与 repositories/device.py(无障碍存活 DeviceLiveness)是不同的表:本文件写 device 表。 +""" +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.device import Device +from app.schemas.device_profile import DeviceReportRequest + +# 设备/系统信息:提供了(非 None)才覆盖,某次上报漏带不会把已有值冲掉 +_STICKY_STR_FIELDS = ("oem", "model", "os_version", "app_version", "channel", "screen", "network", "timezone") + + +def upsert_device( + db: Session, + req: DeviceReportRequest, + *, + user_id: int | None, + last_ip: str | None, +) -> Device: + """按 device_id upsert 一台设备的档案。 + + 写入策略分三类: + - 设备/系统信息(_STICKY_STR_FIELDS + platform + is_emulator):非空才覆盖(sticky)。 + - 位置 latitude/longitude:**整字段覆盖,含 None**——以本次上报实际为准,拿不到即清空 + (leader 规则,见 model Device.latitude 注释;不能像上面那样 sticky)。 + - user_id:仅登录态(user_id 非 None)回填为当前登录用户;游客态保留已有关联,不清空。 + last_active_at / last_ip 每次刷新。 + """ + now = datetime.now(timezone.utc) + device = db.execute( + select(Device).where(Device.device_id == req.device_id) + ).scalar_one_or_none() + if device is None: + device = Device(device_id=req.device_id) + db.add(device) + + # 设备/系统信息:非空才覆盖 + if req.platform: + device.platform = req.platform + for field in _STICKY_STR_FIELDS: + val = getattr(req, field) + if val is not None: + setattr(device, field, val) + if req.is_emulator is not None: + device.is_emulator = req.is_emulator + + # 位置:整字段覆盖(含 None),以本次实际为准 + device.latitude = req.latitude + device.longitude = req.longitude + + # 关联用户:仅登录态回填,游客态不动 + if user_id is not None: + device.user_id = user_id + + device.last_ip = last_ip + device.last_active_at = now + + db.commit() + db.refresh(device) + return device diff --git a/app/schemas/device_profile.py b/app/schemas/device_profile.py new file mode 100644 index 0000000..2562bd2 --- /dev/null +++ b/app/schemas/device_profile.py @@ -0,0 +1,35 @@ +"""设备档案上报 schema(device 表)。 + +与 schemas/device.py(无障碍存活 DeviceLiveness 的 register/heartbeat)是不同用途: +本文件对应 device 表(设备信息/档案),schemas/device.py 对应 device_liveness 表。 +""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class DeviceReportRequest(BaseModel): + """客户端上报的一台设备的当前信息(每次以实际为准)。""" + + device_id: str = Field(max_length=128) + platform: str = Field(default="android", max_length=16) + + # 设备 / 系统信息:提供了才覆盖(见 repo:非空 sticky) + oem: str | None = Field(default=None, max_length=32) + model: str | None = Field(default=None, max_length=64) + os_version: str | None = Field(default=None, max_length=32) + app_version: str | None = Field(default=None, max_length=32) + channel: str | None = Field(default=None, max_length=32) + screen: str | None = Field(default=None, max_length=32) + network: str | None = Field(default=None, max_length=16) + timezone: str | None = Field(default=None, max_length=64) + is_emulator: bool | None = None + + # 位置:客户端每次带「本次实际」的经纬度,拿不到就传 null(或不传)→ 服务端清空。 + # 见 model Device.latitude 注释:这两个字段整字段覆盖,不做 sticky。 + latitude: float | None = None + longitude: float | None = None + + +class DeviceReportOut(BaseModel): + ok: bool = True diff --git a/docs/database/device_liveness.md b/docs/database/device_liveness.md index f2ef1ea..5495f4a 100644 --- a/docs/database/device_liveness.md +++ b/docs/database/device_liveness.md @@ -1,6 +1,6 @@ # device_liveness — 无障碍存活监控(心跳 + 掉线召回) -> 模型 `app/models/device.py`(`DeviceLiveness`) · 仓库 `app/repositories/device.py`(`register_or_update` / `touch_heartbeat` / `list_overdue` / `mark_notified` / `get_device` / `ack_kill_alert`) · 接口 用户 `POST /api/v1/device/register`、`POST /api/v1/device/heartbeat`、`GET /api/v1/device/liveness`、`POST /api/v1/device/liveness/ack`(`app/api/v1/device.py`);后台 worker `heartbeat_monitor_worker` · [← 索引](./README.md) · [总览](./OVERVIEW.md) +> 模型 `app/models/device_liveness.py`(`DeviceLiveness`) · 仓库 `app/repositories/device.py`(`register_or_update` / `touch_heartbeat` / `list_overdue` / `mark_notified` / `get_device` / `ack_kill_alert`) · 接口 用户 `POST /api/v1/device/register`、`POST /api/v1/device/heartbeat`、`GET /api/v1/device/liveness`、`POST /api/v1/device/liveness/ack`(`app/api/v1/device.py`);后台 worker `heartbeat_monitor_worker` · [← 索引](./README.md) · [总览](./OVERVIEW.md) 每行 = 一个用户的一台设备(per-install,`(user_id, device_id)` 唯一)。客户端无障碍服务存活时周期上报心跳刷新 `last_heartbeat_at`;App 前台/登录拿到极光 push token 时上报 `registration_id`。后端 `heartbeat_monitor_worker` 扫「曾保护过、现已心跳超时」的设备,推送(或本期仅终端打印)提醒用户重开无障碍。**表名不叫 `device`**:它存的不是设备信息(品牌/型号),而是**无障碍存活状态**。#65 新增。 diff --git a/tests/test_device_profile.py b/tests/test_device_profile.py new file mode 100644 index 0000000..9b8e51c --- /dev/null +++ b/tests/test_device_profile.py @@ -0,0 +1,98 @@ +"""设备档案上报端点 /api/v1/device/report + upsert 测试。""" +from __future__ import annotations + +from sqlalchemy import select + +from app.core.security import create_token +from app.db.session import SessionLocal +from app.models.device import Device +from app.models.user import User + + +def _payload(**over) -> dict: + base = { + "device_id": "dev-report-1", + "platform": "android", + "oem": "Xiaomi", + "model": "PJF110", + "os_version": "Android 14", + "app_version": "0.2.12(62)", + "channel": "yingyongbao", + "screen": "1080x2400", + "network": "wifi", + "timezone": "Asia/Shanghai", + "is_emulator": False, + "latitude": 31.23, + "longitude": 121.47, + } + base.update(over) + return base + + +def _get(device_id: str) -> Device | None: + with SessionLocal() as db: + return db.execute( + select(Device).where(Device.device_id == device_id) + ).scalar_one_or_none() + + +def test_report_creates_device_as_guest(client) -> None: + r = client.post("/api/v1/device/report", json=_payload(device_id="dev-guest")) + assert r.status_code == 200, r.text + assert r.json()["ok"] is True + d = _get("dev-guest") + assert d is not None + assert d.user_id is None # 游客态:未绑用户 + assert d.oem == "Xiaomi" + assert d.is_emulator is False + assert d.latitude == 31.23 + assert d.last_active_at is not None + + +def test_report_binds_user_when_authed(client) -> None: + with SessionLocal() as db: + u = User(phone="13800000001", username="20000000001") + db.add(u) + db.commit() + uid = u.id + token, _ = create_token(user_id=uid, token_type="access") + r = client.post( + "/api/v1/device/report", + json=_payload(device_id="dev-user"), + headers={"Authorization": f"Bearer {token}"}, + ) + assert r.status_code == 200, r.text + assert _get("dev-user").user_id == uid + + +def test_report_clears_location_when_absent(client) -> None: + # 首次带位置 + client.post( + "/api/v1/device/report", + json=_payload(device_id="dev-loc", latitude=10.0, longitude=20.0), + ) + assert _get("dev-loc").latitude == 10.0 + # 再次上报拿不到位置(lat/lng=None)→ 必须清空,不能保留旧坐标 + r = client.post( + "/api/v1/device/report", + json=_payload(device_id="dev-loc", latitude=None, longitude=None, oem="HONOR"), + ) + assert r.status_code == 200, r.text + d = _get("dev-loc") + assert d.latitude is None # 位置以实际为准 → 清空 + assert d.longitude is None + assert d.oem == "HONOR" # 设备信息正常更新 + + +def test_report_sticky_fields_not_wiped_by_missing(client) -> None: + # 首次全量 + client.post("/api/v1/device/report", json=_payload(device_id="dev-sticky")) + # 再次只带 device_id + 位置(不带 oem/model)→ 设备信息保留旧值(非空才覆盖) + client.post( + "/api/v1/device/report", + json={"device_id": "dev-sticky", "latitude": 1.0, "longitude": 2.0}, + ) + d = _get("dev-sticky") + assert d.oem == "Xiaomi" # 未被漏带的 None 冲掉 + assert d.model == "PJF110" + assert d.latitude == 1.0 # 位置按本次