4、核心数据模型与ORM:用SQLAlchemy定义设备、租户、策略

数据模型这东西,说白了就是系统的骨架。你想想看,设备注册、身份管理、策略下发——所有功能都绕不开几个核心实体。我这些年做物联网平台,见过太多项目前期图省事,模型设计得稀里糊涂,结果后面改得痛不欲生。

今天我们就来把这几个核心模型理清楚。我会用Python的SQLAlchemy做演示,但思路是通用的,你用GORM或者其他ORM也一样。

4.1 先画一张关系图

动手写代码之前,我习惯先画一张图。把实体之间的关系看清楚,代码才不会写歪。

核心实体一览:

  • 租户(Tenant) —— 隔离单位。一个企业就是一个租户。
  • 设备(Device) —— 被管理的终端。每个设备属于一个租户。
  • 策略(Policy) —— 控制规则。可以绑定到租户或设备。
  • 注册凭证(Credential) —— 设备身份证明。每个设备可以有多个凭证。
核心数据模型关系图 核心数据模型关系图 租户 (Tenant) tenant_id, name, status 设备 (Device) device_id, secret, status 策略 (Policy) policy_id, effect, action 凭证 (Credential) cred_id, type, value 1:N 1:N 1:N M:N 💡 关系说明: 租户与设备:一对多。一个租户下有成千上万台设备。 设备与凭证:一对多。一台设备可以有多个凭证(比如主密钥+临时令牌)。

我的经验: 别把关系搞得太复杂。我见过有人把设备分组、设备标签、设备属性全做成独立表,结果查询时关联七八张表,性能一塌糊涂。初期模型尽量精简,后面再按需扩展。

4.2 租户模型:隔离的起点

租户模型是整个系统的隔离边界。每个租户的数据必须严格分开,不能串。

from sqlalchemy import Column, String, DateTime, Enum, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid

class Tenant(Base):
    __tablename__ = 'tenants'

    tenant_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    name = Column(String(128), nullable=False, unique=True)
    display_name = Column(String(256), nullable=False)
    status = Column(Enum('active', 'suspended', 'deleted', name='tenant_status'),
                    default='active', nullable=False)
    description = Column(Text, default='')
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), onupdate=func.now())

    def __repr__(self):
        return f"<Tenant(name='{self.name}', status='{self.status}')>"

这里有几个细节我特别想强调:

  • 用UUID做主键 —— 自增ID在分布式场景下容易冲突,UUID虽然占空间大一点,但省心。我在一个项目里吃过自增ID的亏,两个数据中心的数据合并时ID全乱了。
  • status字段用枚举 —— 别用字符串随便填,否则后面查数据时你会看到"active"、"Active"、"actived"各种写法,想死的心都有。
  • 时间戳带时区 —— 设备可能分布在全球,不带时区的时间戳就是给自己挖坑。

4.3 设备模型:身份的核心

设备模型是系统里数据量最大的表。一台设备就是一个被管理的终端,它得有唯一的身份标识。

class Device(Base):
    __tablename__ = 'devices'

    device_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    tenant_id = Column(UUID(as_uuid=True),
                       ForeignKey('tenants.tenant_id', ondelete='CASCADE'),
                       nullable=False)
    device_name = Column(String(128), nullable=False)
    device_secret = Column(String(256), nullable=False)  # 设备密钥,哈希存储
    status = Column(Enum('online', 'offline', 'disabled', 'revoked',
                         name='device_status'),
                    default='offline', nullable=False)
    firmware_version = Column(String(64), default='')
    last_seen_at = Column(DateTime(timezone=True), nullable=True)
    registered_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), onupdate=func.now())

    # 关系
    tenant = relationship('Tenant', back_populates='devices')
    credentials = relationship('Credential', back_populates='device',
                               cascade='all, delete-orphan')

    __table_args__ = (
        Index('idx_devices_tenant_status', 'tenant_id', 'status'),
    )

⚠️ 注意: device_secret 绝对不能存明文!一定要用 bcrypt 或 argon2 做哈希。我曾经接手过一个项目,设备密钥直接明文存数据库,安全审计时直接被判不合格,整个平台被迫下线整改。

为什么要有 last_seen_at?说白了就是心跳。设备上次活跃时间能帮你判断它是不是还活着。我习惯在设备上线时更新这个字段,配合定时任务清理长期离线的设备。

4.4 策略模型:谁可以做什么

策略就是权限规则。它决定了某个设备能不能执行某个操作。我见过最简单的策略模型只有三个字段:主体、动作、效果。

