feat(auth): 账号唯一性+清理多余账号 —— 迁移0011加phone/微信openid非空部分唯一索引

- 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不再重复造账号、迁移后删旧行
This commit is contained in:
Pine
2026-08-25 22:47:27 +08:00
parent 8a00712750
commit c2f4c13ab3
4 changed files with 210 additions and 7 deletions
+113
View File
@@ -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()
+10 -7
View File
@@ -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()