From c2f4c13ab36bb00421a168cbfab8a5aa0a4b445a Mon Sep 17 00:00:00 2001 From: Pine Date: Tue, 25 Aug 2026 22:47:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E8=B4=A6=E5=8F=B7=E5=94=AF?= =?UTF-8?q?=E4=B8=80=E6=80=A7+=E6=B8=85=E7=90=86=E5=A4=9A=E4=BD=99?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=20=E2=80=94=E2=80=94=20=E8=BF=81=E7=A7=BB001?= =?UTF-8?q?1=E5=8A=A0phone/=E5=BE=AE=E4=BF=A1openid=E9=9D=9E=E7=A9=BA?= =?UTF-8?q?=E9=83=A8=E5=88=86=E5=94=AF=E4=B8=80=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - users.phone/wx_openid/wx_mini_openid/wx_unionid 各自唯一(非空部分唯一索引) - 创建账号必生成ID(create用new_id);find_by_phone/find_by_wx 唯一校验 - cleanup_accounts.py 清理旧accounts已被users接管的重复账号;seed不再重复造账号、迁移后删旧行 --- .../versions/0011_unique_user_identifiers.py | 63 ++++++++++ app/infrastructure/repositories.py | 24 ++++ scripts/db/cleanup_accounts.py | 113 ++++++++++++++++++ scripts/db/seed.py | 17 +-- 4 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 alembic/versions/0011_unique_user_identifiers.py create mode 100644 scripts/db/cleanup_accounts.py diff --git a/alembic/versions/0011_unique_user_identifiers.py b/alembic/versions/0011_unique_user_identifiers.py new file mode 100644 index 0000000..5e0f588 --- /dev/null +++ b/alembic/versions/0011_unique_user_identifiers.py @@ -0,0 +1,63 @@ +"""账号唯一性: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") diff --git a/app/infrastructure/repositories.py b/app/infrastructure/repositories.py index 28d7b7d..4d651a4 100644 --- a/app/infrastructure/repositories.py +++ b/app/infrastructure/repositories.py @@ -219,6 +219,30 @@ class UserRepository: u = await self.session.get(User, user_id) return _user_to_dict(u) if u else None + async def find_by_phone(self, phone: str) -> dict | None: + """按已绑定手机号精准查找(唯一,非空;用户ID/登录名/手机号三者独立各唯一)。""" + p = (phone or "").strip() + if not p: + return None + u = await self.session.scalar(select(User).where(User.phone == p)) + return _user_to_dict(u) if u else None + + async def find_by_wx(self, *, openid: str = "", mini_openid: str = "") -> dict | None: + """按微信身份(网页开放平台 openid / 小程序 openid)查找用户。""" + if mini_openid: + u = await self.session.scalar( + select(User).where(User.wx_mini_openid == mini_openid.strip()), + ) + if u: + return _user_to_dict(u) + if openid: + u = await self.session.scalar( + select(User).where(User.wx_openid == openid.strip()), + ) + if u: + return _user_to_dict(u) + return None + async def list(self) -> list[dict]: return [_user_to_dict(u) for u in await self.session.scalars(select(User).order_by(User.created_at))] diff --git a/scripts/db/cleanup_accounts.py b/scripts/db/cleanup_accounts.py new file mode 100644 index 0000000..bbc8c34 --- /dev/null +++ b/scripts/db/cleanup_accounts.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +"""清理数据库中的多余账号(非运行态,迁移/种子前执行)。 + +用户 ID / 登录名 / 手机号 是三种独立逻辑,各自唯一;账号创建时 ID 必存在。 +历史遗留:培训端旧 ``accounts`` 表是整套平行账号仓(同名/同手机/同微信身份在 +``users`` 已有规范账号)——这些就是「多余账号」。本脚本: + +1. 安全删除旧 ``accounts`` 中已被 ``users`` 规范账号接管(user_id/登录名/手机号/ + 微信身份任一命中)的行; +2. 校验 ``users`` 无重复 登录名/手机号/微信 openid(非空)——为迁移 0011 唯一索引清障; +3. 幂等:重跑安全。 + +用法:uv run python scripts/db/cleanup_accounts.py +配合:先 cleanup(清多余+去重)→ 再 migrate.py(唯一索引)→ seed.py。 +""" +from __future__ import annotations + +import sqlite3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) + +from app import config +from app.infrastructure.repositories import new_id # noqa: F401 (确保路径/依赖就绪) + +LEGACY_TABLES = ("accounts",) + + +def _dup_groups(cur: sqlite3.Cursor, col: str) -> list[tuple]: + """返回 users 中某列非空重复的分组((值, 计数, [id...]))。""" + cur.execute( + f"SELECT {col}, COUNT(*) c FROM users WHERE {col} != '' GROUP BY {col} HAVING c > 1" + ) + return [(row[0], row[1]) for row in cur.fetchall()] + + +def cleanup_users_duplicates(conn: sqlite3.Connection) -> list[str]: + """校验 users 唯一性;存在重复非空值则提示手动处理(返回描述)。""" + cur = conn.cursor() + problems: list[str] = [] + for col in ("username", "phone", "wx_openid", "wx_mini_openid", "wx_unionid"): + groups = _dup_groups(cur, col) + if groups: + for val, cnt in groups: + problems.append(f"users.{col}={val!r} 重复 {cnt} 次") + return problems + + +def cleanup_legacy_accounts(conn: sqlite3.Connection) -> int: + """删除已被 users 接管的旧 accounts 行,返回删除数。""" + cur = conn.cursor() + try: + cur.execute("PRAGMA table_info(accounts)") + cols = [r[1] for r in cur.fetchall()] + except sqlite3.OperationalError: + return 0 + if "username" not in cols: + return 0 + + rows = cur.execute("SELECT id,username,phone,wxid FROM accounts").fetchall() + deleted = 0 + for acct_id, username, phone, wxid in rows: + uname = (username or "").strip() + ph = (phone or "").strip() + wx = (wxid or "").strip() + hit = False + if uname: + cur.execute("SELECT 1 FROM users WHERE username=? LIMIT 1", (uname,)) + hit = hit or cur.fetchone() is not None + if not hit and ph: + cur.execute("SELECT 1 FROM users WHERE phone=? LIMIT 1", (ph,)) + hit = hit or cur.fetchone() is not None + if not hit and wx: + cur.execute( + "SELECT 1 FROM users WHERE wx_mini_openid=? OR wx_openid=? LIMIT 1", + (wx, wx), + ) + hit = hit or cur.fetchone() is not None + if hit: + cur.execute("DELETE FROM accounts WHERE id=?", (acct_id,)) + deleted += 1 + conn.commit() + return deleted + + +def main() -> None: + conn = sqlite3.connect(config.DATABASE_URL.replace("sqlite+aiosqlite:///", "")) + try: + dup = cleanup_users_duplicates(conn) + if dup: + print("⚠️ users 存在重复非空唯一值(需先手动核对):") + for d in dup: + print(" -", d) + print("请先人工确认归属后处理,再执行迁移唯一索引。") + else: + print("✅ users 唯一性检查通过(无重复 登录名/手机号/微信身份)") + for tbl in LEGACY_TABLES: + try: + deleted = cleanup_legacy_accounts(conn) + if deleted: + print(f"✅ 已清理多余账号:{tbl} 移除 {deleted} 条已被 users 接管的行") + except sqlite3.OperationalError as exc: + print(f"⚠️ {tbl} 清理跳过:{exc}") + # 清空后的 accounts 若为空即视为多余账号仓彻底闲置 + n = conn.execute("SELECT COUNT(*) FROM accounts").fetchone()[0] + print(f"旧 accounts 表剩余 {n} 条(0=已彻底清理)") + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/db/seed.py b/scripts/db/seed.py index e0b6c44..e88f2b7 100644 --- a/scripts/db/seed.py +++ b/scripts/db/seed.py @@ -24,17 +24,17 @@ log = logging.getLogger("db.seed") def _seed_training() -> None: - """培训种子(唯一总库 app.db):超管账号 + 排期 + 在线课程(幂等)。""" + """培训种子(唯一总库 app.db):排期 + 在线课程(幂等)。 + + 不再向旧 ``accounts`` 表种子账号 —— 平台规范账号已由 platform_seed 写入 + ``users``(如 u_demo_01/pine),旧 accounts 为「多余账号仓」已被清理 + (见 scripts/db/cleanup_accounts.py),此处不再重复造账号。 + """ import sqlite3 - import json from app.training import db as tdb conn = sqlite3.connect(tdb.DB_PATH) conn.row_factory = sqlite3.Row try: - if conn.execute("SELECT COUNT(*) AS c FROM accounts").fetchone()["c"] == 0: - from app.training.auth import hash_password - conn.execute("INSERT INTO accounts (id,username,password,name,identities,created_at) VALUES (?,?,?,?,?,?)", - ("U-PINE", "pine", hash_password("123456"), "Pine", json.dumps(["admin"]), tdb.now_iso())) if conn.execute("SELECT COUNT(*) AS c FROM events").fetchone()["c"] == 0: for e in tdb.SEED_EVENTS: conn.execute("INSERT INTO events (id,type,mode,title,subtitle,desc,location,host,image,link,start_at,duration_min,capacity,status) VALUES (:id,:type,:mode,:title,:subtitle,:desc,:location,:host,:image,:link,:start_at,:duration_min,:capacity,:status)", e) @@ -87,8 +87,11 @@ async def _migrate_accounts_to_users(db) -> None: ) created += 1 await _ensure_opc_identity(db, user["id"]) + # 迁移即接管:删掉旧 accounts 行,避免同一逻辑账号落在两套表(多余账号)。 + conn.execute("DELETE FROM accounts WHERE id=?", (row["id"],)) migrated += 1 - log.info("accounts→users 迁移完成:共 %s 条,新建 %s 条", migrated, created) + conn.commit() + log.info("accounts→users 迁移完成:共 %s 条,新建 %s 条(旧 accounts 行已清理)", migrated, created) finally: conn.close()