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,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")
|
||||||
@@ -219,6 +219,30 @@ class UserRepository:
|
|||||||
u = await self.session.get(User, user_id)
|
u = await self.session.get(User, user_id)
|
||||||
return _user_to_dict(u) if u else None
|
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]:
|
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))]
|
return [_user_to_dict(u) for u in await self.session.scalars(select(User).order_by(User.created_at))]
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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 sqlite3
|
||||||
import json
|
|
||||||
from app.training import db as tdb
|
from app.training import db as tdb
|
||||||
conn = sqlite3.connect(tdb.DB_PATH)
|
conn = sqlite3.connect(tdb.DB_PATH)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
try:
|
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:
|
if conn.execute("SELECT COUNT(*) AS c FROM events").fetchone()["c"] == 0:
|
||||||
for e in tdb.SEED_EVENTS:
|
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)
|
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
|
created += 1
|
||||||
await _ensure_opc_identity(db, user["id"])
|
await _ensure_opc_identity(db, user["id"])
|
||||||
|
# 迁移即接管:删掉旧 accounts 行,避免同一逻辑账号落在两套表(多余账号)。
|
||||||
|
conn.execute("DELETE FROM accounts WHERE id=?", (row["id"],))
|
||||||
migrated += 1
|
migrated += 1
|
||||||
log.info("accounts→users 迁移完成:共 %s 条,新建 %s 条", migrated, created)
|
conn.commit()
|
||||||
|
log.info("accounts→users 迁移完成:共 %s 条,新建 %s 条(旧 accounts 行已清理)", migrated, created)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user