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:
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user