Archived
为什么:单文件 1100~1900 行的巨石模块让「改一处要通读全文」;同时注释里残留 大量已删台账的编号,以及「Flask/FastAPI 双栈迁移期」这类已失实的架构描述, 会误导后来者按不复存在的架构去改代码。 拆分(对外导入路径不变,行为零变更): - music_downloader.py -> music_downloader/(models/downloader/cover/_http/_log/_concurrency) - auth_system.py -> auth_system/(db/security/users/credits/ledger/mail/cards/backup...) - music_api.py -> music_api/(api/client/crypto/qr/cookies/compat/constants/errors) - services/runtime.py -> services/(api_service/_shared/_taskcore/downloads/batch/podcast/ streaming/tasks);services/runtime.py 保留为兼容门面 兼容性做法:各包用 __init__.py 以 __all__ 原样再导出全部符号,既有调用点 (auth_system.xxx / services.xxx / from music_api import Y)零改动;路径常量改为经包属性 动态读取(auth_system.DB_PATH),否则测试的 monkeypatch 会静默失效并写到生产库。 修复 9 处代码损伤(上一轮注释清理把 `()` 当「空括号残留」删掉所致;用「与拆分前逐字 等价」校验逐一定位,静态检查只会报出其中 4 处): - core/config.py ).strip() or '*' - core/state.py threading.Lock() x2 - qr_login.py datetime.now() / .strip() - music_downloader/downloader.py .lower() - services/podcast.py set() / time.time() x2 清理与删除: - 删除 部署文档.md / 功能介绍.md / README_私有包警告.txt - 清理指向已删台账与文档的过时编号注释;把「迁移期 / 两栈 / 双框架 / 已迁移」等 失实描述改写为现状描述(保留 `<-> Flask /path` 映射表等仍有价值的契约来源说明) - _mkpkg.py:公开包剔除清单去掉已不存在的文档名;私有包警告改为自包含 - pyproject.toml:补 pythonpath = ["."],使裸跑 pytest 不再 import core 失败 验证:ruff 全通过;pyright 0 errors / 0 warnings(102 文件);pytest 144 passed; 真实 uvicorn 冒烟 6 条路由符合预期且生产 data/ 指纹未变;忽略注释的逐字等价性校验通过。
239 lines
9.3 KiB
Python
239 lines
9.3 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""用户增删改查、登录校验与状态。
|
||
|
||
(自 auth_system.py 拆出,行为与原实现一致。)
|
||
"""
|
||
|
||
import json
|
||
import sqlite3
|
||
import time
|
||
from typing import Any
|
||
|
||
from .db import (
|
||
_DB_LOCK,
|
||
_q,
|
||
_x,
|
||
get_conn,
|
||
)
|
||
from .security import (
|
||
_hash_new,
|
||
_normalize_email,
|
||
_verify_password,
|
||
)
|
||
|
||
|
||
def create_user(username: str, password: str, email: str = '', credits: int = 0,
|
||
role: str = 'user', device_id: str = '', ip: str = '', ua: str = '',
|
||
register_ip_limit: int = 0, register_ip_window: int = 86400,
|
||
register_device_once: bool = False) -> sqlite3.Row | None:
|
||
# 余额只接受 -1(不限)或 >= 0;非法负值一律按 0 处理,禁止出现 -2 这类"假不限"
|
||
try:
|
||
credits = int(credits)
|
||
except (TypeError, ValueError):
|
||
credits = 0
|
||
if credits < -1:
|
||
credits = 0
|
||
# 注册入口传 register_ip_limit=3 —— 同 IP 24h 注册数检查挪进
|
||
# _DB_LOCK 事务内与 INSERT 原子完成,堵住"锁外检查+并发插入"的 TOCTOU 刷号;
|
||
# 管理建号与不限制场景传 0 关闭。超限抛 ValueError 由调用方转 400/429。
|
||
now = int(time.time())
|
||
with _DB_LOCK:
|
||
conn = get_conn()
|
||
try:
|
||
if register_ip_limit > 0 and ip:
|
||
n = conn.execute('SELECT COUNT(*) AS n FROM users WHERE register_ip = ? AND created_at > ?',
|
||
(ip, now - register_ip_window)).fetchone()[0]
|
||
if n >= register_ip_limit:
|
||
raise ValueError('当前网络注册过于频繁,请 24 小时后再试或联系管理员')
|
||
if register_device_once and device_id:
|
||
# 设备赠送闸并入同一事务——锁外 device_used 预检有 TOCTOU,
|
||
# 并发同设备双注册可各得一次赠送;锁内复查命中则本次建号不赠送(账号照常创建)
|
||
n = conn.execute("SELECT COUNT(*) AS n FROM users WHERE trial_device = ? AND role != 'admin'",
|
||
(device_id,)).fetchone()[0]
|
||
if n > 0:
|
||
credits = 0
|
||
conn.execute(
|
||
'INSERT INTO users (username, password_hash, salt, email, role, duration_seconds, '
|
||
'trial_device, register_ip, register_ua, last_active, created_at) '
|
||
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||
(username, _hash_new(password), '', email, role,
|
||
-1 if role == 'admin' else int(credits),
|
||
device_id, ip, ua, now, now))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
return get_user_by_name(username)
|
||
|
||
|
||
def get_user_by_name(username: str) -> sqlite3.Row | None:
|
||
rows = _q('SELECT * FROM users WHERE username = ?', (username,))
|
||
return rows[0] if rows else None
|
||
|
||
|
||
def get_user_by_email(email: str) -> sqlite3.Row | None:
|
||
"""按邮箱查用户(注册/建号邮箱查重的友好提示;库侧另有唯一索引兜底并发)"""
|
||
email = _normalize_email(email)
|
||
if not email:
|
||
return None
|
||
rows = _q('SELECT * FROM users WHERE email = ? LIMIT 1', (email,))
|
||
return rows[0] if rows else None
|
||
|
||
|
||
def register_ip_exceeded(ip: str, limit: int = 3, window: int = 86400) -> bool:
|
||
"""同 IP 24h 内注册数超限(默认 3 次)——防 IP 轮换刷免费赠送次数;
|
||
设备指纹可被客户端伪造,IP 维度是第二道闸。家庭 NAT 场景超限可由管理员建号兜底。"""
|
||
if not ip:
|
||
return False
|
||
rows = _q('SELECT COUNT(*) AS n FROM users WHERE register_ip = ? AND created_at > ?',
|
||
(ip, int(time.time() - window)))
|
||
return bool(rows and rows[0]['n'] >= limit)
|
||
|
||
|
||
def get_user_by_id(uid: int) -> sqlite3.Row | None:
|
||
rows = _q('SELECT * FROM users WHERE id = ?', (uid,))
|
||
return rows[0] if rows else None
|
||
|
||
|
||
def verify_login(username: str, password: str) -> sqlite3.Row | None:
|
||
"""校验用户名密码。密码正确即返回用户行(含被禁用/封禁账号——
|
||
状态语义由登录路由判断:禁用显示"已被禁用"、封禁显示封禁原因。
|
||
原实现把禁用账号提前过滤成 None,路由的"账号已被禁用"分支永不可达)"""
|
||
user = get_user_by_name(username)
|
||
if user:
|
||
ok, needs_upgrade = _verify_password(user['password_hash'], password, user['salt'])
|
||
if ok:
|
||
if needs_upgrade:
|
||
# 存量 SHA-256 哈希在登录成功时透明升级为 PBKDF2
|
||
_x('UPDATE users SET password_hash = ?, salt = \'\' WHERE id = ?',
|
||
(_hash_new(password), user['id']))
|
||
return user
|
||
return None
|
||
|
||
|
||
def list_users() -> list[dict[str, Any]]:
|
||
rows = _q('SELECT * FROM users ORDER BY id DESC')
|
||
return [_user_public(r, full=True) for r in rows]
|
||
|
||
|
||
def delete_user(uid: int) -> bool:
|
||
"""删除用户(卡密 used_by 为用户名字符串无外键——删除时同步
|
||
标注"(已注销#id)",管理端卡密列表不再显示悬空的已删用户名)"""
|
||
u = get_user_by_id(uid)
|
||
if not u or u['role'] == 'admin':
|
||
return False
|
||
with _DB_LOCK:
|
||
conn = get_conn()
|
||
try:
|
||
name = u['username']
|
||
conn.execute('DELETE FROM users WHERE id = ? AND role != ?', (uid, 'admin'))
|
||
conn.execute("UPDATE cards SET used_by = used_by || ? WHERE used_by = ?",
|
||
(f'(已注销#{uid})', name))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
return True
|
||
|
||
|
||
def reset_password(uid: int, new_password: str) -> bool:
|
||
"""重置密码(同时递增 login_seq,让所有已签发会话立即失效)"""
|
||
with _DB_LOCK:
|
||
conn = get_conn()
|
||
try:
|
||
conn.execute('UPDATE users SET password_hash = ?, salt = \'\', login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?',
|
||
(_hash_new(new_password), uid))
|
||
conn.commit()
|
||
return True
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def set_user_status(uid: int, status: int) -> bool:
|
||
"""启用 / 禁用账号(1 启用,0 禁用);管理员账号不可禁用"""
|
||
if status not in (0, 1):
|
||
return False
|
||
row = _q('SELECT role FROM users WHERE id = ?', (uid,))
|
||
if row and row[0]['role'] == 'admin':
|
||
return False
|
||
return bool(_x('UPDATE users SET status = ? WHERE id = ?', (status, uid)))
|
||
|
||
|
||
def ban_user(uid: int, reason: str = '') -> bool:
|
||
"""封禁账号(管理员不可封)"""
|
||
row = _q('SELECT role FROM users WHERE id = ?', (uid,))
|
||
if not row or row[0]['role'] == 'admin':
|
||
return False
|
||
return bool(_x('UPDATE users SET status = 2, ban_reason = ? WHERE id = ?',
|
||
(str(reason or '')[:200], uid)))
|
||
|
||
|
||
def unban_user(uid: int) -> bool:
|
||
"""解封账号,恢复正常(清空触发封禁的省份历史,否则下次登录立即再封)"""
|
||
return bool(_x('UPDATE users SET status = 1, ban_reason = \'\', recent_provinces = \'[]\' WHERE id = ?', (uid,)))
|
||
|
||
|
||
def set_user_credits(uid: int, count: int) -> None:
|
||
"""设置用户剩余次数(-1 表示不限)"""
|
||
count = -1 if count < 0 else int(count)
|
||
_x('UPDATE users SET duration_seconds = ? WHERE id = ?', (count, uid))
|
||
|
||
|
||
def set_user_password(uid: int, password: str) -> None:
|
||
# 新哈希 + 递增 login_seq(所有旧会话立即失效)
|
||
with _DB_LOCK:
|
||
conn = get_conn()
|
||
try:
|
||
conn.execute('UPDATE users SET password_hash = ?, salt = \'\', '
|
||
'login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?',
|
||
(_hash_new(password), uid))
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def set_user_netease_cookie(username: str, cookie: str) -> None:
|
||
_x('UPDATE users SET netease_cookie = ? WHERE username = ?', (cookie, username))
|
||
|
||
|
||
def _user_public(r: sqlite3.Row, full: bool = False) -> dict[str, Any]:
|
||
d = {
|
||
'id': r['id'],
|
||
'username': r['username'],
|
||
'email': r['email'],
|
||
'role': r['role'],
|
||
'duration_seconds': r['duration_seconds'],
|
||
'created_at': r['created_at'],
|
||
'last_login': r['last_login'],
|
||
'ann_seen': r['ann_seen'],
|
||
}
|
||
if full:
|
||
d.update({
|
||
'status': r['status'] if 'status' in r.keys() else 1,
|
||
'trial_device': r['trial_device'],
|
||
'register_ip': r['register_ip'],
|
||
'register_ua': r['register_ua'],
|
||
# 列表不再携带明文网易云 Cookie(前端只用布尔值;原文按需走
|
||
# /admin/site/user/cookie 单查接口,避免一次列表请求扩散全部用户凭据)
|
||
'has_netease_cookie': bool(r['netease_cookie']),
|
||
'ban_reason': r['ban_reason'] if 'ban_reason' in r.keys() else '',
|
||
'last_province': _last_province(r),
|
||
})
|
||
return d
|
||
|
||
|
||
def _last_province(r: sqlite3.Row) -> str:
|
||
try:
|
||
history = json.loads(r['recent_provinces'] if 'recent_provinces' in r.keys() and r['recent_provinces'] else '[]')
|
||
return history[-1].get('p', '') if history else ''
|
||
except Exception:
|
||
return ''
|
||
|
||
|
||
# ============ 免费体验(设备识别) ============
|
||
|
||
|
||
def device_used(device_id: str) -> bool:
|
||
if not device_id:
|
||
return False
|
||
rows = _q("SELECT id FROM users WHERE trial_device = ? AND role != 'admin' LIMIT 1", (device_id,))
|
||
return bool(rows)
|