Phoenix 中的 SQLAlchemy 多态用户模型单表继承STI从教学示例到生产实践【免费下载链接】phoenixAI Observability Evaluation项目地址: https://gitcode.com/gh_mirrors/phoenix13/phoenix导读本文以 Phoenix 开源仓库中的 internal_docs/vignettes/sqlalchemy/polymorphic_user/README.md 教学示例为骨架系统讲解如何在 SQLAlchemy 中通过**单表继承Single Table InheritanceSTI**将本地密码登录用户与外部服务认证用户建模到同一张数据库表并辅以仓库内真实生产代码 src/phoenix/db/models.py 中的User/LocalUser/OAuth2User/LDAPUser多态模型作为纵深印证。读完本文你将掌握判别器列discriminator的配置方法、ORM 对象创建与数据库加载的初始化差异、多态查询的写法、以及用 CheckConstraint 与部分唯一索引保障数据完整性的工程实践。为什么需要多态用户模型现代应用的用户认证体系通常不止一种来源既存在使用邮箱 密码在本系统内认证的本地用户也存在通过 OAuth2、SSO、LDAP 等外部身份提供方认证的用户。如果为每种用户单独建表会导致公共字段id、email、created_at等大量重复跨类型查询如列出所有用户必须做 UNION 或多次查询。SQLAlchemy 的单表继承模式给出了更优雅的解法所有用户类型共享同一张users表通过一个判别器列示例中为auth_method记录每行数据所属的具体类型ORM 在加载行数据时根据该列值自动实例化对应的子类。这样既保持了面向对象设计的类型层次又实现了紧凑高效的存储。教学示例 internal_docs/vignettes/sqlalchemy/polymorphic_user/users.py 以auth_method ∈ {local, external}的两类用户演示了该模式的全部要点而 Phoenix 生产模型 src/phoenix/db/models.py 将该模式扩展为LOCAL/OAUTH2/LDAP三类用户是同一思想在真实系统中的落地范例。单表继承STI的核心概念判别器列polymorphic_on与polymorphic_identitySTI 的机制核心是__mapper_args__中的两个配置项配置项作用示例取值polymorphic_on指定哪一列作为判别器决定行数据实例化为哪个类auth_methodpolymorphic_identity每个类在判别器列中对应的唯一标识基类设为None表示抽象基类NoneLocalUser为localExternalUser为external教学示例的配置如下# 基类抽象不可直接实例化 __mapper_args__ { polymorphic_on: auth_method, # 以 auth_method 作为判别器列 polymorphic_identity: None, # 基类为抽象类 } # LocalUser 子类 __mapper_args__ { polymorphic_identity: local, # auth_methodlocal 的行实例化为 LocalUser } # ExternalUser 子类 __mapper_args__ { polymorphic_identity: external, }当 SQLAlchemy 从数据库中读取一行auth_methodlocal的记录时会自动构造LocalUser实例读取auth_methodexternal时则构造ExternalUser。判别器列的值与类一一对应这种映射关系即多态身份polymorphic identity。抽象基类防止误实例化基类User不能被直接实例化。教学示例通过在构造函数中主动拦截实现def __init__(self, **kwargs: Any) - None: if self.__class__ is User: raise TypeError(Cannot instantiate abstract User class) super().__init__(**kwargs)对应的演示逻辑位于 users.py创建User(emailbaseexample.com)并flush()时会抛出预期的TypeError。这一设计保证了所有实体必然属于某个具体子类避免出现无类型用户这种悬空状态。初始化行为创建与加载是两条完全不同的路径理解 SQLAlchemy ORM 的初始化语义是正确设计多态模型的前提。教学文档明确指出两类场景的差异对象创建如LocalUser(emailuserexample.com, passwordsecret)__init__被调用密码哈希与盐生成在这一步完成属性经构造函数写入。数据库加载如session.query(User).all()SQLAlchemy绕过__init__直接从数据库行回填属性——这更接近反序列化deserialization而非构造。因此加载时不会重复执行密码哈希效率更高、行为更可预期。这意味着密码哈希只发生在对象创建阶段数据库加载阶段拿到的password_hash与password_salt都是已存储的最终值直接赋给实例即可。非映射状态如由密码字段推导的计算属性可以通过 Python 描述符property或事件钩子InstanceEvents.load()维护本示例中的auth_method即由构造时显式指定。教学示例的模型定义逐段解析完整可运行代码见 internal_docs/vignettes/sqlalchemy/polymorphic_user/users.py其模型设计要点如下。基类User公共字段 表级约束class User(Base): __tablename__ users id: Mapped[int] mapped_column(primary_keyTrue) # 主键 email: Mapped[str] mapped_column(String, uniqueTrue) # 全表唯一邮箱 auth_method: Mapped[AuthMethod] mapped_column( String, CheckConstraint(auth_method IN (local, external), namevalid_auth_method), ) # 密码字段仅 LocalUser 使用因此允许 NULL用 LargeBinary 存储二进制哈希 password_hash: Mapped[Optional[bytes]] mapped_column(LargeBinary, nullableTrue) password_salt: Mapped[Optional[bytes]] mapped_column(LargeBinary, nullableTrue) __mapper_args__ { polymorphic_on: auth_method, polymorphic_identity: None, } __table_args__ ( # 密码字段要么都 NULL要么都有值ExternalUser 两个字段均为 NULL CheckConstraint( (password_hash IS NULL) (password_salt IS NULL), namepassword_hash_and_salt, ), dict(sqlite_autoincrementTrue), )几点工程细节email的uniqueTrue保证全表跨所有用户类型邮箱不重复auth_method上的CheckConstraint将取值限制为local/external在数据库层面兜底类型安全password_hash_and_salt约束确保password_hash与password_salt要么同时为 NULL外部用户要么同时有值本地用户杜绝只有哈希没有盐这类半残数据sqlite_autoincrementTrue使 SQLite 下即使删除记录 ID 也保持递增不重用。AuthMethod使用typing.Literal定义AuthMethod Literal[local, external]让静态类型检查器能捕获非法的auth_method赋值新增认证方式时必须同步扩展该类型。LocalUserbcrypt 哈希 独立盐class LocalUser(User): __mapper_args__ {polymorphic_identity: local} def __init__(self, email: str, password: str) - None: password_salt bcrypt.gensalt() # 每次调用生成新盐安全随机数 password_hash bcrypt.hashpw(password.encode(), password_salt) super().__init__( emailemail, auth_methodlocal, password_hashpassword_hash, password_saltpassword_salt, )安全要点密码从不以明文存储仅在构造时经 bcrypt 哈希每个用户拥有独立盐bcrypt.gensalt()使用安全随机数生成器盐与哈希分开存储在两个列中盐独立存储的价值在于即便哈希值泄露也无法直接用于其他系统或重放攻击的跨库匹配。ExternalUser无密码、显式 NULLclass ExternalUser(User): __mapper_args__ {polymorphic_identity: external} def __init__(self, email: str) - None: super().__init__(emailemail, auth_methodexternal)外部用户通过 OAuth、SSO 等外部身份提供方认证模型层完全不涉及密码password_hash与password_salt保持 NULL由表级约束强制保证实现了清晰的关注点分离。运行与验证# 安装依赖SQLAlchemy 2.x bcrypt pip install sqlalchemy bcrypt # 运行演示内存 SQLite python internal_docs/vignettes/sqlalchemy/polymorphic_user/users.pymain()users.py演示了完整链路创建内存 SQLite 引擎 →Base.metadata.create_all(engine)建表含全部约束→ 在事务中插入LocalUser与ExternalUser→ 尝试实例化抽象基类并捕获TypeError→ 分别用session.query(User)、session.query(LocalUser)、session.query(ExternalUser)查询并打印各实例的真实类型。运行后应看到All users中同时出现LocalUser与ExternalUser两种实例证明判别器驱动的多态加载生效。从教学示例到 Phoenix 生产模型三类用户的多态实现教学示例并非孤立设计。Phoenix 的用户体系在 src/phoenix/db/models.py 中把同一 STI 模式扩展到了三种认证方式是理解示例模式如何规模化的最佳参照。生产版基类与判别器AuthMethod: TypeAlias Literal[LOCAL, OAUTH2, LDAP] # models.py:177 class User(HasId): # models.py:2175 __tablename__ users username: Mapped[str] mapped_column(nullableFalse, uniqueTrue, indexTrue) email: Mapped[Optional[str]] mapped_column(nullableTrue, uniqueTrue, indexTrue) password_hash: Mapped[Optional[bytes]] password_salt: Mapped[Optional[bytes]] oauth2_client_id: Mapped[Optional[str]] # 仅 OAUTH2 使用 oauth2_user_id: Mapped[Optional[str]] # 仅 OAUTH2 使用 ldap_unique_id: Mapped[Optional[str]] # 仅 LDAP 使用 auth_method: Mapped[AuthMethod] mapped_column( CheckConstraint(auth_method IN (LOCAL, OAUTH2, LDAP), namevalid_auth_method) ) __mapper_args__ { polymorphic_on: auth_method, polymorphic_identity: None, # 基类抽象 }三个子类通过polymorphic_identity区分models.pyLocalUser→LOCAL构造时强制要求password_hash与password_salt非空否则抛ValueErrorOAuth2User→OAUTH2携带oauth2_client_id/oauth2_user_idreset_passwordFalseLDAPUser→LDAP以ldap_unique_idobjectGUID、entryUUID 等标识邮箱可为 NULL。生产版约束矩阵比教学示例更严格的数据完整性Phoenix 在表级约束上对字段与认证方式的组合合法性做了穷举式校验models.py注释中以表格形式给出了约束摘要字段LOCALOAUTH2LDAPpasswordR必须有N必须 NULLN必须 NULLemailRR*邮箱或 ldap_unique_id 至少其一ldap_unique_idNN*必须且非 NULL 时唯一oauth2_client_id / oauth2_user_idNO可选N对应实现为 5 个命名 CheckConstraintlocal_auth_has_password_no_oauthLOCAL 用户必须有密码字段且不得携带任何 oauth2/ldap 字段non_local_auth_has_no_password非 LOCAL 用户密码字段必须为 NULLldap_auth_validLDAP 用户不得有 oauth2 字段且必须有email或ldap_unique_id防止无法定位的孤儿账号oauth2_auth_no_ldap_fieldsOAUTH2 用户不得有ldap_unique_idnon_ldap_auth_has_email除 LDAP 外的用户必须有邮箱。此外还使用部分唯一索引partial unique index为外部认证提供数据库级去重ix_users_oauth2_unique对(oauth2_client_id, oauth2_user_id)仅在auth_method OAUTH2时生效ix_users_ldap_unique_id对ldap_unique_id仅在auth_method LDAP且非 NULL 时生效。两个索引同时给出了 PostgreSQLpostgresql_where与 SQLitesqlite_where两种方言实现保证测试与生产行为一致。这一设计与教学示例的password_hash_and_salt约束一脉相承把业务规则下沉到数据库层即使应用层出现绕过 ORM 的写入如直接 SQL数据完整性依然有保障。生产环境的安全加固清单教学文档在结尾明确声明示例中的 bcrypt 处理仅用于教学生产环境还应叠加以下措施登录限流rate limiting与失败锁定策略account lockout密码复杂度要求会话管理session management与额外认证因子如 MFASECRET_KEY等敏感信息交由安全密钥管理系统保管示例中SECRET_KEY secrets.token_urlsafe(32)仅为演示数据库访问控制与安全配置、定期安全审计与依赖更新。在 Phoenix 仓库中上述能力有对应的完整实现可对照阅读认证与 RBAC 设计见 internal_docs/specs/rbac.md 与 internal_docs/specs/ldap-authentication.mdAPI 密钥与令牌生命周期见 internal_docs/specs/api-key-identity-authority-lifecycle.md。小结从 教学示例 到 Phoenix 生产模型单表继承模式被证明是一种可平滑扩展的用户建模方案一张users表承载全部用户类型auth_method判别器驱动正确的子类实例化抽象基类 子类专属字段在保持类型层次清晰的同时避免空表与 JOIN 的复杂度CheckConstraint 部分唯一索引把密码字段一致性跨类型邮箱唯一外部身份唯一等规则固化在数据库层创建与加载分离的初始化语义确保密码哈希只在对象创建时执行一次加载路径高效且安全。如果要在自己的项目中复刻推荐路径是先按教学示例在内存 SQLite 上跑通两类用户的多态读写再参照 Phoenix 的约束矩阵逐步补齐业务规则最后引入真实数据库并利用其方言特性如 PostgreSQL 的部分索引强化约束。【免费下载链接】phoenixAI Observability Evaluation项目地址: https://gitcode.com/gh_mirrors/phoenix13/phoenix创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考