class Policy(Base):
    __tablename__ = 'policies'

    policy_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    tenant_id = Column(UUID(as_uuid=True),
                       ForeignKey('tenants.tenant_id', ondelete='CASCADE'),
                       nullable=False)
    policy_name = Column(String(128), nullable=False)
    effect = Column(Enum('allow', 'deny', name='policy_effect'), nullable=False)
    action = Column(String(128), nullable=False)  # 例如 "device:read", "data:publish"
    resource = Column(String(256), nullable=False)  # 例如 "device/*", "topic/sensor/#"
    priority = Column(Integer, default=0)  # 数字越小优先级越高
    description = Column(Text, default='')
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), onupdate=func.now())

    # 多对多关系:策略可以绑定到多个设备
    devices = relationship('Device', secondary='policy_bindings',
                           back_populates='policies')

这里有个设计取舍:策略是绑定到租户级别还是设备级别?我的建议是——租户级别做默认策略,设备级别做例外策略。大部分规则在租户层面统一配置,只有特殊设备才单独绑定策略。这样策略数量可控,不会爆炸。

4.5 凭证模型:设备的身份证

凭证是设备用来证明自己身份的东西。可以是预置的密钥,也可以是动态颁发的令牌。

class Credential(Base):
    __tablename__ = 'credentials'

    cred_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    device_id = Column(UUID(as_uuid=True),
                       ForeignKey('devices.device_id', ondelete='CASCADE'),
                       nullable=False)
    cred_type = Column(Enum('pre_shared_key', 'x509_cert', 'jwt_token',
                            name='credential_type'),
                       nullable=False)
    cred_value = Column(Text, nullable=False)  # 哈希后的凭证值
    expires_at = Column(DateTime(timezone=True), nullable=True)
    is_revoked = Column(Boolean, default=False)
    created_at = Column(DateTime(timezone=True), server_default=func.now())

    device = relationship('Device', back_populates='credentials')

我的习惯: 凭证一定要有过期时间。哪怕是预共享密钥,也建议定期轮换。我在一个车联网项目里,设备出厂时烧录的密钥用了三年没换过,结果某批次密钥泄露,几十万台设备全部需要远程更新凭证,那场面……别提了。

4.6 多对多关系:策略绑定表

策略和设备是多对多的关系。一张策略可以绑定多个设备,一个设备也可以应用多个策略。我们需要一张关联表。

class PolicyBinding(Base):
    __tablename__ = 'policy_bindings'

    binding_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    policy_id = Column(UUID(as_uuid=True),
                       ForeignKey('policies.policy_id', ondelete='CASCADE'),
                       nullable=False)
    device_id = Column(UUID(as_uuid=True),
                       ForeignKey('devices.device_id', ondelete='CASCADE'),
                       nullable=False)
    created_at = Column(DateTime(timezone=True), server_default=func.now())

    __table_args__ = (
        UniqueConstraint('policy_id', 'device_id', name='uq_policy_device'),
    )

加唯一约束是为了防止重复绑定。同一个策略对同一个设备绑定两次,没有任何意义。

4.7 数据库迁移:让模型落地

模型定义好了,怎么让它变成数据库里的表?用Alembic。它是SQLAlchemy官方推荐的迁移工具。

初始化迁移环境:

# 安装
pip install alembic

# 初始化
alembic init migrations

配置 alembic.ini 里的数据库连接串:

sqlalchemy.url = postgresql://user:password@localhost:5432/device_registry

然后修改 migrations/env.py,导入你的模型:

from app.models import Base  # 你的模型基类
target_metadata = Base.metadata

生成第一个迁移脚本:

alembic revision --autogenerate -m "init core tables"

检查生成的脚本,确认无误后执行:

alembic upgrade head

⚠️ 注意: --autogenerate 不是万能的。它检测不了所有变更,比如列名重命名、数据类型变更。每次自动生成后,一定要人工审查生成的脚本。我曾经因为没检查,自动迁移把一整个表的数据类型改错了,生产环境挂了半小时。

4.8 模型设计的几个原则

最后,分享几个我这些年总结出来的模型设计原则:

原则 说明 反面案例
单一职责 一张表只描述一个实体 把设备信息和策略规则混在一张表里
适度冗余 常用字段可以冗余存储,减少关联查询 每次查设备都要关联租户表才能拿到租户名
索引先行 查询条件字段提前建索引 百万级设备表,按状态查询没有索引
软删除 用状态字段标记删除,不真删数据 直接 DELETE,数据恢复不了

嗯,模型设计就聊到这里。这些代码你直接拿去用,但记得根据你的业务场景调整字段。比如你的设备需要存储地理位置,那就加个 location 字段;需要支持固件升级,就加个 firmware_url。模型是活的,别把它做死了。