"""设备表(设备档案 / 终端注册)。 每条 = 一台设备,以客户端生成的 device_id 唯一标识(格式 device__<8hex>, per-install;见 pricebot 客户端 PriceBotService.getOrCreateDeviceId)。登录前(游客态) 即可建档,登录后回填 user_id ——单表只记「最近一个」登录用户,不保留一机多号历史。 字段采集来源(逐列见注释): 已在埋点 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, Float, ForeignKey, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base class Device(Base): __tablename__ = "device" id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) # 客户端生成的设备唯一 ID(全局唯一);与 analytics_event / device_liveness 用同一个值 device_id: Mapped[str] = mapped_column( String(128), unique=True, index=True, nullable=False ) # 关联用户:登录后回填「最近一次登录」的 user;游客态为空(可空 → 不阻塞未登录建档) user_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("user.id"), index=True, nullable=True ) # ---- 设备 / 系统信息(埋点已采集,注册/上报接口带过来即可) ---- 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 ) 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""