"""账号唯一性:users 的 登录名/手机号/微信 openid 各自唯一(非空值部分唯一) 账号唯一性约定(用户 ID / 登录名 / 手机号 / 微信身份 均为独立逻辑,各自唯一): - id 主键天然唯一(创建时由服务端生成,必存在) - username 登录名 — 已唯一(见 0001 models unique=True) - phone 手机号 — 本次加「非空部分唯一」索引(空串=未绑定,允许多个,互不冲突) - wx_openid / wx_mini_openid / wx_unionid — 微信身份同样非空部分唯一,避免一个微信身份被搭到两个账号 SQLite 不支持带 WHERE 的唯一约束,但支持部分唯一索引: CREATE UNIQUE INDEX ... ON users (phone) WHERE phone != '' 同时 DB 层保留普通索引便于按 openid 查询。 Revision ID: 0011_unique_user_identifiers Revises: 0010_user_opc_profile Create Date: 2026-08-25 """ from __future__ import annotations import sqlalchemy as sa from alembic import op revision = "0011_unique_user_identifiers" down_revision = "0010_user_opc_profile" branch_labels = None depends_on = None def upgrade() -> None: bind = op.get_bind() dialect = bind.dialect.name # 非空值部分唯一索引(SQLite 专属语法;其它方言退化为普通索引并另行约束) # 迁移前若有重复非空值会失败——请先用 scripts/db/cleanup_accounts.py 清理。 unique_cols = ["phone", "wx_openid", "wx_mini_openid", "wx_unionid"] for col in unique_cols: if dialect != "sqlite": continue op.create_index( f"ux_users_{col}_nonempty", "users", [col], unique=True, sqlite_where=sa.text(f"{col} != ''"), ) # 数据库连接(非空内存)唯一:postgres 用部分唯一索引、mysql 用普通(另行靠应用层校验) if dialect == "postgresql": for col in unique_cols: op.create_index( f"ux_users_{col}_nonempty", "users", [col], unique=True, postgresql_where=sa.text(f"{col} IS NOT NULL"), ) def downgrade() -> None: bind = op.get_bind() dialect = bind.dialect.name unique_cols = ["phone", "wx_openid", "wx_mini_openid", "wx_unionid"] for col in unique_cols: if dialect not in ("sqlite", "postgresql"): continue op.drop_index(f"ux_users_{col}_nonempty", table_name="users")