afc55ab910
- 新建 device 设备表(设备/系统信息 + 网络/时区/模拟器/位置 + 关联用户 + 状态/时间戳), 以客户端 device_id 唯一标识;登录后回填 user_id,游客态可空 - 原 device 模型改名 device_liveness(实为无障碍存活表),腾出 device 名给新表并保持 表名与实体名一致;同步更新 __init__ / repositories / admin queries / 文档引用 - 新增 POST /api/v1/device/report:软鉴权(带 JWT 绑用户、游客也收),按 device_id upsert; 设备信息非空才覆盖,位置 latitude/longitude 整字段覆盖含清空(以本次上报实际为准) - 新增 alembic migration bb47051068c8 建 device 表(裁掉 autogenerate 带出的无关表漂移) - 补 /device/report 端点与 upsert 单元测试 4 例
82 lines
4.6 KiB
Python
82 lines
4.6 KiB
Python
"""设备表(设备档案 / 终端注册)。
|
|
|
|
每条 = 一台设备,以客户端生成的 device_id 唯一标识(格式 device_<MODEL>_<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"<Device id={self.id} device_id={self.device_id} user_id={self.user_id}>"
|