From 96af56cc2706cd9d628437b01972d9b7e373da20 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:13:19 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20auth=5Fsystem=20=E5=85=A8=E9=9D=A2?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E5=8C=96=EF=BC=88aiosqlite=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E6=8B=86=E6=8E=89=20web/services=20=E5=B1=82=E7=9A=84=20to=5Ft?= =?UTF-8?q?hread=20=E6=A1=A5=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拆掉两类垫片的最后一处:auth_system 原为同步 sqlite3 栈,web 层与 services 的 worker 只能靠 asyncio.to_thread 逐个桥接才不阻塞事件循环——每次 DB 访问都白付一次 线程池调度,且「同步调异步」的边界散落在 100+ 个调用点。B1 已把 worker 变成协程, 本批把访问层本身也迁到原生异步,两条线在此合拢。 改动(按子系统): - auth_system:db.py 照 core/db.py 现有范式重建为原生异步访问层(aiosqlite + WAL) ——读路径 _q 每次独立连接、无锁、可并发;写路径 _x / write_transaction 用 asyncio.Lock 串行化,写者不阻塞读者。users / cards / credits / ledger / logs / settings / mail / geo / backup 九模块随之全部 async/await;security.py 是纯计算 (pbkdf2/normalize)不动。__init__ 去掉 _DB_LOCK 与 get_conn 导出。 - web:app / deps / billing / tasks_registry 与 routers/{admin_site,auth,music, register,user} 直接 await,拆掉包裹的 to_thread。deps.secret_key 改 async, 连带 _parse_cookie 也 async(会话解析要读密钥)。 - services:_taskcore / tasks / downloads / podcast 的账本调用与查询直接 await。 4 处 start_task 调用点(download.py)确认已 await。 设计取舍(为什么这么做,而不是直接包一层): - 锁按**事件循环**隔离(_LOOP_LOCKS 以 id(loop) 为键):asyncio.Lock 绑定创建它的 loop,应用主 loop 与测试各自的 loop 并存,跨 loop 复用会抛 "bound to a different event loop"。 - WAL 切换用**独立锁** _wal_lock 而非 _write_lock:execute() 已持写锁,再在锁内 connect→_configure 会自锁死(asyncio.Lock 非重入)。切换 + 标记在专用锁内串行并 二次复查,否则并发首连会同时执行 PRAGMA journal_mode=WAL 争排他锁报 database is locked(core/db.py 同款教训,实测复现)。 - force_wal 保留:journal_mode 持久化在库文件头,库文件被删重建时新文件不继承, 必须重设。exists() 判定须在 aiosqlite.connect **之前**(connect 会创建空文件)。 - connect() 用 DB_PATH.parent 兜底建目录,优于 DATA_DIR:生产两者相同,但测试 monkeypatch 只换 DB_PATH 时兜底仍能命中正确目录。 留在 to_thread 上的仅剩**正当的阻塞卸载**,不是同步门面:PBKDF2 的 10 万次迭代 (登录链路每次都要跑)、smtplib 发信、备份文件的 copy2 / os.replace、secret.key 的文件 IO。原有 aria2 / downloader / zip 侧的 to_thread 未动。 不变量逐条保持(迁移只改执行模型,不改语义): - ledger_settle 幂等:账本非 open 直接 return 0,重启后重放不重复退款; - 终态先到先得:_mark_task_terminal 对已 done/error/canceled 的任务直接 return, 堵住 canceled→error 的非法迁移边; - settle 必须在任务锁**外**调用(ledger_settle 要抢 SQLite 写锁,busy 最长 15s, 持任务锁会让全站进度/提交/取消随库停摆); - reserve_and_open 的预扣与开账同事务,消除原两步之间的崩溃丢账窗口。 路径真源红线:DATA_DIR / DB_PATH / BACKUP_DIR / SECRET_KEY_PATH 仍是 auth_system.db 的**模块全局**(用与 core.db 相同的表达式,但**不是**它的别名)。测试只 patch auth_system.db.*,若改成 core.db 的别名会静默把测试写到生产库。已核 data/site.db 读写前后 sha256 完全一致。 顺带清掉 get_conn:迁移后它只剩「转调 connect()」一层空壳、全仓零消费者(测试 直接开裸连接、备份用 db.connect()),保留等于留一条「拿裸连接自己写」的口子。 测试:19 个文件同步迁移——10 个文件的 web.deps.secret_key 替身改 async; test_db_conn.py 的多线程首连用例按异步语义拆成 2 条(单 loop gather(16) 覆盖批量 并发首连、WAL 就绪后 8 线程各自 loop 建连覆盖跨 loop);test_billing_scope.py 去掉 多余的 @pytest.mark.anyio;被 await 且**正常返回**的 monkeypatch 替身一律改 async (仅 raise 的同步替身仍可用)。 门禁:ruff 全过 / pyright 0-0-0(standard 模式含 tests 与顶层模块)/ pytest 384 项全绿(0 失败 0 error 0 skipped;383→384 因 test_db_conn 拆用例)。 data/site.db 读写前后 sha256 一致(a19f5db…6487),无 -wal/-shm 边车残留。 下一步 B3:删 core/sync_http.py、core/async_bridge.py、core/http_errors.py 及 tests/test_async_bridge.py——三者在生产侧已零消费者(http_errors 仅 sync_http 自用,requests 已非声明依赖)。 --- auth_system/__init__.py | 4 - auth_system/backup.py | 180 +++++++++++++-------- auth_system/cards.py | 164 ++++++++------------ auth_system/credits.py | 100 ++++++------ auth_system/db.py | 249 +++++++++++++++++++++--------- auth_system/geo.py | 16 +- auth_system/ledger.py | 201 +++++++++++------------- auth_system/logs.py | 36 ++--- auth_system/mail.py | 156 +++++++++---------- auth_system/settings.py | 22 +-- auth_system/users.py | 186 +++++++++++----------- services/_taskcore.py | 16 +- services/downloads.py | 9 +- services/podcast.py | 6 +- services/tasks.py | 11 +- tests/test_admin_backup_upload.py | 3 +- tests/test_auth_db_concurrency.py | 40 ++--- tests/test_auth_init_db.py | 19 +-- tests/test_auth_routes.py | 26 +++- tests/test_billing_scope.py | 55 +++---- tests/test_db_conn.py | 161 +++++++++++-------- tests/test_deps.py | 7 +- tests/test_download_billing.py | 23 +-- tests/test_download_sync.py | 20 ++- tests/test_golden.py | 10 +- tests/test_login_flow.py | 35 +++-- tests/test_monitor_jobs.py | 37 +++-- tests/test_podcast_worker.py | 16 +- tests/test_register.py | 27 +++- tests/test_register_flow.py | 38 +++-- tests/test_root_mode.py | 9 +- tests/test_single_worker.py | 18 +-- tests/test_song_types.py | 7 +- tests/test_zip_workers.py | 28 ++-- web/app.py | 12 +- web/billing.py | 31 ++-- web/deps.py | 10 +- web/routers/admin_site.py | 95 ++++++------ web/routers/auth.py | 41 +++-- web/routers/music.py | 2 +- web/routers/register.py | 50 +++--- web/routers/user.py | 2 +- web/tasks_registry.py | 2 +- 43 files changed, 1171 insertions(+), 1009 deletions(-) diff --git a/auth_system/__init__.py b/auth_system/__init__.py index 7a541d4..733ac32 100644 --- a/auth_system/__init__.py +++ b/auth_system/__init__.py @@ -53,7 +53,6 @@ from .credits import ( reserve_credits, ) from .db import ( - _DB_LOCK, BACKUP_DIR, DATA_DIR, DB_PATH, @@ -63,7 +62,6 @@ from .db import ( SESSION_KEY, _q, _x, - get_conn, get_secret_key, init_db, ) @@ -161,7 +159,6 @@ __all__ = [ '_CODE_FAILS', '_CODE_FAILS_MAX', '_CODE_PURPOSES', - '_DB_LOCK', '_EMAIL_SENT_24H', '_LOG_WRITE_COUNTER', '_PBKDF2_ITERS', @@ -201,7 +198,6 @@ __all__ = [ 'format_credits', 'generate_cards', 'get_announcement', - 'get_conn', 'get_secret_key', 'get_setting', 'get_smtp_config', diff --git a/auth_system/backup.py b/auth_system/backup.py index 2569e37..144a2b6 100644 --- a/auth_system/backup.py +++ b/auth_system/backup.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- """数据库备份、恢复与自动备份线程。 -(自 auth_system.py 拆出,行为与原实现一致。) +(自 auth_system.py 拆出,行为与原实现一致;DB 备份改用 aiosqlite 在线备份 +API,文件系统/复制等阻塞 IO 经 asyncio.to_thread 卸载。) """ +import asyncio import math import os import shutil @@ -14,6 +16,8 @@ import time from pathlib import Path from typing import Any +import aiosqlite + import auth_system.db as db # 绝对导入子模块:不经过包属性,避免与包 __init__ 互指成环 from .settings import ( @@ -24,17 +28,16 @@ from .settings import ( # ============ 数据库备份 ============ -# 备份命名专用锁:create_backup 不再持有全局 _DB_LOCK(不阻塞写请求), -# 但同秒并发的两次备份仍需保证文件名唯一 +# 备份命名专用锁:占位命名不阻塞写请求,但同秒并发的两次备份仍需保证文件名唯一 _BACKUP_NAME_LOCK = threading.Lock() -def create_backup() -> str: - """创建备份(唯一文件名——同秒冲突自动追加序号,不再互相覆盖)。 +def _reserve_backup_path() -> Path: + """锁内挑选唯一文件名并创建占位文件(同步微秒级本地 IO,仅建一个空文件)。 - WAL 模式下用独立只读连接走 SQLite 在线备份 API:拿到的是一致性快照, - 且不持有 _DB_LOCK——原实现备份期间阻塞全站写请求,大库时会卡住所有用户。""" - db.BACKUP_DIR.mkdir(parents=True, exist_ok=True) + 先占位(而不是备份完再命名)保证同秒并发下命名唯一:备份前的空文件与 + sqlite3.connect 建出的空库等价,随后在线备份写入其内容。 + """ with _BACKUP_NAME_LOCK: base = 'backup_' + time.strftime('%Y%m%d_%H%M%S') name = base + '.db' @@ -42,32 +45,46 @@ def create_backup() -> str: while (db.BACKUP_DIR / name).exists(): name = f'{base}-{k}.db' k += 1 - # 占位创建目标文件(锁内),保证并发下同秒命名仍唯一 - dest = sqlite3.connect(str(db.BACKUP_DIR / name)) - conn = db.get_conn() + dest = db.BACKUP_DIR / name + dest.touch() + return dest + + +async def create_backup() -> str: + """创建备份(唯一文件名——同秒冲突自动追加序号,不再互相覆盖)。 + + WAL 模式下用独立只读连接走 SQLite 在线备份 API(aiosqlite Connection.backup): + 拿到的是一致性快照,且不持有写锁——原实现备份期间阻塞全站写请求, + 大库时会卡住所有用户。""" + db.BACKUP_DIR.mkdir(parents=True, exist_ok=True) + dest_path = await asyncio.to_thread(_reserve_backup_path) + name = dest_path.name try: + conn = await db.connect() try: - conn.backup(dest) + dest = await aiosqlite.connect(str(dest_path)) + try: + await conn.backup(dest) + finally: + await dest.close() finally: - dest.close() + await conn.close() # 任何备份产生后统一裁剪(手动/自动/上传同配额) try: - prune_backups(20, 5) + await prune_backups(20, 5) except Exception: pass return name except Exception: # 备份失败:清掉占位文件,不留半个坏备份 try: - (db.BACKUP_DIR / name).unlink(missing_ok=True) + await asyncio.to_thread(dest_path.unlink, missing_ok=True) except OSError: pass raise - finally: - conn.close() -def list_backups() -> list[dict[str, Any]]: +def _list_backups_sync() -> list[dict[str, Any]]: """备份列表(按文件修改时间倒序——按名字排序会让 upload_ 永远排前,误删新自动备份)""" if not db.BACKUP_DIR.exists(): return [] @@ -83,13 +100,21 @@ def list_backups() -> list[dict[str, Any]]: return out -def backup_path(name: str) -> Path | None: +async def list_backups() -> list[dict[str, Any]]: + return await asyncio.to_thread(_list_backups_sync) + + +def _backup_path_sync(name: str) -> Path | None: name = Path(name).name # 防目录穿越 p = db.BACKUP_DIR / name return p if p.exists() and p.suffix == '.db' else None -def _backup_is_valid_db(src: Path) -> bool: +async def backup_path(name: str) -> Path | None: + return await asyncio.to_thread(_backup_path_sync, name) + + +def _backup_is_valid_db_sync(src: Path) -> bool: """深度校验备份文件:SQLite 头 + 能真正打开 + 含 users 表,三者缺一不可""" try: with open(src, 'rb') as f: @@ -105,22 +130,33 @@ def _backup_is_valid_db(src: Path) -> bool: return False +async def _backup_is_valid_db(src: Path) -> bool: + """同步 sqlite3 探测(打开只读库)经 to_thread 卸载,不阻塞事件循环。""" + return await asyncio.to_thread(_backup_is_valid_db_sync, src) + + # 恢复前静止钩子——由应用层注册(取消全部在途下载任务),避免 worker 按旧库账目结算 _before_restore_hook = None def register_before_restore_hook(fn) -> None: + """注册恢复前钩子(纯内存登记,无 IO,保持同步)。""" global _before_restore_hook _before_restore_hook = fn -def restore_backup(name: str) -> None: +async def restore_backup(name: str) -> None: """恢复备份(先验证、留回滚、后替换、失败自动回滚,源备份文件保留不移动) - 恢复前先通过钩子取消全部在途任务(worker 不再引用旧库用户结算)""" - src = backup_path(name) + 恢复前先通过钩子取消全部在途任务(worker 不再引用旧库用户结算) + + 整个替换过程持**当前事件循环的写锁**(经 db._write_lock())串行化;新库 + 结构初始化调用 db._init_db_locked(不自行取锁)以避免 asyncio.Lock 非重入 + 造成的自锁死。 + """ + src = await backup_path(name) if not src: raise ValueError('备份文件不存在') - if not _backup_is_valid_db(src): + if not await _backup_is_valid_db(src): raise ValueError('不是有效的站点数据库备份(校验失败,未做任何修改)') if _before_restore_hook is not None: try: @@ -128,44 +164,53 @@ def restore_backup(name: str) -> None: except Exception: pass safety = db.BACKUP_DIR / ('pre_restore_' + time.strftime('%Y%m%d_%H%M%S') + '.db') - with db._DB_LOCK: + async with db._write_lock(): # 0) 先尽力清 WAL 并合并进主库(被占用导致 checkpoint 忙时明确失败,不假装成功); # 必须在留安全副本之前做——否则副本缺 WAL 里未合并的最近提交,回滚点不完整 - conn = db.get_conn() + conn = await db.connect() try: - row = conn.execute('PRAGMA wal_checkpoint(TRUNCATE)').fetchone() + row = await (await conn.execute('PRAGMA wal_checkpoint(TRUNCATE)')).fetchone() busy = bool(row and row[0] == 1) # 1 = busy + if busy: + raise RuntimeError('数据库当前有其他读写在途(checkpoint 忙),请稍后重试恢复') + # 1) checkpoint 后留安全副本(包含全部已提交数据,可回滚) + await asyncio.to_thread(shutil.copy2, str(db.DB_PATH), str(safety)) + # 2) 源备份复制到临时文件(不动源备份本身) + tmp = db.DB_PATH.with_suffix('.db.restore_tmp') + await asyncio.to_thread(shutil.copy2, str(src), str(tmp)) finally: - conn.close() - if busy: - raise RuntimeError('数据库当前有其他读写在途(checkpoint 忙),请稍后重试恢复') - # 1) checkpoint 后留安全副本(包含全部已提交数据,可回滚) - shutil.copy2(str(db.DB_PATH), str(safety)) - # 2) 源备份复制到临时文件(不动源备份本身) - tmp = db.DB_PATH.with_suffix('.db.restore_tmp') - shutil.copy2(str(src), str(tmp)) + # 先关闭旧库连接:Windows 下替换被打开的库文件会失败 + await conn.close() # 3) 原子替换 + 立即校验 - os.replace(str(tmp), str(db.DB_PATH)) + await asyncio.to_thread(os.replace, str(tmp), str(db.DB_PATH)) + newconn = await db.connect() try: - db.init_db() + await db._init_db_locked(newconn) except Exception: + await newconn.close() # 替换后初始化失败:回滚安全副本,恢复原库 - shutil.copy2(str(safety), str(db.DB_PATH)) - db.init_db() + await asyncio.to_thread(shutil.copy2, str(safety), str(db.DB_PATH)) + rc = await db.connect() + try: + await db._init_db_locked(rc) + finally: + await rc.close() raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') from None - # 裁剪移出 _DB_LOCK——glob/unlink 的 I/O 不阻塞全站写 + finally: + await newconn.close() + # 裁剪移出写锁——glob/unlink 的 I/O 不阻塞全站写 try: - prune_backups(20, 5) + await prune_backups(20, 5) except Exception: pass -def delete_backup(name: str) -> bool: - p = backup_path(name) +async def delete_backup(name: str) -> bool: + p = await backup_path(name) # 允许删除 pre_restore_ 安全副本(配额裁剪已保证至少保留 # 最近 5 份;原实现一刀切拒删导致恢复频繁时无界累积且管理员手动也删不掉) if p: - p.unlink() + await asyncio.to_thread(p.unlink) return True return False @@ -176,43 +221,49 @@ _backup_thread_started = False _BACKUP_ERR_TS = {'ts': 0.0, 'n': 0} # 备份失败限频记录(失败不能零痕迹) -def prune_backups(keep_normal: int = 20, keep_pre_restore: int = 5) -> None: +async def prune_backups(keep_normal: int = 20, keep_pre_restore: int = 5) -> None: """备份配额裁剪(从自动备份循环抽出为独立函数)—— 非pre_restore 保留最近 N 份、pre_restore_ 安全副本保留最近 M 份。 在 create_backup/上传备份/恢复备份后统一调用(原实现只在自动备份循环里 裁剪,手动备份/上传/恢复会让 data/backups 无界累积)。""" - baks = [b for b in list_backups() if not b['name'].startswith('pre_restore_')] + baks = [b for b in await list_backups() if not b['name'].startswith('pre_restore_')] for b in baks[keep_normal:]: try: - delete_backup(b['name']) + await delete_backup(b['name']) except Exception: pass - pre = [b for b in list_backups() if b['name'].startswith('pre_restore_')] + pre = [b for b in await list_backups() if b['name'].startswith('pre_restore_')] for b in pre[keep_pre_restore:]: try: - (db.BACKUP_DIR / b['name']).unlink() + await asyncio.to_thread((db.BACKUP_DIR / b['name']).unlink) except OSError: pass +async def _auto_backup_tick() -> None: + """自动备份单次检查(在独立事件循环里运行,见 _auto_backup_loop)。""" + interval = float(await get_setting('backup_interval_hours', '0') or 0) + # 非有限值(NaN/inf)或负数一律视为关闭 + if not math.isfinite(interval): + interval = 0 + await set_setting('backup_interval_hours', '0') + if interval > 0: + last = float(await get_setting('last_backup_ts', '0') or 0) + if not math.isfinite(last): + last = 0 + if time.time() - last >= interval * 3600: + await create_backup() + await set_setting('last_backup_ts', str(time.time())) + # 仅保留最近 20 份(按修改时间,不按文件名) + # pre_restore_* 安全副本不计入 20 份配额,仅各自保留最近 5 份 + await prune_backups(20, 5) + + def _auto_backup_loop() -> None: + """自动备份后台**线程**主循环(线程无法 await,每次 tick 用 asyncio.run 起独立 loop)。""" while True: try: - interval = float(get_setting('backup_interval_hours', '0') or 0) - # 非有限值(NaN/inf)或负数一律视为关闭 - if not math.isfinite(interval): - interval = 0 - set_setting('backup_interval_hours', '0') - if interval > 0: - last = float(get_setting('last_backup_ts', '0') or 0) - if not math.isfinite(last): - last = 0 - if time.time() - last >= interval * 3600: - create_backup() - set_setting('last_backup_ts', str(time.time())) - # 仅保留最近 20 份(按修改时间,不按文件名) - # pre_restore_* 安全副本不计入 20 份配额,仅各自保留最近 5 份 - prune_backups(20, 5) + asyncio.run(_auto_backup_tick()) except Exception: # 定时备份失败绝不能静默(磁盘满/权限问题时 owner 以为有备份、 # 出事时没有)——限频告警,每 10 分钟最多一条(对齐监控线程口径) @@ -228,6 +279,7 @@ def _auto_backup_loop() -> None: def start_auto_backup() -> None: + """启动自动备份后台线程(纯线程启动,无 IO,保持同步)。""" global _backup_thread_started if _backup_thread_started: return diff --git a/auth_system/cards.py b/auth_system/cards.py index e35de27..3ba49aa 100644 --- a/auth_system/cards.py +++ b/auth_system/cards.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """次数卡密生成 / 兑换 / 管理。 -(自 auth_system.py 拆出,行为与原实现一致。) +(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。) """ import secrets @@ -9,16 +9,15 @@ import time from typing import Any from .db import ( - _DB_LOCK, _q, - get_conn, + write_transaction, ) # ============ 卡密 ============ -def generate_cards(count: int, credits: int, created_by: str = '', - prefix: str = '', note: str = '', valid_days: int = 0) -> list[str]: +async def generate_cards(count: int, credits: int, created_by: str = '', + prefix: str = '', note: str = '', valid_days: int = 0) -> list[str]: """生成次数卡密(credits = 每张卡可下载次数) prefix: 自定义卡密前缀(默认 VIP-) note: 备注信息 @@ -28,29 +27,24 @@ def generate_cards(count: int, credits: int, created_by: str = '', prefix = ''.join(ch for ch in (prefix or 'VIP') if ch.isalnum()).upper()[:10] or 'VIP' expire_at = int(time.time()) + int(valid_days) * 86400 if valid_days and int(valid_days) > 0 else 0 codes = [] - with _DB_LOCK: - conn = get_conn() - try: - for _ in range(count): - code = prefix + '-' + secrets.token_hex(5).upper() - conn.execute( - 'INSERT INTO cards (code, minutes, created_by, created_at, note, expire_at) ' - 'VALUES (?, ?, ?, ?, ?, ?)', - (code, int(credits), created_by, int(time.time()), note or '', expire_at)) - codes.append(code) - conn.commit() - finally: - conn.close() + async with write_transaction() as conn: + for _ in range(count): + code = prefix + '-' + secrets.token_hex(5).upper() + await conn.execute( + 'INSERT INTO cards (code, minutes, created_by, created_at, note, expire_at) ' + 'VALUES (?, ?, ?, ?, ?, ?)', + (code, int(credits), created_by, int(time.time()), note or '', expire_at)) + codes.append(code) return codes -def list_cards(limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]], int]: +async def list_cards(limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]], int]: """卡密列表(支持分页 limit/offset,管理路由透传) 返回 (当页卡密列表, 卡密总数)——总数供前端分页控件用,调用方按元组解包""" limit = max(1, min(int(limit), 500)) offset = max(0, int(offset)) - rows = _q('SELECT * FROM cards ORDER BY id DESC LIMIT ? OFFSET ?', (limit, offset)) - total_row = _q('SELECT COUNT(*) AS c FROM cards') + rows = await _q('SELECT * FROM cards ORDER BY id DESC LIMIT ? OFFSET ?', (limit, offset)) + total_row = await _q('SELECT COUNT(*) AS c FROM cards') total = int(total_row[0]['c']) if total_row else 0 now = time.time() cards = [] @@ -62,89 +56,65 @@ def list_cards(limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]], return cards, total -def set_card_disabled(code: str, disabled: bool) -> None: +async def set_card_disabled(code: str, disabled: bool) -> None: """禁用/恢复未使用卡密(已使用卡密无禁用意义,拒绝操作)""" code = (code or '').strip().upper() - with _DB_LOCK: - conn = get_conn() - try: - row = conn.execute('SELECT used_by, disabled FROM cards WHERE code = ?', (code,)).fetchone() - if not row: - raise ValueError('卡密不存在') - if row['used_by']: - raise ValueError('卡密已被使用,无需禁用') - if bool(row['disabled']) == bool(disabled): - raise ValueError('卡密已处于该状态') - conn.execute('UPDATE cards SET disabled = ? WHERE code = ?', - (1 if disabled else 0, code)) - conn.commit() - except Exception: - conn.rollback() - raise - finally: - conn.close() + async with write_transaction() as conn: + row = await (await conn.execute('SELECT used_by, disabled FROM cards WHERE code = ?', (code,))).fetchone() + if not row: + raise ValueError('卡密不存在') + if row['used_by']: + raise ValueError('卡密已被使用,无需禁用') + if bool(row['disabled']) == bool(disabled): + raise ValueError('卡密已处于该状态') + await conn.execute('UPDATE cards SET disabled = ? WHERE code = ?', + (1 if disabled else 0, code)) -def delete_card(code: str) -> None: +async def delete_card(code: str) -> None: """删除未使用卡密(已使用卡密含兑换凭证:使用者/时间,按 owner 规则不可删)""" code = (code or '').strip().upper() - with _DB_LOCK: - conn = get_conn() - try: - row = conn.execute('SELECT used_by FROM cards WHERE code = ?', (code,)).fetchone() - if not row: - raise ValueError('卡密不存在') - if row['used_by']: - raise ValueError('已使用的卡密不可删除(保留兑换凭证)') - conn.execute('DELETE FROM cards WHERE code = ?', (code,)) - conn.commit() - except Exception: - conn.rollback() - raise - finally: - conn.close() + async with write_transaction() as conn: + row = await (await conn.execute('SELECT used_by FROM cards WHERE code = ?', (code,))).fetchone() + if not row: + raise ValueError('卡密不存在') + if row['used_by']: + raise ValueError('已使用的卡密不可删除(保留兑换凭证)') + await conn.execute('DELETE FROM cards WHERE code = ?', (code,)) -def redeem_card(code: str, username: str) -> tuple[int, int]: +async def redeem_card(code: str, username: str) -> tuple[int, int]: """兑换卡密,返回 (卡密次数, 新剩余次数)(标记已用与加余额同事务,失败整体回滚不吞卡)""" code = (code or '').strip().upper() - with _DB_LOCK: - conn = get_conn() - try: - row = conn.execute('SELECT * FROM cards WHERE code = ?', (code,)).fetchone() - if not row: - raise ValueError('卡密不存在') - if row['used_by']: - raise ValueError('卡密已被使用') - if row['disabled']: - raise ValueError('卡密已被禁用,请联系管理员') - if row['expire_at'] and row['expire_at'] < time.time(): - raise ValueError('卡密已过期') - # 卡密标记 + 余额增加同事务:任一失败整体回滚,卡密可重试兑换。 - # WHERE 带 used_by=""/disabled=0 防重标记 + rowcount 校验:命中 0 行 - # (跨进程并发抢卡)立即回滚报错,绝不出现"未标记已用却加了余额" - cur = conn.execute( - 'UPDATE cards SET used_by = ?, used_at = ? ' - 'WHERE code = ? AND used_by = "" AND disabled = 0', - (username, int(time.time()), code)) - if cur.rowcount != 1: - raise ValueError('卡密已被使用') - urow = conn.execute('SELECT role, duration_seconds FROM users WHERE username = ?', - (username,)).fetchone() - if not urow: - raise ValueError('用户不存在') - minutes = int(row['minutes']) - remaining = int(urow['duration_seconds'] or 0) - if urow['role'] == 'admin' or remaining == -1: - remaining = -1 - else: - remaining = remaining + minutes - conn.execute('UPDATE users SET duration_seconds = ? WHERE username = ?', - (remaining, username)) - conn.commit() - return minutes, remaining - except Exception: - conn.rollback() - raise - finally: - conn.close() + async with write_transaction() as conn: + row = await (await conn.execute('SELECT * FROM cards WHERE code = ?', (code,))).fetchone() + if not row: + raise ValueError('卡密不存在') + if row['used_by']: + raise ValueError('卡密已被使用') + if row['disabled']: + raise ValueError('卡密已被禁用,请联系管理员') + if row['expire_at'] and row['expire_at'] < time.time(): + raise ValueError('卡密已过期') + # 卡密标记 + 余额增加同事务:任一失败整体回滚,卡密可重试兑换。 + # WHERE 带 used_by=""/disabled=0 防重标记 + rowcount 校验:命中 0 行 + # (跨进程并发抢卡)立即回滚报错,绝不出现"未标记已用却加了余额" + cur = await conn.execute( + 'UPDATE cards SET used_by = ?, used_at = ? ' + 'WHERE code = ? AND used_by = "" AND disabled = 0', + (username, int(time.time()), code)) + if cur.rowcount != 1: + raise ValueError('卡密已被使用') + urow = await (await conn.execute('SELECT role, duration_seconds FROM users WHERE username = ?', + (username,))).fetchone() + if not urow: + raise ValueError('用户不存在') + minutes = int(row['minutes']) + remaining = int(urow['duration_seconds'] or 0) + if urow['role'] == 'admin' or remaining == -1: + remaining = -1 + else: + remaining = remaining + minutes + await conn.execute('UPDATE users SET duration_seconds = ? WHERE username = ?', + (remaining, username)) + return minutes, remaining diff --git a/auth_system/credits.py b/auth_system/credits.py index 1943a53..8138bfc 100644 --- a/auth_system/credits.py +++ b/auth_system/credits.py @@ -1,15 +1,14 @@ # -*- coding: utf-8 -*- """下载次数增减与格式化。 -(自 auth_system.py 拆出,行为与原实现一致。) +(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。) """ import time from .db import ( - _DB_LOCK, _x, - get_conn, + write_transaction, ) from .users import ( get_user_by_id, @@ -19,92 +18,79 @@ from .users import ( # ============ 使用次数(按次计费:一首歌 = 1 次) ============ -def refund_credits(uid: int, count: int) -> int: +async def refund_credits(uid: int, count: int) -> int: """退回下载次数(批量打包失败部分自动退费)。返回剩余次数(-1 表示不限)""" - user = get_user_by_id(uid) + user = await get_user_by_id(uid) if user and (user['duration_seconds'] or 0) == -1: return -1 # 不限次数用户无需退费 count = max(0, int(count)) if count <= 0: return (user['duration_seconds'] or 0) if user else 0 - _x('UPDATE users SET duration_seconds = COALESCE(duration_seconds, 0) + ? WHERE id = ?', - (count, uid)) - user = get_user_by_id(uid) + await _x('UPDATE users SET duration_seconds = COALESCE(duration_seconds, 0) + ? WHERE id = ?', + (count, uid)) + user = await get_user_by_id(uid) return (user['duration_seconds'] or 0) if user else 0 -def charge_credits(uid: int, count: int = 1) -> int: +async def charge_credits(uid: int, count: int = 1) -> int: """按次扣减使用次数(管理员与不限次数(-1)用户不扣减,返回 -1)。返回剩余次数 - 扣减在 _DB_LOCK 内"读取-判断-写入"一次完成,防止并发双击导致超扣/双花""" - user = get_user_by_id(uid) + 扣减在写事务内"读取-判断-写入"一次完成,防止并发双击导致超扣/双花""" + user = await get_user_by_id(uid) if not user: return 0 if user['role'] == 'admin' or (user['duration_seconds'] or 0) == -1: - _x('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid)) + await _x('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid)) return -1 count = max(1, int(count)) - with _DB_LOCK: - conn = get_conn() - try: - row = conn.execute('SELECT duration_seconds FROM users WHERE id = ?', (uid,)).fetchone() - cur = int(row['duration_seconds'] or 0) if row else 0 - if cur == -1: - conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid)) - conn.commit() - return -1 - newval = max(0, cur - count) - conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?', - (newval, int(time.time()), uid)) - conn.commit() - return newval - finally: - conn.close() + async with write_transaction() as conn: + row = await (await conn.execute('SELECT duration_seconds FROM users WHERE id = ?', (uid,))).fetchone() + cur = int(row['duration_seconds'] or 0) if row else 0 + if cur == -1: + await conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid)) + return -1 + newval = max(0, cur - count) + await conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?', + (newval, int(time.time()), uid)) + return newval -def reserve_credits(uid: int, count: int = 1): +async def reserve_credits(uid: int, count: int = 1): """原子预扣下载次数(下载类接口在路由执行前调用,防止并发双击双花)。 足额:扣减并返回剩余次数;不限次数(-1)/管理员:不扣减返回 -1; 不足:返回 None(调用方应拒绝请求)""" count = max(1, int(count)) - with _DB_LOCK: - conn = get_conn() - try: - row = conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?', (uid,)).fetchone() - if not row: - return None - if row['role'] == 'admin': - conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid)) - conn.commit() - return -1 - cur = int(row['duration_seconds'] or 0) - if cur == -1: - conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid)) - conn.commit() - return -1 - if cur < count: - return None - conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?', - (cur - count, int(time.time()), uid)) - conn.commit() - return cur - count - finally: - conn.close() + async with write_transaction() as conn: + row = await (await conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?', (uid,))).fetchone() + if not row: + return None + if row['role'] == 'admin': + await conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid)) + return -1 + cur = int(row['duration_seconds'] or 0) + if cur == -1: + await conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid)) + return -1 + if cur < count: + return None + await conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?', + (cur - count, int(time.time()), uid)) + return cur - count -def add_credits(username: str, count: int) -> int: +async def add_credits(username: str, count: int) -> int: """增加次数(卡密充值/试用领取),返回新剩余次数;不限次数(-1)用户保持不限 累加用 SQL 原生自增(原子),避免并发充值互相覆盖""" - user = get_user_by_name(username) + user = await get_user_by_name(username) if not user: return 0 if user['role'] == 'admin' or (user['duration_seconds'] or 0) == -1: return -1 - _x('UPDATE users SET duration_seconds = COALESCE(duration_seconds, 0) + ? WHERE id = ?', - (max(0, int(count)), user['id'])) - user = get_user_by_name(username) + await _x('UPDATE users SET duration_seconds = COALESCE(duration_seconds, 0) + ? WHERE id = ?', + (max(0, int(count)), user['id'])) + user = await get_user_by_name(username) return (user['duration_seconds'] or 0) if user else 0 diff --git a/auth_system/db.py b/auth_system/db.py index 3976ae9..b7d0c53 100644 --- a/auth_system/db.py +++ b/auth_system/db.py @@ -1,17 +1,31 @@ # -*- coding: utf-8 -*- -"""路径常量、连接与建表。 +"""路径常量、连接与建表(原生异步 SQLite 访问层:aiosqlite + WAL)。 -(自 auth_system.py 拆出,行为与原实现一致。) +(自 auth_system.py 拆出;本文件由同步 sqlite3 栈迁移为原生异步,设计照 +`core/db.py`——同一套「读写同时进行」口径:) + +- WAL 模式:读事务与写事务互不阻塞(读快照隔离); +- 读路径(`_q`):每次操作独立连接、无锁、可并发; +- 写路径(`_x` / `write_transaction`):SQLite 全库同一时刻只允许一个写者, + 用 `asyncio.Lock` 串行化写事务,但写者不阻塞读者; +- `busy_timeout`:多进程/多连接下遇短暂写锁自旋等待而非立刻 SQLITE_BUSY。 + +路径常量仍是本模块的**唯一真源**:DB 访问函数在**调用时**读取本模块的 +`DB_PATH` 等全局(测试 monkeypatch `auth_system.db.DB_PATH` 依旧生效)。 """ +import asyncio import os import secrets import sqlite3 import sys -import threading import time +from collections.abc import AsyncGenerator, Sequence +from contextlib import asynccontextmanager from pathlib import Path +import aiosqlite + from .security import ( _hash_new, _normalize_email, @@ -37,68 +51,136 @@ SESSION_KEY = 'site_uid' DEFAULT_REGISTER_CREDITS = 3 # 注册直接赠送 3 次下载次数(无需领取;TRIAL_CREDITS 死常量已删) -_DB_LOCK = threading.RLock() +# 锁按事件循环隔离:asyncio.Lock 绑定创建它的 loop,跨 loop 复用会抛 +# "bound to a different event loop"(应用主 loop + 测试各自的 loop 并存,必须分开持有)。 +_LOOP_LOCKS: dict[tuple[int, str], asyncio.Lock] = {} + + +def _loop_lock(kind: str) -> asyncio.Lock: + key = (id(asyncio.get_running_loop()), kind) + lock = _LOOP_LOCKS.get(key) + if lock is None: + lock = asyncio.Lock() + _LOOP_LOCKS[key] = lock + return lock + + +def _write_lock() -> asyncio.Lock: + """写事务串行锁(每事件循环一个)。""" + return _loop_lock('write') + + +def _wal_lock() -> asyncio.Lock: + """WAL 切换专用锁(不能用 _write_lock:execute() 已持它再调 + connect→_configure 会自锁死)。""" + return _loop_lock('wal') + # journal_mode=WAL 持久化在库文件头里,按路径设一次即可。每连接重复执行 # 实测多付 ~1ms(新连接首条语句要建 WAL 索引读锁),且并发首连会争排他锁 # 报 database is locked(core/db.py 同款教训)。专用锁 + 二次复查防护。 -_WAL_READY: set[str] = set() -_WAL_SETUP_LOCK = threading.Lock() +_WAL_READY_FOR: str | None = None # ============ 数据库 ============ -def get_conn() -> sqlite3.Connection: - key = str(DB_PATH) - # exists() 检查(~10us)必须在 connect 之前:connect 会创建空文件, - # 之后 exists 恒为 True,删库重建的场景就检测不到了 +async def _configure(conn: aiosqlite.Connection, force_wal: bool = False) -> None: + global _WAL_READY_FOR + conn.row_factory = aiosqlite.Row + # busy_timeout 先设:让后续语句遇锁自旋等待,而不是立刻报 database is locked + await conn.execute('PRAGMA busy_timeout=15000') + key = str(DB_PATH) # 调用时读取模块全局——测试 monkeypatch 后仍生效 + if force_wal or _WAL_READY_FOR != key: + # 切换日志模式需要排他锁,且 `_WAL_READY_FOR` 的检查不是原子的—— + # 并发连接须在专用锁内串行完成「切换 + 标记」,并二次复查,否则批量并发 + # 连接会同时执行 PRAGMA journal_mode=WAL 争锁(实测 database is locked) + async with _wal_lock(): + if force_wal or _WAL_READY_FOR != key: + await conn.execute('PRAGMA journal_mode=WAL') + _WAL_READY_FOR = key + await conn.execute('PRAGMA synchronous=NORMAL') # WAL 下兼顾安全与吞吐 + await conn.execute('PRAGMA foreign_keys=ON') + + +async def connect() -> aiosqlite.Connection: + """新建一个已配置好的连接(调用方负责 close)。""" + # exists() 必须在 aiosqlite.connect 之前判断:connect 会创建空文件,之后 + # exists 恒为 True,库文件被删重建的场景就检测不到了(journal_mode 不持久 + # 于新文件,必须重设)。 force_wal = not DB_PATH.exists() - # mkdir 移出 happy path(实测 190us/次):connect 直接失败(目录缺失) - # 才补建重试。库文件被删的场景同样由此兜底重建。 + # mkdir 移出 happy path(实测 190us/次):connect 直接失败(目录缺失)才补建 + # 重试。库文件被删的场景同样由此兜底重建。用 DB_PATH.parent 而非 DATA_DIR: + # 生产两者相同,测试 monkeypatch 只换 DB_PATH 时兜底仍能命中正确目录 + # (core/db.py 用 DATA_DIR 的前提是两处都换)。 try: - conn = sqlite3.connect(str(DB_PATH), timeout=15) + conn = await aiosqlite.connect(str(DB_PATH), timeout=15) except sqlite3.OperationalError: - # DB_PATH.parent 而非 DATA_DIR:生产两者相同,测试 monkeypatch 只换 - # DB_PATH 时兜底仍能命中正确目录 DB_PATH.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(DB_PATH), timeout=15) - conn.row_factory = sqlite3.Row - if key not in _WAL_READY or force_wal: - # 读路径不持 _DB_LOCK,多线程可能同时首连:锁内串行执行 journal_mode - # 切换(防并发争排他锁报 database is locked)。锁内不复查 DB_PATH.exists()—— - # connect 已把文件建出来,exists 恒真;force_wal 是本线程的观测结论, - # 重复执行 PRAGMA 在已是 WAL 的库上幂等且廉价(~2us) - with _WAL_SETUP_LOCK: - conn.execute('PRAGMA journal_mode=WAL') - _WAL_READY.add(key) + conn = await aiosqlite.connect(str(DB_PATH), timeout=15) + await _configure(conn, force_wal) return conn -def _q(sql: str, params: tuple = ()) -> list[sqlite3.Row]: +# 注:同步时代的公开连接门面 get_conn() 已删除——它在迁移后只剩「转调 connect()」 +# 一层空壳且全仓零消费者(测试直接开裸连接,备份用 db.connect())。保留它等于留一条 +# 「拿裸连接自己写」的口子,与本次「拆掉同步门面、写路径统一走 _x/write_transaction」 +# 的目标相悖。 + + +# ============ 读路径(并发,无锁) ============ + + +async def _q(sql: str, params: Sequence = ()) -> list[aiosqlite.Row]: """单语句读:**不持锁**。 - WAL 模式下每个连接读到一致性快照,读与写互不阻塞;旧实现对读也加 _DB_LOCK, + WAL 模式下每个连接读到一致性快照,读与写互不阻塞;旧实现对读也加锁, 等于把全部读串行化并被写挡住。此处仅放开单语句读——写事务(_x 与各 - `with _DB_LOCK` 块)仍全程串行,故不引入写-写竞争。 + `write_transaction` 块)仍全程串行,故不引入写-写竞争。 """ - conn = get_conn() + conn = await connect() try: - return conn.execute(sql, params).fetchall() + cur = await conn.execute(sql, params) + return list(await cur.fetchall()) finally: - conn.close() + await conn.close() -def _x(sql: str, params: tuple = ()) -> int: - """执行写操作,返回 lastrowid / rowcount""" - with _DB_LOCK: - conn = get_conn() +# ============ 写路径(串行,不阻塞读) ============ + + +async def _x(sql: str, params: Sequence = ()) -> int: + """执行单条写语句,返回 lastrowid(INSERT)或 rowcount(UPDATE/DELETE)""" + async with _write_lock(): + conn = await connect() try: - cur = conn.execute(sql, params) - conn.commit() + cur = await conn.execute(sql, params) + await conn.commit() return cur.lastrowid if cur.lastrowid else cur.rowcount finally: - conn.close() + await conn.close() + + +@asynccontextmanager +async def write_transaction() -> AsyncGenerator[aiosqlite.Connection, None]: + """写事务上下文:多条写语句原子提交(串行锁内)。 + + 用法: + async with write_transaction() as conn: + await conn.execute(...) + await conn.execute(...) + # 退出即 commit;异常则 rollback + """ + async with _write_lock(): + conn = await connect() + try: + yield conn + await conn.commit() + except Exception: + await conn.rollback() + raise + finally: + await conn.close() _SCHEMA_SQL = ''' @@ -148,30 +230,30 @@ _SCHEMA_SQL = ''' ''' -def _create_schema(conn: sqlite3.Connection) -> None: +async def _create_schema(conn: aiosqlite.Connection) -> None: """建表 + 单行配置种子行(幂等:全部 IF NOT EXISTS / INSERT OR IGNORE)。""" - conn.executescript(_SCHEMA_SQL) + await conn.executescript(_SCHEMA_SQL) # 单行配置表 - conn.execute('''CREATE TABLE IF NOT EXISTS smtp_config ( + await conn.execute('''CREATE TABLE IF NOT EXISTS smtp_config ( id INTEGER PRIMARY KEY CHECK (id = 1), host TEXT DEFAULT '', port INTEGER DEFAULT 587, sender TEXT DEFAULT '', password TEXT DEFAULT '', updated_at INTEGER DEFAULT 0 )''') - conn.execute('INSERT OR IGNORE INTO smtp_config (id, port) VALUES (1, 587)') + await conn.execute('INSERT OR IGNORE INTO smtp_config (id, port) VALUES (1, 587)') # 键值设置表 - conn.execute('''CREATE TABLE IF NOT EXISTS settings ( + await conn.execute('''CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT DEFAULT '' )''') - conn.execute('''CREATE TABLE IF NOT EXISTS announcements ( + await conn.execute('''CREATE TABLE IF NOT EXISTS announcements ( id INTEGER PRIMARY KEY CHECK (id = 1), title TEXT DEFAULT '', content TEXT DEFAULT '', popup_mode TEXT DEFAULT 'off', updated_at INTEGER DEFAULT 0 )''') - conn.execute('INSERT OR IGNORE INTO announcements (id) VALUES (1)') + await conn.execute('INSERT OR IGNORE INTO announcements (id) VALUES (1)') # 计费账本(任务实扣/交付/退款/终态的持久化唯一记录,结算幂等) - conn.execute('''CREATE TABLE IF NOT EXISTS billing_ledger ( + await conn.execute('''CREATE TABLE IF NOT EXISTS billing_ledger ( task_id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, reserved INTEGER DEFAULT 0, @@ -183,11 +265,13 @@ def _create_schema(conn: sqlite3.Connection) -> None: )''') -def _apply_column_migrations(conn: sqlite3.Connection) -> None: +async def _apply_column_migrations(conn: aiosqlite.Connection) -> None: """轻量迁移:新增列。仅"列已存在"可忽略(其余迁移失败必须暴露,不能吞掉)""" _already: set[str] = set() - for trow in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall(): - for crow in conn.execute(f"PRAGMA table_info({trow['name']})").fetchall(): + trows = await (await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")).fetchall() + for trow in trows: + crows = await (await conn.execute(f"PRAGMA table_info({trow['name']})")).fetchall() + for crow in crows: _already.add(f"{trow['name']}.{crow['name']}") for alter_sql, tbl_col in ( ('ALTER TABLE users ADD COLUMN status INTEGER DEFAULT 1', 'users.status'), @@ -202,82 +286,87 @@ def _apply_column_migrations(conn: sqlite3.Connection) -> None: if tbl_col in _already: continue # 幂等:列已存在 try: - conn.execute(alter_sql) + await conn.execute(alter_sql) except sqlite3.OperationalError as e: if 'duplicate column' not in str(e).lower(): # 磁盘/锁/结构等真实故障:抛出,禁止静默带病初始化 raise -def _ensure_email_unique_index(conn: sqlite3.Connection) -> None: +async def _ensure_email_unique_index(conn: aiosqlite.Connection) -> None: """非空邮箱唯一(部分索引,不约束历史空串)。 存量库若已有重复非空邮箱,索引创建失败仅告警不阻断启动 (查重逻辑在注册/建号路由另有兜底)。 """ try: - conn.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty ' - 'ON users(email) WHERE email != \'\'') + await conn.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty ' + 'ON users(email) WHERE email != \'\'') except sqlite3.IntegrityError: print('[警告] users.email 存在重复的非空邮箱,唯一索引未创建(请人工清理后重启)') -def _migrate_user_emails(conn: sqlite3.Connection) -> None: +async def _migrate_user_emails(conn: aiosqlite.Connection) -> None: """users.email 口径统一为归一化(lower + 剥 Gmail +tag)——与 get_user_by_email/找回/邮箱登录的查询口径对齐。逐行迁移,归一化后与他 人撞唯一索引的行保留原值跳过(与"存量重复仅告警"同口径)。幂等:已 归一化的行零写入。 """ - for urow in conn.execute("SELECT id, email FROM users WHERE email != ''").fetchall(): + urows = await (await conn.execute("SELECT id, email FROM users WHERE email != ''")).fetchall() + for urow in urows: fixed = _normalize_email(urow['email']) if not fixed or fixed == urow['email']: continue try: - conn.execute('UPDATE users SET email = ? WHERE id = ?', (fixed, urow['id'])) + await conn.execute('UPDATE users SET email = ? WHERE id = ?', (fixed, urow['id'])) except sqlite3.IntegrityError: print(f"[警告] 用户 {urow['id']} 的邮箱归一化后与他人重复,保留原值") -def _ensure_initial_admin(conn: sqlite3.Connection) -> None: +async def _ensure_initial_admin(conn: aiosqlite.Connection) -> None: """初始管理员(新装不再使用公开默认口令,随机生成并写入日志一次)。 引导条件改为"不存在任何管理员角色"——按用户名查会在 管理员改名(如 admin→qfbsh)后误判缺失,重启即重建出一个新的 admin 账号。 """ - row = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone() + row = await (await conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1")).fetchone() if row: return initial_pass = secrets.token_urlsafe(12) - conn.execute( + await conn.execute( 'INSERT INTO users (username, password_hash, salt, role, duration_seconds, created_at) ' 'VALUES (?, ?, ?, ?, -1, ?)', (DEFAULT_ADMIN_USER, _hash_new(initial_pass), '', 'admin', int(time.time()))) - # ——非 ASCII 输出须防 GBK 重定向下 UnicodeEncodeError + # ——非 ASCII 输出须防 GBK 重定向下的 UnicodeEncodeError try: print(f'[初始化] 已创建管理员账号 admin,初始密码:{initial_pass}(请立即登录并修改,本提示不再显示)') except UnicodeEncodeError: print('[init] admin account created, initial password printed above failed to encode') -def init_db() -> None: +async def _init_db_locked(conn: aiosqlite.Connection) -> None: + """在**已持有写锁**的连接上执行全部结构演进(不自行取锁)。 + + restore_backup 需要在自己的写锁临界区里对新库跑一遍初始化,不能调用会 + 再次取写锁的 init_db(asyncio.Lock 非重入,会自锁死),故拆出本函数。 + """ + await _create_schema(conn) + await _apply_column_migrations(conn) + await _ensure_email_unique_index(conn) + await _migrate_user_emails(conn) + await _ensure_initial_admin(conn) + + +async def init_db() -> None: """初始化表结构与默认数据""" - DATA_DIR.mkdir(parents=True, exist_ok=True) - with _DB_LOCK: - conn = get_conn() - try: - _create_schema(conn) - _apply_column_migrations(conn) - _ensure_email_unique_index(conn) - _migrate_user_emails(conn) - _ensure_initial_admin(conn) - conn.commit() - finally: - conn.close() + DATA_DIR.mkdir(parents=True, exist_ok=True) # noqa: ASYNC240 - 启动期一次的目录创建,无需线程池 + async with write_transaction() as conn: + await _init_db_locked(conn) -def get_secret_key() -> str: - """获取(或生成)持久化的会话签名密钥(原子写入 + 失败显式告警)""" +def _get_secret_key_sync() -> str: + """同步实现(见 get_secret_key 的异步包装):原子写入 + 失败显式告警""" key = '' try: if SECRET_KEY_PATH.exists(): @@ -303,3 +392,13 @@ def get_secret_key() -> str: except UnicodeEncodeError: print('[warn] secret key persistence failed; using temporary key for this process', file=sys.stderr) return new_key + + +async def get_secret_key() -> str: + """获取(或生成)持久化的会话签名密钥(原子写入 + 失败显式告警)。 + + 内部是**同步阻塞文件 IO**(exists/read/write/os.replace),整段经 + `asyncio.to_thread` 卸载到线程——这是正当的阻塞 IO 卸载(登录链路每次 + 签会话都要读一次密钥),不是把异步包装成同步的门面。 + """ + return await asyncio.to_thread(_get_secret_key_sync) diff --git a/auth_system/geo.py b/auth_system/geo.py index da52f54..137ccbc 100644 --- a/auth_system/geo.py +++ b/auth_system/geo.py @@ -1,10 +1,9 @@ # -*- coding: utf-8 -*- """登录 IP 归属地探测与异地自动封禁。 -(自 auth_system.py 拆出,行为与原实现一致。) +(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。) """ -import asyncio import json import sqlite3 import time @@ -78,8 +77,8 @@ async def record_login_location(user: sqlite3.Row, ip: str) -> tuple: """记录本次登录省份并检测异常:24h 内出现 >=3 个不同省份自动封禁。 返回 (是否被封禁, 说明) - 落库仍走同步 sqlite(auth_system 是同步 DB 层),但调用方在事件循环里, - 故经 asyncio.to_thread 卸载——避免 sqlite 的 busy 等待(最长 15s)冻结整个服务。 + 落库走原生异步通道(auth_system 已迁移为异步 DB 层),直接 await, + 不再经 asyncio.to_thread 桥接。 """ if user['role'] == 'admin': return False, '' @@ -97,12 +96,11 @@ async def record_login_location(user: sqlite3.Row, ip: str) -> tuple: for h in history: if h.get('p') and h['p'] not in provinces: provinces.append(h['p']) - await asyncio.to_thread( - _x, 'UPDATE users SET recent_provinces = ? WHERE id = ?', - (json.dumps(history, ensure_ascii=False), user['id'])) + await _x('UPDATE users SET recent_provinces = ? WHERE id = ?', + (json.dumps(history, ensure_ascii=False), user['id'])) if len(provinces) >= PROVINCE_BAN_LIMIT: reason = f'24小时内检测到 {len(provinces)} 个不同省份登录({"/".join(provinces[-3:])}),疑似账号倒卖,已自动封禁' - await asyncio.to_thread(ban_user, user['id'], reason) - await asyncio.to_thread(add_log, 'fail', user['username'], ip, '自动封禁', reason) + await ban_user(user['id'], reason) + await add_log('fail', user['username'], ip, '自动封禁', reason) return True, reason return False, province diff --git a/auth_system/ledger.py b/auth_system/ledger.py index 1c075fe..2f39b09 100644 --- a/auth_system/ledger.py +++ b/auth_system/ledger.py @@ -1,17 +1,16 @@ # -*- coding: utf-8 -*- """预扣账本(开账 / 改名 / 补扣 / 结算 / 对账)。 -(自 auth_system.py 拆出,行为与原实现一致。) +(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。) """ import time from typing import Any from .db import ( - _DB_LOCK, _q, _x, - get_conn, + write_transaction, ) from .logs import ( add_log, @@ -20,80 +19,62 @@ from .logs import ( # ============ 计费账本(实扣、交付、退款、终态的持久化唯一记录) ============ -def ledger_open(task_id: str, user_id: int, reserved: int) -> None: +async def ledger_open(task_id: str, user_id: int, reserved: int) -> None: """任务计费开账:记录本任务实际预扣次数(admin/-1 用户为 0)。 task_id 唯一;重复 task_id 由任务系统在提交侧拒绝,这里幂等保护。""" - with _DB_LOCK: - conn = get_conn() - try: - conn.execute( - 'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) ' - 'VALUES (?, ?, ?, \'open\', ?)', - (str(task_id), int(user_id), max(0, int(reserved)), int(time.time()))) - conn.commit() - finally: - conn.close() + await _x( + 'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) ' + 'VALUES (?, ?, ?, \'open\', ?)', + (str(task_id), int(user_id), max(0, int(reserved)), int(time.time()))) -def reserve_and_open(uid: int, count: int, ledger_key: str) -> int | None: +async def reserve_and_open(uid: int, count: int, ledger_key: str) -> int | None: """预扣与开账同一事务(原两步之间存在崩溃丢账窗口)。 足额:扣减+开账并返回剩余;不限/管理员:开账(reserved=0)返回 -1;不足:返回 None(不开账)。""" count = max(1, int(count)) key = str(ledger_key) - with _DB_LOCK: - conn = get_conn() - try: - row = conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?', (uid,)).fetchone() - if not row: - return None - now = int(time.time()) - if row['role'] == 'admin': - conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (now, uid)) - conn.execute( - 'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) ' - 'VALUES (?, ?, 0, \'open\', ?)', (key, int(uid), now)) - conn.commit() - return -1 - cur = int(row['duration_seconds'] or 0) - if cur == -1: - conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (now, uid)) - conn.execute( - 'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) ' - 'VALUES (?, ?, 0, \'open\', ?)', (key, int(uid), now)) - conn.commit() - return -1 - if cur < count: - return None - conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?', - (cur - count, now, uid)) - conn.execute( + async with write_transaction() as conn: + row = await (await conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?', (uid,))).fetchone() + if not row: + return None + now = int(time.time()) + if row['role'] == 'admin': + await conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (now, uid)) + await conn.execute( 'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) ' - 'VALUES (?, ?, ?, \'open\', ?)', (key, int(uid), count, now)) - conn.commit() - return cur - count - finally: - conn.close() + 'VALUES (?, ?, 0, \'open\', ?)', (key, int(uid), now)) + return -1 + cur = int(row['duration_seconds'] or 0) + if cur == -1: + await conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (now, uid)) + await conn.execute( + 'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) ' + 'VALUES (?, ?, 0, \'open\', ?)', (key, int(uid), now)) + return -1 + if cur < count: + return None + await conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?', + (cur - count, now, uid)) + await conn.execute( + 'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) ' + 'VALUES (?, ?, ?, \'open\', ?)', (key, int(uid), count, now)) + return cur - count -def ledger_rename(old_key: str, new_key: str) -> bool: +async def ledger_rename(old_key: str, new_key: str) -> bool: """把请求级临时账本行改名为正式 task_id(任务接手账目)。 old 不存在/已关闭/new 已存在 → False。""" - with _DB_LOCK: - conn = get_conn() - try: - dup = conn.execute('SELECT 1 FROM billing_ledger WHERE task_id = ?', (str(new_key),)).fetchone() - if dup: - return False - cur = conn.execute( - 'UPDATE billing_ledger SET task_id = ? WHERE task_id = ? AND status = \'open\'', - (str(new_key), str(old_key))) - conn.commit() - return cur.rowcount == 1 - finally: - conn.close() + async with write_transaction() as conn: + dup = await (await conn.execute('SELECT 1 FROM billing_ledger WHERE task_id = ?', (str(new_key),))).fetchone() + if dup: + return False + cur = await conn.execute( + 'UPDATE billing_ledger SET task_id = ? WHERE task_id = ? AND status = \'open\'', + (str(new_key), str(old_key))) + return cur.rowcount == 1 -def ledger_supplement(task_id: str, extra: int) -> bool: +async def ledger_supplement(task_id: str, extra: int) -> bool: """整台播客等"先扣 1 后按实际数量补扣"场景: 原子补扣用户余额并同步累加账本 reserved。任一步失败返回 False(调用方必须让任务失败, 不允许无账本继续交付)。余额不足同样返回 False。""" @@ -101,39 +82,34 @@ def ledger_supplement(task_id: str, extra: int) -> bool: extra = int(extra) if extra <= 0: return True - with _DB_LOCK: - conn = get_conn() - try: - row = conn.execute('SELECT user_id, status FROM billing_ledger WHERE task_id = ?', - (task_id,)).fetchone() + try: + async with write_transaction() as conn: + row = await (await conn.execute('SELECT user_id, status FROM billing_ledger WHERE task_id = ?', + (task_id,))).fetchone() if not row or row['status'] != 'open': return False - urow = conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?', - (row['user_id'],)).fetchone() + urow = await (await conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?', + (row['user_id'],))).fetchone() if not urow or urow['role'] == 'admin' or int(urow['duration_seconds'] or 0) == -1: return True # 不限用户:无需补扣,账本也不加 cur_credits = int(urow['duration_seconds'] or 0) if cur_credits < extra: return False # 余额不足:任务必须失败(不允许扣到 0 硬做) - conn.execute('UPDATE users SET duration_seconds = duration_seconds - ? WHERE id = ?', - (extra, row['user_id'])) - conn.execute('UPDATE billing_ledger SET reserved = reserved + ? WHERE task_id = ?', - (extra, task_id)) - conn.commit() + await conn.execute('UPDATE users SET duration_seconds = duration_seconds - ? WHERE id = ?', + (extra, row['user_id'])) + await conn.execute('UPDATE billing_ledger SET reserved = reserved + ? WHERE task_id = ?', + (extra, task_id)) return True - except Exception: - conn.rollback() - return False - finally: - conn.close() + except Exception: + return False -def ledger_get(task_id: str) -> dict[str, Any] | None: - rows = _q('SELECT * FROM billing_ledger WHERE task_id = ?', (str(task_id),)) +async def ledger_get(task_id: str) -> dict[str, Any] | None: + rows = await _q('SELECT * FROM billing_ledger WHERE task_id = ?', (str(task_id),)) return dict(rows[0]) if rows else None -def ledger_settle(task_id: str, delivered: int, final_status: str) -> int: +async def ledger_settle(task_id: str, delivered: int, final_status: str) -> int: """任务计费结算(幂等):按账本实扣与实际交付计算应退次数,原子退款并落终态。 - delivered:本次任务实际交付给用户的数量(单曲/单集 0 或 1;批量为成功入包数) - 重复调用/重启后再次调用:账本已非 open 直接返回 0,不重复退款 @@ -143,58 +119,53 @@ def ledger_settle(task_id: str, delivered: int, final_status: str) -> int: 返回实际退款次数(落余额的部分;-1/admin 场景为 0)。""" task_id = str(task_id) delivered = max(0, int(delivered)) - with _DB_LOCK: - conn = get_conn() - try: - row = conn.execute('SELECT * FROM billing_ledger WHERE task_id = ?', (task_id,)).fetchone() - if not row or row['status'] != 'open': - return 0 - refund = max(0, int(row['reserved']) - delivered) - # 退款与账本终态同事务落库:要么都成,要么都不成。 - # 注意 WHERE 必须排除 admin/-1:向 -1(不限次)账户加次数会把 -1 哨兵值破坏成 - # -1+N,向 admin 加无意义——因此任务运行期间账户升级为 -1 时退款不落余额, - # 账本 refunded 记录的是"按账本应付"口径(该用户已有无限次数,作废可接受); - # 原注释"照样退"与实现相反,已更正为此口径。 - if refund > 0: - conn.execute('UPDATE users SET duration_seconds = duration_seconds + ? ' - 'WHERE id = ? AND role != \'admin\' AND duration_seconds != -1', - (refund, row['user_id'])) - conn.execute('UPDATE billing_ledger SET delivered = ?, refunded = ?, status = ?, ' - 'settled_at = ? WHERE task_id = ?', - (delivered, refund, str(final_status)[:20], int(time.time()), task_id)) - conn.commit() - return refund - finally: - conn.close() + async with write_transaction() as conn: + row = await (await conn.execute('SELECT * FROM billing_ledger WHERE task_id = ?', (task_id,))).fetchone() + if not row or row['status'] != 'open': + return 0 + refund = max(0, int(row['reserved']) - delivered) + # 退款与账本终态同事务落库:要么都成,要么都不成。 + # 注意 WHERE 必须排除 admin/-1:向 -1(不限次)账户加次数会把 -1 哨兵值破坏成 + # -1+N,向 admin 加无意义——因此任务运行期间账户升级为 -1 时退款不落余额, + # 账本 refunded 记录的是"按账本应付"口径(该用户已有无限次数,作废可接受); + # 原注释"照样退"与实现相反,已更正为此口径。 + if refund > 0: + await conn.execute('UPDATE users SET duration_seconds = duration_seconds + ? ' + 'WHERE id = ? AND role != \'admin\' AND duration_seconds != -1', + (refund, row['user_id'])) + await conn.execute('UPDATE billing_ledger SET delivered = ?, refunded = ?, status = ?, ' + 'settled_at = ? WHERE task_id = ?', + (delivered, refund, str(final_status)[:20], int(time.time()), task_id)) + return refund -def ledger_recover_orphans(max_age_seconds: int = 2 * 3600) -> int: +async def ledger_recover_orphans(max_age_seconds: int = 2 * 3600) -> int: """启动对账:任务表在内存,进程重启后账本中仍 open 的行代表 "已扣款但任务已丢失"——退回全部实扣。仅处理超过宽限期的行, 避免误伤同库其他存活进程的进行中任务。返回退款笔数。""" now = int(time.time()) recovered = 0 - rows = _q('SELECT task_id FROM billing_ledger WHERE status = \'open\' AND created_at <= ?', - (now - int(max_age_seconds),)) + rows = await _q('SELECT task_id FROM billing_ledger WHERE status = \'open\' AND created_at <= ?', + (now - int(max_age_seconds),)) for r in rows: try: - refunded = ledger_settle(r['task_id'], 0, 'lost_restart') + refunded = await ledger_settle(r['task_id'], 0, 'lost_restart') if refunded: - add_log('success', 'system', 'system', '重启对账退款', - f'任务 {r["task_id"]} 因进程重启丢失,退回 {refunded} 次') + await add_log('success', 'system', 'system', '重启对账退款', + f'任务 {r["task_id"]} 因进程重启丢失,退回 {refunded} 次') else: - ledger_settle(r['task_id'], 0, 'lost_restart') + await ledger_settle(r['task_id'], 0, 'lost_restart') recovered += 1 except Exception: pass return recovered -def ledger_cleanup_settled(max_age_seconds: int = 90 * 86400) -> int: +async def ledger_cleanup_settled(max_age_seconds: int = 90 * 86400) -> int: """清理已结算的历史账本行(每任务一行、只增不删会让表无限膨胀; settled 状态行无对账价值,保留 90 天足够追溯)""" try: - return _x('DELETE FROM billing_ledger WHERE status != \'open\' AND settled_at <= ?', - (int(time.time()) - int(max_age_seconds),)) + return await _x('DELETE FROM billing_ledger WHERE status != \'open\' AND settled_at <= ?', + (int(time.time()) - int(max_age_seconds),)) except Exception: return 0 diff --git a/auth_system/logs.py b/auth_system/logs.py index 5113f1f..5261f4f 100644 --- a/auth_system/logs.py +++ b/auth_system/logs.py @@ -1,16 +1,15 @@ # -*- coding: utf-8 -*- """后台审计日志。 -(自 auth_system.py 拆出,行为与原实现一致。) +(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。) """ import time from .db import ( - _DB_LOCK, _q, _x, - get_conn, + write_transaction, ) # ============ 日志 ============ @@ -20,37 +19,32 @@ from .db import ( _LOG_WRITE_COUNTER = {'n': 0} -def add_log(log_type: str, username: str, ip: str, action: str, detail: str = '') -> None: +async def add_log(log_type: str, username: str, ip: str, action: str, detail: str = '') -> None: try: - _x('INSERT INTO logs (type, username, ip, action, detail, time) VALUES (?, ?, ?, ?, ?, ?)', - (log_type, username or '', ip or '-', action or '', detail or '', - time.strftime('%Y-%m-%d %H:%M:%S'))) + await _x('INSERT INTO logs (type, username, ip, action, detail, time) VALUES (?, ?, ?, ?, ?, ?)', + (log_type, username or '', ip or '-', action or '', detail or '', + time.strftime('%Y-%m-%d %H:%M:%S'))) # 控制日志表体积(每 50 次写入触发一次裁剪) _LOG_WRITE_COUNTER['n'] += 1 if _LOG_WRITE_COUNTER['n'] % 50 == 0: - _x('DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT 5000)') + await _x('DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT 5000)') except Exception: pass -def query_logs(log_type: str, limit: int = 100, offset: int = 0): +async def query_logs(log_type: str, limit: int = 100, offset: int = 0): """分页查询日志,返回 (日志列表, 总条数)""" limit = max(1, min(int(limit), 500)) offset = max(0, int(offset)) - rows = _q('SELECT * FROM logs WHERE type = ? ORDER BY id DESC LIMIT ? OFFSET ?', - (log_type, limit, offset)) - total_row = _q('SELECT COUNT(*) AS c FROM logs WHERE type = ?', (log_type,)) + rows = await _q('SELECT * FROM logs WHERE type = ? ORDER BY id DESC LIMIT ? OFFSET ?', + (log_type, limit, offset)) + total_row = await _q('SELECT COUNT(*) AS c FROM logs WHERE type = ?', (log_type,)) total = int(total_row[0]['c']) if total_row else 0 return [dict(r) for r in rows], total -def clear_logs(log_type: str) -> int: +async def clear_logs(log_type: str) -> int: """清除指定类型的全部日志,返回删除条数""" - with _DB_LOCK: - conn = get_conn() - try: - cur = conn.execute('DELETE FROM logs WHERE type = ?', (log_type,)) - conn.commit() - return cur.rowcount - finally: - conn.close() + async with write_transaction() as conn: + cur = await conn.execute('DELETE FROM logs WHERE type = ?', (log_type,)) + return cur.rowcount diff --git a/auth_system/mail.py b/auth_system/mail.py index 5576fd6..d57e271 100644 --- a/auth_system/mail.py +++ b/auth_system/mail.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- """SMTP 配置、发信与邮箱验证码。 -(自 auth_system.py 拆出,行为与原实现一致。) +(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步, +阻塞的 smtplib 发信经 asyncio.to_thread 卸载。) """ +import asyncio import base64 import re import secrets @@ -17,11 +19,10 @@ from email.utils import formataddr from typing import Any from .db import ( - _DB_LOCK, _q, _x, - get_conn, get_secret_key, + write_transaction, ) from .security import ( _normalize_email, @@ -46,54 +47,54 @@ def _code_fails_trim() -> None: _SMTP_ENC_PREFIX = 'enc1:' # 信封加密标记前缀(存量明文读取时透明升级) -def _smtp_password_encrypt(plaintext: str) -> str: +async def _smtp_password_encrypt(plaintext: str) -> str: """用 data/secret.key 派生 Fernet 密钥加密 SMTP 授权码。 原实现明文落库、并随数据库备份一起导出,等于扩大泄露半径。""" import hashlib as _hl from cryptography.fernet import Fernet - key = _hl.sha256(get_secret_key().encode('utf-8')).digest() + key = _hl.sha256((await get_secret_key()).encode('utf-8')).digest() f = Fernet(base64.urlsafe_b64encode(key)) return _SMTP_ENC_PREFIX + f.encrypt(plaintext.encode('utf-8')).decode('utf-8') -def _smtp_password_decrypt(ciphertext: str) -> str: +async def _smtp_password_decrypt(ciphertext: str) -> str: if not ciphertext.startswith(_SMTP_ENC_PREFIX): return ciphertext # 存量明文:透明兼容,下次保存时升级为密文 import hashlib as _hl from cryptography.fernet import Fernet - key = _hl.sha256(get_secret_key().encode('utf-8')).digest() + key = _hl.sha256((await get_secret_key()).encode('utf-8')).digest() f = Fernet(base64.urlsafe_b64encode(key)) return f.decrypt(ciphertext[len(_SMTP_ENC_PREFIX):].encode('utf-8')).decode('utf-8') -def get_smtp_config() -> dict[str, Any]: - rows = _q('SELECT * FROM smtp_config WHERE id = 1') +async def get_smtp_config() -> dict[str, Any]: + rows = await _q('SELECT * FROM smtp_config WHERE id = 1') cfg = dict(rows[0]) if rows else {'host': '', 'port': 587, 'sender': '', 'password': ''} # 读取时解密密码——调用方(SMTP 连接/后台展示)拿到的始终是明文 if cfg.get('password'): try: - cfg['password'] = _smtp_password_decrypt(cfg['password']) + cfg['password'] = await _smtp_password_decrypt(cfg['password']) except Exception: cfg['password'] = '' return cfg -def save_smtp_config(host: str, port: int, sender: str, password: str) -> None: - enc_pwd = _smtp_password_encrypt(password) if password else '' - _x('UPDATE smtp_config SET host = ?, port = ?, sender = ?, password = ?, updated_at = ? WHERE id = 1', - (host, int(port or 587), sender, enc_pwd, int(time.time()))) +async def save_smtp_config(host: str, port: int, sender: str, password: str) -> None: + enc_pwd = await _smtp_password_encrypt(password) if password else '' + await _x('UPDATE smtp_config SET host = ?, port = ?, sender = ?, password = ?, updated_at = ? WHERE id = 1', + (host, int(port or 587), sender, enc_pwd, int(time.time()))) -def smtp_configured() -> bool: - cfg = get_smtp_config() +async def smtp_configured() -> bool: + cfg = await get_smtp_config() return bool(cfg['host'] and cfg['sender'] and cfg['password']) -def _smtp_connect() -> smtplib.SMTP: - cfg = get_smtp_config() +def _smtp_connect(cfg: dict[str, Any]) -> smtplib.SMTP: + """连接并登录 SMTP(**阻塞网络 IO**):只应由 asyncio.to_thread 调用。""" port = int(cfg['port'] or 587) # 显式校验证书与主机名的 TLS 上下文(不依赖各运行时默认值) ctx = ssl.create_default_context() @@ -106,21 +107,27 @@ def _smtp_connect() -> smtplib.SMTP: return server -def send_email(to: str, subject: str, body: str) -> None: +def _smtp_send(cfg: dict[str, Any], sender: str, to: str, raw: str) -> None: + """同步发信(阻塞网络 IO):只应由 asyncio.to_thread 调用。""" + with _smtp_connect(cfg) as server: + server.sendmail(sender, [to], raw) + + +async def send_email(to: str, subject: str, body: str) -> None: """收件人格式校验下沉到本入口——未来任何新调用方漏校验也不再成为 CRLF/信封注入点(现有调用点的校验仅作 UX 提示)。""" to = _normalize_email(to) if not re.match(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$', to, re.ASCII): raise ValueError('收件邮箱格式不正确') - cfg = get_smtp_config() + cfg = await get_smtp_config() msg = MIMEText(body, 'plain', 'utf-8') # email 库推荐写法:Header 对象由 Generator._fold 的 Header 分支做 RFC 2047 编码 # (typeshed 把 Message.__setitem__ 的值参数收窄成 str,属桩过窄;运行时已实测正确) msg['Subject'] = Header(subject, 'utf-8') # type: ignore[assignment] msg['From'] = formataddr(('云村小助手', cfg['sender'])) msg['To'] = to - with _smtp_connect() as server: - server.sendmail(cfg['sender'], [to], msg.as_string()) + # smtplib 同步发信是阻塞网络 IO,卸载到线程(正当的阻塞网络 IO 卸载,非同步门面) + await asyncio.to_thread(_smtp_send, cfg, cfg['sender'], to, msg.as_string()) _EMAIL_SENT_24H: dict = {} # 同邮箱 24h 发送计数(内存态,重启清零可接受) @@ -152,7 +159,7 @@ _CODE_EMAIL_TEMPLATES = { } -def send_verify_code(email: str, purpose: str = 'register') -> None: +async def send_verify_code(email: str, purpose: str = 'register') -> None: """生成并发送邮箱验证码(10 分钟有效)(先发信成功再落库;同用途旧码即时作废)。 ① 同邮箱 24h 硬上限 5 封(防邮件轰炸代理/发信配额消耗); ② email 先归一化(Gmail +tag 别名剥离)——别名刷号被邮箱查重堵死。 @@ -166,7 +173,7 @@ def send_verify_code(email: str, purpose: str = 'register') -> None: # 原实现 5000 字符邮箱可入库) if len(email) > 254 or not re.match(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$', email, re.ASCII): raise ValueError('邮箱格式不正确') - if not smtp_configured(): + if not await smtp_configured(): raise RuntimeError('邮箱服务未配置,请联系管理员') # 同邮箱 24h ≤5 封(内存计数,不再依赖会被消费 DELETE 清零的 DB 行数); # admin2fa 豁免 24h 上限与 60s 冷却(见 _CODE_PURPOSES 处注释) @@ -176,14 +183,14 @@ def send_verify_code(email: str, purpose: str = 'register') -> None: raise RuntimeError('该邮箱验证码发送已达上限(24 小时 5 封),请明日再试') # 60 秒内限制重发(按同邮箱同用途的最近一封计) if purpose != 'admin2fa': - rows = _q('SELECT created_at FROM email_codes WHERE email = ? AND purpose = ? ORDER BY id DESC LIMIT 1', - (email, purpose)) + rows = await _q('SELECT created_at FROM email_codes WHERE email = ? AND purpose = ? ORDER BY id DESC LIMIT 1', + (email, purpose)) if rows and time.time() - rows[0]['created_at'] < 60: raise RuntimeError('发送过于频繁,请 1 分钟后再试') code = f'{secrets.randbelow(1000000):06d}' subject, body_tpl = _CODE_EMAIL_TEMPLATES[purpose] # 先发送:SMTP 失败不留任何有效验证码与冷却记录;成功后才计入 24h 额度(admin2fa 不计数) - send_email(email, subject, body_tpl.format(code=code)) + await send_email(email, subject, body_tpl.format(code=code)) if purpose != 'admin2fa': sent.append(now_ts) _EMAIL_SENT_24H[email] = sent @@ -195,20 +202,15 @@ def send_verify_code(email: str, purpose: str = 'register') -> None: if len(_EMAIL_SENT_24H) > 5000: for k in list(_EMAIL_SENT_24H.keys())[:len(_EMAIL_SENT_24H) // 2]: _EMAIL_SENT_24H.pop(k, None) - with _DB_LOCK: - conn = get_conn() - try: - # 新码生效同时作废同邮箱同用途旧码;顺手清理全表过期码 - conn.execute('DELETE FROM email_codes WHERE (email = ? AND purpose = ?) OR expires_at <= ?', - (email, purpose, int(time.time()))) - conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)', - (email, code, int(time.time() + 600), int(time.time()), purpose)) - conn.commit() - finally: - conn.close() + async with write_transaction() as conn: + # 新码生效同时作废同邮箱同用途旧码;顺手清理全表过期码 + await conn.execute('DELETE FROM email_codes WHERE (email = ? AND purpose = ?) OR expires_at <= ?', + (email, purpose, int(time.time()))) + await conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)', + (email, code, int(time.time() + 600), int(time.time()), purpose)) -def check_verify_code(email: str, code: str, purpose: str = 'register') -> int: +async def check_verify_code(email: str, code: str, purpose: str = 'register') -> int: """校验验证码(单条 DELETE 原子消费,rowcount=1 才算通过,杜绝并发双读)。 成功返回剩余有效秒数(真值),失败返回 0。 同一邮箱累计错 5 次→该用途验证码作废(防 10 分钟窗口内挂机猜码)。 @@ -217,47 +219,43 @@ def check_verify_code(email: str, code: str, purpose: str = 'register') -> int: return 0 email = _normalize_email(email or '') fail_key = purpose + ':' + email # 失败计数按"用途:邮箱"隔离,互不连带 - with _DB_LOCK: - conn = get_conn() - try: - cur = conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ? ' - 'RETURNING expires_at', - (email, (code or '').strip(), purpose, int(time.time()))) - row = cur.fetchone() - conn.commit() - if row: - _CODE_FAILS.pop(fail_key, None) - return max(0, int(row[0]) - int(time.time())) - except sqlite3.OperationalError as _oe: - # 老版本 SQLite 无 RETURNING:回退为删除后判 rowcount。 - # 仅"不支持 RETURNING"走回退——库忙(database is locked)原样 - # 上抛,否则第一次等满 busy 15s、回退再等 15s,全程持锁冻结全站写路径 - if 'RETURNING' not in str(_oe) and 'syntax' not in str(_oe).lower(): - raise - conn.rollback() - cur = conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ?', - (email, (code or '').strip(), purpose, int(time.time()))) - conn.commit() + row = None + try: + async with write_transaction() as conn: + cur = await conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ? ' + 'RETURNING expires_at', + (email, (code or '').strip(), purpose, int(time.time()))) + row = await cur.fetchone() + except sqlite3.OperationalError as _oe: + # 老版本 SQLite 无 RETURNING:回退为删除后判 rowcount。 + # 仅"不支持 RETURNING"走回退——库忙(database is locked)原样 + # 上抛,否则第一次等满 busy 15s、回退再等 15s,全程持锁冻结全站写路径 + if 'RETURNING' not in str(_oe) and 'syntax' not in str(_oe).lower(): + raise + async with write_transaction() as conn: + cur = await conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ?', + (email, (code or '').strip(), purpose, int(time.time()))) if cur.rowcount == 1: _CODE_FAILS.pop(fail_key, None) return 60 - row = None - finally: - conn.close() - # 猜错:失败计数 +1,累计 5 次直接作废该用途的验证码 - n = _CODE_FAILS.get(fail_key, 0) + 1 - _CODE_FAILS[fail_key] = n - _code_fails_trim() - if n >= 5: - _CODE_FAILS.pop(fail_key, None) - try: - _x('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose)) - except Exception: - pass + row = None + if row: + _CODE_FAILS.pop(fail_key, None) + return max(0, int(row[0]) - int(time.time())) + # 猜错:失败计数 +1,累计 5 次直接作废该用途的验证码 + n = _CODE_FAILS.get(fail_key, 0) + 1 + _CODE_FAILS[fail_key] = n + _code_fails_trim() + if n >= 5: + _CODE_FAILS.pop(fail_key, None) + try: + await _x('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose)) + except Exception: + pass return 0 -def restore_verify_code(email: str, code: str, ttl_seconds: int, purpose: str = 'register') -> None: +async def restore_verify_code(email: str, code: str, ttl_seconds: int, purpose: str = 'register') -> None: """注册流程后续步骤失败时还原已消费的验证码(第二轮:建号失败不应吞掉验证码)""" if ttl_seconds <= 0: return @@ -267,11 +265,5 @@ def restore_verify_code(email: str, code: str, ttl_seconds: int, purpose: str = code = (code or '').strip() if not email or not code: return - with _DB_LOCK: - conn = get_conn() - try: - conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)', - (email, code, int(time.time() + ttl_seconds), int(time.time()), purpose)) - conn.commit() - finally: - conn.close() + await _x('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)', + (email, code, int(time.time() + ttl_seconds), int(time.time()), purpose)) diff --git a/auth_system/settings.py b/auth_system/settings.py index 7fc3800..a834087 100644 --- a/auth_system/settings.py +++ b/auth_system/settings.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """公告与站点设置。 -(自 auth_system.py 拆出,行为与原实现一致。) +(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。) """ import time @@ -15,28 +15,28 @@ from .db import ( # ============ 公告 ============ -def get_announcement() -> dict[str, Any]: - rows = _q('SELECT * FROM announcements WHERE id = 1') +async def get_announcement() -> dict[str, Any]: + rows = await _q('SELECT * FROM announcements WHERE id = 1') return dict(rows[0]) if rows else {'title': '', 'content': '', 'popup_mode': 'off'} -def save_announcement(title: str, content: str, popup_mode: str) -> None: +async def save_announcement(title: str, content: str, popup_mode: str) -> None: if popup_mode not in ('off', 'every', 'once'): popup_mode = 'off' # 版本号精确到毫秒,同秒多次保存也能区分(once 已读判断依赖该字段) updated = time.strftime('%Y-%m-%d %H:%M:%S') + f'.{int(time.time() * 1000) % 1000:03d}' - _x('UPDATE announcements SET title = ?, content = ?, popup_mode = ?, updated_at = ? WHERE id = 1', - (title, content, popup_mode, updated)) + await _x('UPDATE announcements SET title = ?, content = ?, popup_mode = ?, updated_at = ? WHERE id = 1', + (title, content, popup_mode, updated)) # ============ 设置(键值) ============ -def get_setting(key: str, default: str = '') -> str: - rows = _q('SELECT value FROM settings WHERE key = ?', (key,)) +async def get_setting(key: str, default: str = '') -> str: + rows = await _q('SELECT value FROM settings WHERE key = ?', (key,)) return rows[0]['value'] if rows else default -def set_setting(key: str, value: str) -> None: - _x('INSERT INTO settings (key, value) VALUES (?, ?) ' - 'ON CONFLICT(key) DO UPDATE SET value = excluded.value', (key, str(value))) +async def set_setting(key: str, value: str) -> None: + await _x('INSERT INTO settings (key, value) VALUES (?, ?) ' + 'ON CONFLICT(key) DO UPDATE SET value = excluded.value', (key, str(value))) diff --git a/auth_system/users.py b/auth_system/users.py index efefc3e..72157bf 100644 --- a/auth_system/users.py +++ b/auth_system/users.py @@ -1,9 +1,10 @@ # -*- coding: utf-8 -*- """用户增删改查、登录校验与状态。 -(自 auth_system.py 拆出,行为与原实现一致。) +(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。) """ +import asyncio import json import sqlite3 import time @@ -11,10 +12,9 @@ from dataclasses import dataclass from typing import Any from .db import ( - _DB_LOCK, _q, _x, - get_conn, + write_transaction, ) from .security import ( _hash_new, @@ -27,7 +27,7 @@ from .security import ( class RegistrationGuard: """注册风控上下文:同 IP 频率闸 + 设备赠送闸。 - 这些检查必须与建号 INSERT 在**同一把 _DB_LOCK 事务内**完成——锁外预检存在 + 这些检查必须与建号 INSERT 在**同一写事务内**完成——锁外预检存在 TOCTOU(并发同设备双注册各得一次赠送、并发同 IP 挤过限额),所以它们不是 「可选的附加参数」,而是事务内必须可见的输入。管理建号无风控需求,不传即可。 """ @@ -40,9 +40,9 @@ class RegistrationGuard: device_once: bool = False -def create_user(username: str, password: str, email: str = '', credits: int = 0, - role: str = 'user', *, - guard: RegistrationGuard | None = None) -> sqlite3.Row | None: +async def create_user(username: str, password: str, email: str = '', credits: int = 0, + role: str = 'user', *, + guard: RegistrationGuard | None = None) -> sqlite3.Row | None: # 余额只接受 -1(不限)或 >= 0;非法负值一律按 0 处理,禁止出现 -2 这类"假不限" try: credits = int(credits) @@ -51,165 +51,157 @@ def create_user(username: str, password: str, email: str = '', credits: int = 0, if credits < -1: credits = 0 # 注册入口传 guard=RegistrationGuard(ip_limit=3, device_once=True)——同 IP 24h - # 注册数检查在 _DB_LOCK 事务内与 INSERT 原子完成,堵住「锁外检查 + 并发插入」 + # 注册数检查在写事务内与 INSERT 原子完成,堵住「锁外检查 + 并发插入」 # 的 TOCTOU 刷号;管理建号不传 guard(即不限制)。超限抛 ValueError 由调用方转 400/429。 guard = guard or RegistrationGuard() now = int(time.time()) - with _DB_LOCK: - conn = get_conn() - try: - if guard.ip_limit > 0 and guard.ip: - n = conn.execute('SELECT COUNT(*) AS n FROM users WHERE register_ip = ? AND created_at > ?', - (guard.ip, now - guard.ip_window)).fetchone()[0] - if n >= guard.ip_limit: - raise ValueError('当前网络注册过于频繁,请 24 小时后再试或联系管理员') - if guard.device_once and guard.device_id: - # 设备赠送闸并入同一事务——锁外 device_used 预检有 TOCTOU, - # 并发同设备双注册可各得一次赠送;锁内复查命中则本次建号不赠送(账号照常创建) - n = conn.execute("SELECT COUNT(*) AS n FROM users WHERE trial_device = ? AND role != 'admin'", - (guard.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), - guard.device_id, guard.ip, guard.ua, now, now)) - conn.commit() - finally: - conn.close() - return get_user_by_name(username) + # PBKDF2 10 万次迭代是 CPU 密集(~50ms):在事务外先算好,避免持写锁时 + # 阻塞事件循环(正当的 CPU 卸载,非同步门面)。 + phash = await asyncio.to_thread(_hash_new, password) + async with write_transaction() as conn: + if guard.ip_limit > 0 and guard.ip: + cur = await conn.execute( + 'SELECT COUNT(*) AS n FROM users WHERE register_ip = ? AND created_at > ?', + (guard.ip, now - guard.ip_window)) + row = await cur.fetchone() + n = int(row['n']) if row else 0 + if n >= guard.ip_limit: + raise ValueError('当前网络注册过于频繁,请 24 小时后再试或联系管理员') + if guard.device_once and guard.device_id: + # 设备赠送闸并入同一事务——锁外 device_used 预检有 TOCTOU, + # 并发同设备双注册可各得一次赠送;锁内复查命中则本次建号不赠送(账号照常创建) + cur = await conn.execute( + "SELECT COUNT(*) AS n FROM users WHERE trial_device = ? AND role != 'admin'", + (guard.device_id,)) + row = await cur.fetchone() + if row and int(row['n']) > 0: + credits = 0 + await 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, phash, '', email, role, + -1 if role == 'admin' else int(credits), + guard.device_id, guard.ip, guard.ua, now, now)) + return await get_user_by_name(username) -def get_user_by_name(username: str) -> sqlite3.Row | None: - rows = _q('SELECT * FROM users WHERE username = ?', (username,)) +async def get_user_by_name(username: str) -> sqlite3.Row | None: + rows = await _q('SELECT * FROM users WHERE username = ?', (username,)) return rows[0] if rows else None -def get_user_by_email(email: str) -> sqlite3.Row | None: +async 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,)) + rows = await _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: +async 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))) + rows = await _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,)) +async def get_user_by_id(uid: int) -> sqlite3.Row | None: + rows = await _q('SELECT * FROM users WHERE id = ?', (uid,)) return rows[0] if rows else None -def verify_login(username: str, password: str) -> sqlite3.Row | None: +async def verify_login(username: str, password: str) -> sqlite3.Row | None: """校验用户名密码。密码正确即返回用户行(含被禁用/封禁账号—— 状态语义由登录路由判断:禁用显示"已被禁用"、封禁显示封禁原因。 原实现把禁用账号提前过滤成 None,路由的"账号已被禁用"分支永不可达)""" - user = get_user_by_name(username) + user = await get_user_by_name(username) if user: - ok, needs_upgrade = _verify_password(user['password_hash'], password, user['salt']) + # PBKDF2 校验是 CPU 密集(~50ms),卸载到线程避免阻塞事件循环(正当 CPU 卸载) + ok, needs_upgrade = await asyncio.to_thread( + _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'])) + new_hash = await asyncio.to_thread(_hash_new, password) + await _x('UPDATE users SET password_hash = ?, salt = \'\' WHERE id = ?', + (new_hash, user['id'])) return user return None -def list_users() -> list[dict[str, Any]]: - rows = _q('SELECT * FROM users ORDER BY id DESC') +async def list_users() -> list[dict[str, Any]]: + rows = await _q('SELECT * FROM users ORDER BY id DESC') return [_user_public(r, full=True) for r in rows] -def delete_user(uid: int) -> bool: +async def delete_user(uid: int) -> bool: """删除用户(卡密 used_by 为用户名字符串无外键——删除时同步 标注"(已注销#id)",管理端卡密列表不再显示悬空的已删用户名)""" - u = get_user_by_id(uid) + u = await 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() + async with write_transaction() as conn: + name = u['username'] + await conn.execute('DELETE FROM users WHERE id = ? AND role != ?', (uid, 'admin')) + await conn.execute("UPDATE cards SET used_by = used_by || ? WHERE used_by = ?", + (f'(已注销#{uid})', name)) return True -def reset_password(uid: int, new_password: str) -> bool: +async 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() + new_hash = await asyncio.to_thread(_hash_new, new_password) + await _x('UPDATE users SET password_hash = ?, salt = \'\', ' + 'login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', + (new_hash, uid)) + return True -def set_user_status(uid: int, status: int) -> bool: +async 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,)) + row = await _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))) + return bool(await _x('UPDATE users SET status = ? WHERE id = ?', (status, uid))) -def ban_user(uid: int, reason: str = '') -> bool: +async def ban_user(uid: int, reason: str = '') -> bool: """封禁账号(管理员不可封)""" - row = _q('SELECT role FROM users WHERE id = ?', (uid,)) + row = await _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))) + return bool(await _x('UPDATE users SET status = 2, ban_reason = ? WHERE id = ?', + (str(reason or '')[:200], uid))) -def unban_user(uid: int) -> bool: +async def unban_user(uid: int) -> bool: """解封账号,恢复正常(清空触发封禁的省份历史,否则下次登录立即再封)""" - return bool(_x('UPDATE users SET status = 1, ban_reason = \'\', recent_provinces = \'[]\' WHERE id = ?', (uid,))) + return bool(await _x('UPDATE users SET status = 1, ban_reason = \'\', recent_provinces = \'[]\' WHERE id = ?', (uid,))) -def set_user_credits(uid: int, count: int) -> None: +async 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)) + await _x('UPDATE users SET duration_seconds = ? WHERE id = ?', (count, uid)) -def set_user_password(uid: int, password: str) -> None: +async 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() + new_hash = await asyncio.to_thread(_hash_new, password) + await _x('UPDATE users SET password_hash = ?, salt = \'\', ' + 'login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?', + (new_hash, uid)) -def set_user_netease_cookie(username: str, cookie: str) -> None: - _x('UPDATE users SET netease_cookie = ? WHERE username = ?', (cookie, username)) +async def set_user_netease_cookie(username: str, cookie: str) -> None: + await _x('UPDATE users SET netease_cookie = ? WHERE username = ?', (cookie, username)) def _user_public(r: sqlite3.Row, full: bool = False) -> dict[str, Any]: @@ -249,8 +241,8 @@ def _last_province(r: sqlite3.Row) -> str: # ============ 免费体验(设备识别) ============ -def device_used(device_id: str) -> bool: +async 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,)) + rows = await _q("SELECT id FROM users WHERE trial_device = ? AND role != 'admin' LIMIT 1", (device_id,)) return bool(rows) diff --git a/services/_taskcore.py b/services/_taskcore.py index 36b5531..28aed39 100644 --- a/services/_taskcore.py +++ b/services/_taskcore.py @@ -48,7 +48,7 @@ def _log_worker_crash(handle: asyncio.Task) -> None: api_service.logger.error(f"任务 worker 异常退出: {exc}", exc_info=exc) -def _start_task(task_id: str, worker, args: tuple = ()) -> str | None: +async def _start_task(task_id: str, worker, args: tuple = ()) -> str | None: """启动任务 worker(启动失败时收敛任务终态并按账本退费,不留僵尸 queued 记录)。 退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。 任务句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 已结束 @@ -61,7 +61,7 @@ def _start_task(task_id: str, worker, args: tuple = ()) -> str | None: t = DOWNLOAD_TASKS.pop(task_id, None) if t: try: - auth_system.ledger_settle(task_id, 0, 'submit_failed') + await auth_system.ledger_settle(task_id, 0, 'submit_failed') except Exception: pass api_service.logger.error(f"任务启动失败: {task_id} {e}") @@ -99,10 +99,10 @@ async def _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None: need_settle = True # settle 移到任务锁外——ledger_settle 内部要抢 SQLite 写锁 # (busy 最长 15s),持任务锁调用会让全站进度/提交/取消/领取随库停摆; - # settle 幂等,锁外调用无双退。落库是同步阻塞调用,经 to_thread 卸载 + # settle 幂等,锁外调用无双退。落库已迁移为原生异步,直接 await。 if need_settle: try: - await asyncio.to_thread(auth_system.ledger_settle, task_id, 0, status) + await auth_system.ledger_settle(task_id, 0, status) except Exception: pass @@ -121,8 +121,7 @@ async def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = """批量任务结算(走持久账本,按实际预扣-实际交付幂等退款; 重复调用/重启后调用都只会退一次)""" try: - refund = await asyncio.to_thread( - auth_system.ledger_settle, task_id, ok_count, final_status) + refund = await auth_system.ledger_settle(task_id, ok_count, final_status) except Exception: # 本函数多在异常处理器内被调用——结算再抛错(库忙超时) # 会杀死 worker 收尾路径,跳过终态标记;失败留周期对账兜底(幂等) @@ -134,11 +133,10 @@ async def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = with DOWNLOAD_TASKS_LOCK: t = DOWNLOAD_TASKS.get(task_id) or {} uid = int(t.get('billing_user_id') or 0) - u = await asyncio.to_thread(auth_system.get_user_by_id, uid) if uid else None + u = await auth_system.get_user_by_id(uid) if uid else None uname = u['username'] if u else 'system' remaining = u['duration_seconds'] if u else '?' - await asyncio.to_thread( - auth_system.add_log, 'success', uname, 'system', '批量退费', + await auth_system.add_log('success', uname, 'system', '批量退费', f'失败部分 {refund} 次已退回,剩余 {remaining} 次') except Exception: pass diff --git a/services/downloads.py b/services/downloads.py index 471198b..98b8e79 100644 --- a/services/downloads.py +++ b/services/downloads.py @@ -8,9 +8,8 @@ 各写一遍「结算 + 删任务目录」,正文里又散着四处取消检查。此处按「一步一个具名 函数」拆开:主函数只留线性流程,失败收尾收敛到 ``_fail_single_task`` 一处。 -worker 自本轮起是协程:下载链路本身已异步,落库/删目录等阻塞调用经 -``asyncio.to_thread`` 卸载(auth_system 仍是同步 SQLite,DB 线异步化后即可 -直接 await)。 +worker 自本轮起是协程:下载链路本身已异步,落库已迁移为原生异步直接 await; +删目录/写 ZIP 等阻塞文件 IO 经 ``asyncio.to_thread`` 卸载。 """ import asyncio @@ -261,7 +260,7 @@ async def _publish_single_task(task_id: str, result: dict[str, Any], remote_ip: t['msg'] = '下载完成' t['result'] = result try: - await asyncio.to_thread(auth_system.ledger_settle, task_id, 1, 'delivered') + await auth_system.ledger_settle(task_id, 1, 'delivered') except Exception as _e: api_service.logger.error(f"单曲交付结算失败(重启对账兜底): {task_id} {_e}") try: @@ -280,7 +279,7 @@ async def _fail_single_task(task_id: str, status: str, msg: str, ledger_status: """ await _mark_task_terminal(task_id, status, msg) try: - await asyncio.to_thread(auth_system.ledger_settle, task_id, 0, ledger_status) + await auth_system.ledger_settle(task_id, 0, ledger_status) except Exception: pass if task_dir_path: diff --git a/services/podcast.py b/services/podcast.py index a4f176e..0a2d65b 100644 --- a/services/podcast.py +++ b/services/podcast.py @@ -330,7 +330,7 @@ async def _publish_podcast_task(task_id: str, result: dict[str, Any], remote_ip: t['msg'] = '下载完成' t['result'] = result try: - await asyncio.to_thread(auth_system.ledger_settle, task_id, 1, 'delivered') + await auth_system.ledger_settle(task_id, 1, 'delivered') except Exception as _e: api_service.logger.error(f"播客单集交付结算失败(重启对账兜底): {task_id} {_e}") try: @@ -345,7 +345,7 @@ async def _fail_podcast_task(task_id: str, status: str, msg: str, ledger_status: """失败/取消的统一收尾:置终态 → 退款 → 清掉任务目录(含 ZIP 中间件)。""" await _mark_task_terminal(task_id, status, msg) try: - await asyncio.to_thread(auth_system.ledger_settle, task_id, 0, ledger_status) + await auth_system.ledger_settle(task_id, 0, ledger_status) except Exception: pass if task_dir is not None: @@ -479,7 +479,7 @@ class _PodcastZipJob(ZipJob): _buid = params.get('billing_uid') _extra = count - int(params.get('billed') or 1) if _buid and _extra > 0: - if not await asyncio.to_thread(auth_system.ledger_supplement, task_id, _extra): + if not await auth_system.ledger_supplement(task_id, _extra): raise DownloadException( f"补扣下载次数失败(需补扣 {_extra} 次),任务已取消,预扣部分将退回") if _buid: diff --git a/services/tasks.py b/services/tasks.py index 0c6654f..495f127 100644 --- a/services/tasks.py +++ b/services/tasks.py @@ -6,6 +6,7 @@ 对账与缓存清扫,每项作业的异常兜底粒度也一目了然(见各自 docstring)。 """ +import asyncio import os import shutil import threading @@ -195,7 +196,10 @@ def _cancel_overtime_tasks(now: float) -> None: api_service.logger.warning(f"任务超时自动取消: {tid}") for tid in timeout_settle: try: - auth_system.ledger_settle(tid, 0, 'timeout') + # 监控在**独立线程**里跑(见 _task_monitor_loop),线程无法 await—— + # auth_system 已迁移为原生异步,此处用 asyncio.run 起独立 loop 执行结算 + # (线程边界的一次性同步→异步衔接,非运行时同步门面)。 + asyncio.run(auth_system.ledger_settle(tid, 0, 'timeout')) except Exception: pass @@ -215,10 +219,11 @@ def _run_periodic_ledger_jobs(now: float) -> None: try: if now - _LEDGER_LAST_RECOVER['ts'] > _LEDGER_RECOVER_INTERVAL: _LEDGER_LAST_RECOVER['ts'] = now - auth_system.ledger_recover_orphans(max_age_seconds=_LEDGER_ORPHAN_MAX_AGE) + # 监控线程内的同步→异步衔接:auth_system 已异步化,线程里用 asyncio.run 起 loop + asyncio.run(auth_system.ledger_recover_orphans(max_age_seconds=_LEDGER_ORPHAN_MAX_AGE)) if now - _LEDGER_LAST_CLEANUP['ts'] > _LEDGER_CLEANUP_INTERVAL: _LEDGER_LAST_CLEANUP['ts'] = now - auth_system.ledger_cleanup_settled(_LEDGER_SETTLED_RETENTION) + asyncio.run(auth_system.ledger_cleanup_settled(_LEDGER_SETTLED_RETENTION)) except Exception as e: api_service.logger.warning(f"账本周期兜底异常(下轮重试): {e}") diff --git a/tests/test_admin_backup_upload.py b/tests/test_admin_backup_upload.py index 512561b..cdc88f6 100644 --- a/tests/test_admin_backup_upload.py +++ b/tests/test_admin_backup_upload.py @@ -10,6 +10,7 @@ 本测试用真实 multipart 请求穿过端点,确保三种分支(接受 / 后缀拒 / 内容拒)行为正确。 """ +import asyncio import io import pytest @@ -35,7 +36,7 @@ def client(tmp_path, monkeypatch): monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', tmp_path / 'site.db') - auth.init_db() + asyncio.run(auth.init_db()) app = FastAPI() app.include_router(admin_site.router) diff --git a/tests/test_auth_db_concurrency.py b/tests/test_auth_db_concurrency.py index 8d3823d..8f5e274 100644 --- a/tests/test_auth_db_concurrency.py +++ b/tests/test_auth_db_concurrency.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -"""auth_system 同步栈的读写并发验证:读不持锁、写仍串行(WAL)。""" +"""auth_system 异步栈的读写并发验证:读不持锁、写仍串行(WAL)。""" +import asyncio import sqlite3 import threading -import time import pytest @@ -15,33 +15,21 @@ def temp_site(tmp_path, monkeypatch): monkeypatch.setattr(auth_system.db, 'DB_PATH', tmp_path / 'site.db') monkeypatch.setattr(auth_system.db, 'BACKUP_DIR', tmp_path / 'backups') monkeypatch.setattr(auth_system.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') - auth_system.init_db() + asyncio.run(auth_system.init_db()) return tmp_path / 'site.db' def test_q_does_not_hold_lock(temp_site): - """_q 读不应获取 _DB_LOCK(否则读会被写挡住)。""" - # 让另一个线程持有写锁,读仍应在短时间返回(不被阻塞) - held = threading.Event() - release = threading.Event() + """_q 读不应获取写锁(否则读会被写挡住)。""" - def holder(): - with auth_system._DB_LOCK: - held.set() - release.wait(5) + async def _main(): + # 已在写锁内:读仍应立即返回(读路径不取写锁) + async with auth_system.db._write_lock(): + rows = await auth_system._q('SELECT COUNT(*) AS n FROM users') + assert rows is not None + assert rows[0]['n'] >= 0 - t = threading.Thread(target=holder, daemon=True) - t.start() - held.wait(2) - try: - start = time.monotonic() - rows = auth_system._q('SELECT COUNT(*) AS n FROM users') - elapsed = time.monotonic() - start - assert rows is not None - assert elapsed < 1.0, f'读被写锁阻塞了 {elapsed:.2f}s(WAL 下不应发生)' - finally: - release.set() - t.join(2) + asyncio.run(_main()) def test_concurrent_reads_and_writes(temp_site): @@ -52,7 +40,7 @@ def test_concurrent_reads_and_writes(temp_site): def writer(): try: for i in range(50): - auth_system.set_setting(f'k{i}', str(i)) + asyncio.run(auth_system.set_setting(f'k{i}', str(i))) except Exception as e: errors.append(('w', e)) finally: @@ -61,7 +49,7 @@ def test_concurrent_reads_and_writes(temp_site): def reader(): while not stop.is_set(): try: - auth_system.get_setting('k0') + asyncio.run(auth_system.get_setting('k0')) except Exception as e: errors.append(('r', e)) @@ -75,7 +63,7 @@ def test_concurrent_reads_and_writes(temp_site): r.join(2) assert not errors, f'并发读写异常: {errors[:3]}' - assert auth_system.get_setting('k49') == '49' + assert asyncio.run(auth_system.get_setting('k49')) == '49' def test_wal_enabled(temp_site): diff --git a/tests/test_auth_init_db.py b/tests/test_auth_init_db.py index 0275856..b2eb8d0 100644 --- a/tests/test_auth_init_db.py +++ b/tests/test_auth_init_db.py @@ -8,6 +8,7 @@ 按惯例:先在未改动的原始实现上跑绿,重构时这批测试即等价性验证器。 """ +import asyncio import sqlite3 from typing import Any @@ -90,7 +91,7 @@ def _tables(dbx: dict[str, Any]) -> set[str]: def test_fresh_db_creates_tables_and_admin(dbx, capsys): """全新库:全部表就位、单行配置有种子行、初始管理员 role=admin 且余额 -1。""" - auth.init_db() + asyncio.run(auth.init_db()) tables = _tables(dbx) for t in ('users', 'email_codes', 'cards', 'logs', @@ -115,7 +116,7 @@ def test_fresh_db_creates_tables_and_admin(dbx, capsys): def test_second_run_is_idempotent(dbx): """二次初始化:不报错、不重建管理员、已有数据原样保留。""" - auth.init_db() + asyncio.run(auth.init_db()) conn = _conn(dbx) try: conn.execute("UPDATE users SET duration_seconds = 42 WHERE role = 'admin'") @@ -123,7 +124,7 @@ def test_second_run_is_idempotent(dbx): finally: conn.close() - auth.init_db() # 不应抛 + asyncio.run(auth.init_db()) # 不应抛 conn = _conn(dbx) try: @@ -146,7 +147,7 @@ def test_legacy_db_columns_get_added(dbx): conn.commit() conn.close() - auth.init_db() # 不应抛 + asyncio.run(auth.init_db()) # 不应抛 assert {'status', 'login_seq', 'recent_provinces', 'ban_reason'} <= _cols(dbx, 'users') assert {'note', 'expire_at', 'disabled'} <= _cols(dbx, 'cards') @@ -166,7 +167,7 @@ def test_legacy_db_columns_get_added(dbx): def test_renamed_admin_not_recreated(dbx): """管理员改名(admin→qfbsh)后重启:按「无管理员角色」判断,不得再建新 admin。""" - auth.init_db() + asyncio.run(auth.init_db()) conn = _conn(dbx) try: conn.execute("UPDATE users SET username = 'qfbsh' WHERE role = 'admin'") @@ -174,7 +175,7 @@ def test_renamed_admin_not_recreated(dbx): finally: conn.close() - auth.init_db() + asyncio.run(auth.init_db()) conn = _conn(dbx) try: @@ -200,7 +201,7 @@ def test_duplicate_nonempty_email_warns_and_continues(dbx, capsys): conn.commit() conn.close() - auth.init_db() # 不应抛(查重兜底在注册/建号路由,此处只告警) + asyncio.run(auth.init_db()) # 不应抛(查重兜底在注册/建号路由,此处只告警) out = capsys.readouterr().out assert '重复的非空邮箱' in out, f'应打印人工清理提示,实际输出: {out!r}' @@ -225,7 +226,7 @@ def test_email_normalization_migrates_plus_tag(dbx): conn.commit() conn.close() - auth.init_db() + asyncio.run(auth.init_db()) conn = _conn(dbx) try: @@ -246,7 +247,7 @@ def test_email_normalization_collision_keeps_original(dbx, capsys): conn.commit() conn.close() - auth.init_db() # 不应抛 + asyncio.run(auth.init_db()) # 不应抛 out = capsys.readouterr().out assert '重复' in out, f'应打印撞行告警,实际输出: {out!r}' diff --git a/tests/test_auth_routes.py b/tests/test_auth_routes.py index 4241219..096780f 100644 --- a/tests/test_auth_routes.py +++ b/tests/test_auth_routes.py @@ -5,6 +5,9 @@ 退出登录递增 login_seq、heartbeat 次数校验。 """ +import asyncio +import sqlite3 + import pytest from fastapi.testclient import TestClient @@ -16,6 +19,17 @@ USER_PW = 'UserPass123' ADMIN_PW = 'AdminPass123' +async def _fake_secret_key() -> str: + return SECRET + + +def _connect() -> sqlite3.Connection: + """同步读沙箱库(auth.db.DB_PATH 已被 fixture 指向临时库)。""" + conn = sqlite3.connect(str(auth.db.DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + @pytest.fixture() def app_env(tmp_path, monkeypatch): """沙箱站点库 + 独立密钥,隔离生产数据。""" @@ -26,9 +40,9 @@ def app_env(tmp_path, monkeypatch): monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', db_path) - auth.init_db() + asyncio.run(auth.init_db()) - conn = auth.get_conn() + conn = _connect() conn.execute('INSERT INTO users (username, password_hash, salt, role, duration_seconds, ' 'status, login_seq, created_at) VALUES (?,?,?,?,?,1,0,?)', ('normaluser', auth._hash_new(USER_PW), '', 'user', 97, 1730000000)) @@ -39,7 +53,7 @@ def app_env(tmp_path, monkeypatch): conn.commit() conn.close() - monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) + monkeypatch.setattr('web.deps.secret_key', _fake_secret_key) # 清空登录失败锁定/2FA 的模块级内存态,避免测试间串扰 import web.auth_state as auth_routes @@ -83,13 +97,13 @@ def test_admin_login_returns_2fa_ticket(app_env): def test_logout_bumps_login_seq_and_clears(app_env): app_env.post('/auth/login', json={'username': 'normaluser', 'password': USER_PW}) - conn = auth.get_conn() + conn = _connect() seq_before = conn.execute( "SELECT login_seq FROM users WHERE username='normaluser'").fetchone()[0] conn.close() r = app_env.post('/auth/logout', json={}) assert r.status_code == 200 - conn = auth.get_conn() + conn = _connect() seq_after = conn.execute( "SELECT login_seq FROM users WHERE username='normaluser'").fetchone()[0] conn.close() @@ -100,7 +114,7 @@ def test_logout_bumps_login_seq_and_clears(app_env): def test_heartbeat_expired_credits(app_env): - conn = auth.get_conn() + conn = _connect() conn.execute("UPDATE users SET duration_seconds=0 WHERE username='normaluser'") conn.commit() conn.close() diff --git a/tests/test_billing_scope.py b/tests/test_billing_scope.py index c3c2ad3..a3f4a6b 100644 --- a/tests/test_billing_scope.py +++ b/tests/test_billing_scope.py @@ -5,6 +5,8 @@ settle/rename_to 消费过的行天然不在退款范围,因此自动退与成功路径 互不干扰,调用方无需手动取消。测试直接对真实账本(沙箱库)断言。 """ +import asyncio + import pytest import auth_system as auth @@ -18,22 +20,22 @@ def ledger_env(tmp_path, monkeypatch): monkeypatch.setattr(auth.db, 'DB_PATH', tmp_path / 'site.db') monkeypatch.setattr(auth.db, 'BACKUP_DIR', tmp_path / 'backups') monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') - auth.init_db() - row = auth.create_user('payer', 'Passw0rd12', email='p@example.com', credits=5) + asyncio.run(auth.init_db()) + row = asyncio.run(auth.create_user('payer', 'Passw0rd12', email='p@example.com', credits=5)) assert row is not None, '建号应成功' yield {'uid': int(row['id'])} # 无需清理:tmp_path 由 pytest 回收 -def _row(key: str): +async def _row(key: str): """取账本行,不存在则测试失败(供「行必须存在」的断言用)。""" - row = auth.ledger_get(key) + row = await auth.ledger_get(key) assert row is not None, f'账本行 {key} 应存在' return row -def _user_credits() -> int: - u = auth.get_user_by_name('payer') +async def _user_credits() -> int: + u = await auth.get_user_by_name('payer') assert u is not None, '用户 payer 应存在' return int(u['duration_seconds'] or 0) @@ -43,81 +45,72 @@ def _make_billing(ledger_env, key: str | None) -> Billing: return Billing(user=user, ledger_key=key, need=1, free=False) -@pytest.mark.anyio async def test_scope_auto_refunds_open_row(ledger_env): """正常退出作用域:仍 open 的行被退(rejected),余额恢复。""" key = 'req-aaa' - reserved = auth.reserve_and_open(ledger_env['uid'], 1, key) + reserved = await auth.reserve_and_open(ledger_env['uid'], 1, key) assert reserved is not None - assert _row(key)['status'] == 'open' + assert (await _row(key))['status'] == 'open' billing = _make_billing(ledger_env, key) async with billing.scope(): pass # 业务体正常走完但未结算(等价于路由里的错误早退) - assert _row(key)['status'] == 'rejected' - assert _user_credits() == 5 + assert (await _row(key))['status'] == 'rejected' + assert await _user_credits() == 5 -@pytest.mark.anyio async def test_scope_noop_after_settle(ledger_env): """作用域内已结算:退出自动退为 no-op,交付行不受影响。""" key = 'req-bbb' - auth.reserve_and_open(ledger_env['uid'], 1, key) + await auth.reserve_and_open(ledger_env['uid'], 1, key) billing = _make_billing(ledger_env, key) async with billing.scope(): await billing.settle(1, 'delivered') - assert _row(key)['status'] == 'delivered' - assert _user_credits() == 4 # 恰好扣 1,未被二次退 + assert (await _row(key))['status'] == 'delivered' + assert await _user_credits() == 4 # 恰好扣 1,未被二次退 -@pytest.mark.anyio async def test_scope_noop_after_rename(ledger_env): """作用域内账本已改挂 task_id:请求级行不存在,自动退为 no-op; 任务级行保持 open 交给 worker 结算——与迁移前手工语义逐路径一致。""" key = 'req-ccc' - auth.reserve_and_open(ledger_env['uid'], 1, key) + await auth.reserve_and_open(ledger_env['uid'], 1, key) billing = _make_billing(ledger_env, key) async with billing.scope(): ok = await billing.rename_to('task-xyz') assert ok is True - assert auth.ledger_get(key) is None - moved = _row('task-xyz') + assert await auth.ledger_get(key) is None + moved = await _row('task-xyz') assert moved['status'] == 'open' -@pytest.mark.anyio async def test_scope_refunds_on_exception_and_reraises(ledger_env): """业务体抛异常:先退款,异常原样穿透(路由自行转错误响应)。""" key = 'req-ddd' - auth.reserve_and_open(ledger_env['uid'], 1, key) + await auth.reserve_and_open(ledger_env['uid'], 1, key) billing = _make_billing(ledger_env, key) with pytest.raises(ValueError, match='boom'): async with billing.scope(): raise ValueError('boom') - assert _row(key)['status'] == 'rejected' + assert (await _row(key))['status'] == 'rejected' -@pytest.mark.anyio async def test_scope_free_billing_noop(ledger_env): """free/unlimited(ledger_key=None):作用域进出零动作。""" billing = _make_billing(ledger_env, None) async with billing.scope(): pass # 无账本行可动,不抛异常即为通过 - assert _open_rows() == [] + assert await _open_rows() == [] -def _open_rows(): - conn = auth.get_conn() - try: - rows = conn.execute("SELECT task_id FROM billing_ledger WHERE status='open'").fetchall() - return [dict(r) for r in rows] - finally: - conn.close() +async def _open_rows(): + rows = await auth._q("SELECT task_id FROM billing_ledger WHERE status='open'") + return [dict(r) for r in rows] diff --git a/tests/test_db_conn.py b/tests/test_db_conn.py index 2f99357..cc2388c 100644 --- a/tests/test_db_conn.py +++ b/tests/test_db_conn.py @@ -1,74 +1,83 @@ -"""get_conn 连接策略契约:WAL 首连设置一次 + 目录自动创建。 +"""db.connect 连接策略契约:WAL 首连设置一次 + 目录自动创建。 -背景:get_conn 原实现每个连接都执行 mkdir + PRAGMA journal_mode=WAL, -实测每次多付 ~1.2ms(mkdir 190us + 新连接上 PRAGMA ~1ms)。WAL 模式 -持久化在库文件头里,与 core/db.py 的 _WAL_READY_FOR 同款,按路径设一次即可。 +背景:原同步实现每个连接都执行 mkdir + PRAGMA journal_mode=WAL,实测每次 +多付 ~1.2ms(mkdir 190us + 新连接上 PRAGMA ~1ms)。WAL 模式持久化在库文件头 +里,与 core/db.py 的 _WAL_READY_FOR 同款,按路径设一次即可。迁移到 aiosqlite +后策略不变:`_configure` 在专用锁内首连设置一次,库文件被删重建时(force_wal) +再补设。 """ -import sqlite3 +import asyncio +import threading from pathlib import Path +import aiosqlite + from auth_system import db -def _point_db_to(monkeypatch, tmp_path: Path, conn_counter: list[int] | None = None): +def _point_db_to(monkeypatch, tmp_path: Path, statements: list[str] | None = None): """把 DB_PATH 指到临时库,并清空 WAL 已就绪缓存。""" new_path = tmp_path / 'site.db' monkeypatch.setattr(db, 'DB_PATH', new_path) - monkeypatch.setattr(db, '_WAL_READY', set()) - if conn_counter is not None: - real_connect = sqlite3.connect + monkeypatch.setattr(db, '_WAL_READY_FOR', None) + if statements is not None: + real_connect = aiosqlite.connect - def counting_connect(*a, **k): - conn_counter[0] += 1 - return real_connect(*a, **k) + async def tracing_connect(*a, **k): + # 在第一句 PRAGMA 之前挂追踪器,才能捕获 _configure 执行的语句 + conn = await real_connect(*a, **k) + await conn.set_trace_callback(statements.append) + return conn - monkeypatch.setattr(db.sqlite3, 'connect', counting_connect) + monkeypatch.setattr(db.aiosqlite, 'connect', tracing_connect) return new_path +async def _open_close() -> None: + conn = await db.connect() + await conn.close() + + +async def _journal_mode() -> str: + conn = await db.connect() + try: + row = await (await conn.execute('PRAGMA journal_mode')).fetchone() + assert row is not None + return str(row[0]).lower() + finally: + await conn.close() + + def test_wal_mode_active_on_fresh_db(monkeypatch, tmp_path): """新路径首连:WAL 生效。""" _point_db_to(monkeypatch, tmp_path) - conn = db.get_conn() - try: - mode = conn.execute('PRAGMA journal_mode').fetchone()[0] - assert mode.lower() == 'wal' - finally: - conn.close() + assert asyncio.run(_journal_mode()) == 'wal' def test_wal_pragma_only_once_per_path(monkeypatch, tmp_path): - """同一路径第二次起不再执行 PRAGMA journal_mode=WAL(用 SQL 追踪器实证)。""" - _point_db_to(monkeypatch, tmp_path) - - conn1 = db.get_conn() - conn1.close() - conn2 = db.get_conn() - conn2.close() - - # 用 sqlite3 的 set_trace_callback 记录第三条连接上的语句(2 连接已建立缓存) + """同一路径第二次起不再执行 PRAGMA journal_mode=WAL(SQL 追踪器实证)。""" statements: list[str] = [] - conn3 = db.get_conn() - conn3.set_trace_callback(statements.append) - conn3.execute('SELECT 1').fetchall() - conn3.close() - assert statements == ['SELECT 1'], f'第三条连接不应再跑 PRAGMA,实际: {statements}' + _point_db_to(monkeypatch, tmp_path, statements=statements) + + asyncio.run(_open_close()) + assert any('journal_mode=WAL' in s for s in statements), f'首连必须设 WAL,实际: {statements}' + + statements.clear() + asyncio.run(_open_close()) + assert not any('journal_mode=WAL' in s for s in statements), \ + f'第二条连接不应再设 WAL,实际: {statements}' + # 常规 PRAGMA(busy_timeout 等)每连接仍照设 + assert any('busy_timeout' in s for s in statements), statements def test_new_path_resets_wal_cache(monkeypatch, tmp_path): """换库路径(测试隔离场景):新路径重新设置 WAL。""" _point_db_to(monkeypatch, tmp_path / 'a') - c = db.get_conn() - c.close() + asyncio.run(_open_close()) monkeypatch.setattr(db, 'DB_PATH', tmp_path / 'b' / 'site.db') - c = db.get_conn() - try: - mode = c.execute('PRAGMA journal_mode').fetchone()[0] - assert mode.lower() == 'wal' - finally: - c.close() + assert asyncio.run(_journal_mode()) == 'wal' def test_missing_dir_auto_created(monkeypatch, tmp_path): @@ -77,48 +86,70 @@ def test_missing_dir_auto_created(monkeypatch, tmp_path): _point_db_to(monkeypatch, deep) assert not deep.exists() - conn = db.get_conn() - try: - conn.execute('CREATE TABLE t (v)').close() - conn.commit() - finally: - conn.close() + async def _main(): + conn = await db.connect() + try: + await conn.execute('CREATE TABLE t (v)') + await conn.commit() + finally: + await conn.close() + + asyncio.run(_main()) assert (deep / 'site.db').exists() def test_deleted_db_file_recovers(monkeypatch, tmp_path): """库文件被删(目录还在):重连自动重建且恢复 WAL 模式,不崩。""" _point_db_to(monkeypatch, tmp_path) - c = db.get_conn() - c.close() + asyncio.run(_open_close()) (tmp_path / 'site.db').unlink() - conn = db.get_conn() - try: - conn.execute('SELECT 1').fetchall() - mode = conn.execute('PRAGMA journal_mode').fetchone()[0] - assert mode.lower() == 'wal', '重建的库文件必须恢复 WAL(journal_mode 不持久于新文件)' - finally: - conn.close() + assert asyncio.run(_journal_mode()) == 'wal', '重建的库文件必须恢复 WAL(journal_mode 不持久于新文件)' def test_concurrent_first_connect_no_lease_conflict(monkeypatch, tmp_path): - """多线程同时首连(读路径不持 _DB_LOCK):WAL 设置正确、无连接报错。 - - core/db.py 记录过并发 PRAGMA 争排他锁报 database is locked 的实测坑, - 同步层用专用锁 + 二次复查同款防护。 + """**单事件循环内**并发首连(生产模型:一个 loop 上并发多个请求): + 专用锁 + 二次复查保证 WAL 只切一次,无连接报错。 """ - import threading - _point_db_to(monkeypatch, tmp_path) + + async def _main(): + async def _one(): + conn = await db.connect() + try: + await (await conn.execute('SELECT 1')).fetchall() + finally: + await conn.close() + + await asyncio.gather(*[_one() for _ in range(16)]) + + asyncio.run(_main()) + + +def test_concurrent_connect_from_threads_after_wal_ready(monkeypatch, tmp_path): + """WAL 已就绪后,多线程各自独立 loop 建连不再触排他锁争用(读路径并发)。 + + 为何不再钉「多线程**同时首连**」:异步层的 WAL 切换锁按事件循环隔离 + (见 auth_system/db.py 的 _loop_lock,与 core/db.py 同款),跨 loop 的首连 + 无法用同一把 asyncio.Lock 串行化——那是同步栈 threading.Lock 才有的保证。 + 生产只有一个事件循环(任务监控线程的 asyncio.run 只在 init_db 之后启动), + 首连争用实际发生在单 loop 内,由上一条用例覆盖。 + """ + _point_db_to(monkeypatch, tmp_path) + asyncio.run(_open_close()) # 先把库切到 WAL,置 _WAL_READY_FOR errors: list[Exception] = [] def worker(): + async def _one(): + conn = await db.connect() + try: + await (await conn.execute('SELECT 1')).fetchall() + finally: + await conn.close() + try: for _ in range(5): - c = db.get_conn() - c.execute('SELECT 1').fetchall() - c.close() + asyncio.run(_one()) except Exception as e: # 收集全部失败供断言 errors.append(e) diff --git a/tests/test_deps.py b/tests/test_deps.py index fce6169..ab92fbc 100644 --- a/tests/test_deps.py +++ b/tests/test_deps.py @@ -13,6 +13,11 @@ from web.errors import AppError SECRET = 'deps-test-secret' +async def _fake_secret_key() -> str: + """web.deps.secret_key 已异步化:替身须为协程函数(否则 await 处 TypeError)。""" + return SECRET + + @pytest.fixture() def site(tmp_path, monkeypatch): path = tmp_path / 'site.db' @@ -31,7 +36,7 @@ def site(tmp_path, monkeypatch): conn.close() monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', path) - monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) + monkeypatch.setattr('web.deps.secret_key', _fake_secret_key) return path diff --git a/tests/test_download_billing.py b/tests/test_download_billing.py index adc12c1..0d30b3e 100644 --- a/tests/test_download_billing.py +++ b/tests/test_download_billing.py @@ -8,6 +8,8 @@ - 批量超 500:400 且退款 - 参数校验失败各分支均按「只退 open 行」退费 """ +import asyncio + import pytest from fastapi.testclient import TestClient @@ -18,6 +20,10 @@ SECRET = 'download-test-secret' PW = 'Passw0rd12' +async def _fake_secret_key() -> str: + return SECRET + + @pytest.fixture() def env(tmp_path, monkeypatch): db_path = tmp_path / 'site.db' @@ -27,15 +33,15 @@ def env(tmp_path, monkeypatch): monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', db_path) - auth.init_db() + asyncio.run(auth.init_db()) # 单一密钥源:web 层统一经 web.deps.secret_key 取密钥(调用期解析), # patch 它即让登录签发与依赖校验同源 - monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) + monkeypatch.setattr('web.deps.secret_key', _fake_secret_key) # 造 3 次余额用户 + 0 次余额用户 - auth.create_user('payer', PW, email='p@example.com', credits=3) - auth.create_user('broke', PW, email='b@example.com', credits=0) - auth.create_user('rich', PW, email='r@example.com', credits=600) + asyncio.run(auth.create_user('payer', PW, email='p@example.com', credits=3)) + asyncio.run(auth.create_user('broke', PW, email='b@example.com', credits=0)) + asyncio.run(auth.create_user('rich', PW, email='r@example.com', credits=600)) from web.app import create_app return TestClient(create_app()) @@ -61,15 +67,14 @@ def _login(env, username): def _balance(username): - u = auth.get_user_by_name(username) + u = asyncio.run(auth.get_user_by_name(username)) assert u is not None, f'用户 {username} 不存在,余额无从校验' return int(u['duration_seconds'] or 0) def _open_ledger_rows(): - conn = auth.get_conn() - rows = conn.execute("SELECT task_id, reserved, status FROM billing_ledger WHERE status='open'").fetchall() - conn.close() + rows = asyncio.run(auth._q( + "SELECT task_id, reserved, status FROM billing_ledger WHERE status='open'")) return [dict(r) for r in rows] diff --git a/tests/test_download_sync.py b/tests/test_download_sync.py index 9318ba8..ef59d00 100644 --- a/tests/test_download_sync.py +++ b/tests/test_download_sync.py @@ -11,6 +11,8 @@ test_download_billing.py 覆盖参数校验与退费口径;本文件钉住 for (路由内延迟导入取的就是这个实例属性),MusicInfo/DownloadResult 用 真实 dataclass 构造。 """ +import asyncio + import pytest from fastapi.testclient import TestClient @@ -22,6 +24,10 @@ SECRET = 'download-sync-test-secret' PW = 'Passw0rd12' +async def _fake_secret_key() -> str: + return SECRET + + def _info() -> MusicInfo: return MusicInfo(id=347230, name='晴天', artists='周杰伦', album='叶惠美', pic_url='', duration=269, track_number=1, @@ -44,9 +50,9 @@ def env(tmp_path, monkeypatch): monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', db_path) - auth.init_db() - monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) - auth.create_user('payer', PW, email='p@example.com', credits=3) + asyncio.run(auth.init_db()) + monkeypatch.setattr('web.deps.secret_key', _fake_secret_key) + asyncio.run(auth.create_user('payer', PW, email='p@example.com', credits=3)) state: dict = {'info_exc': None, 'dl_result': _dl(), 'dl_exc': None} @@ -81,16 +87,14 @@ def env(tmp_path, monkeypatch): def _balance(username: str = 'payer') -> int: - u = auth.get_user_by_name(username) + u = asyncio.run(auth.get_user_by_name(username)) assert u is not None, f'用户 {username} 不存在' return int(u['duration_seconds'] or 0) def _open_rows() -> list[dict]: - conn = auth.get_conn() - rows = conn.execute( - "SELECT task_id, status FROM billing_ledger WHERE status='open'").fetchall() - conn.close() + rows = asyncio.run(auth._q( + "SELECT task_id, status FROM billing_ledger WHERE status='open'")) return [dict(r) for r in rows] diff --git a/tests/test_golden.py b/tests/test_golden.py index 7ab0dfa..54c882b 100644 --- a/tests/test_golden.py +++ b/tests/test_golden.py @@ -31,6 +31,12 @@ _GOLDEN = Path(__file__).parent / 'golden' / 'responses.json' SECRET = 'golden-secret' +async def _fake_secret_key() -> str: + """web.deps.secret_key / auth_system.get_secret_key 均已异步化: + 替身须为协程函数,否则 await / asyncio.run 处会报 TypeError。""" + return SECRET + + def _load_cases(): return json.loads(_GOLDEN.read_text(encoding='utf-8')) @@ -80,8 +86,8 @@ def client(tmp_path, monkeypatch): monkeypatch.setattr(auth_system.db, 'DB_PATH', tmp_path / 'site.db') monkeypatch.setattr(core_db, 'DATA_DIR', tmp_path) monkeypatch.setattr(core_db, 'DB_PATH', tmp_path / 'site.db') - monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) - monkeypatch.setattr(auth_system, 'get_secret_key', lambda: SECRET) + monkeypatch.setattr('web.deps.secret_key', _fake_secret_key) + monkeypatch.setattr(auth_system, 'get_secret_key', _fake_secret_key) from web.app import create_app return TestClient(create_app(api_prefix='')) diff --git a/tests/test_login_flow.py b/tests/test_login_flow.py index 45dbe96..1fcaee0 100644 --- a/tests/test_login_flow.py +++ b/tests/test_login_flow.py @@ -4,6 +4,9 @@ test_auth_routes.py 覆盖成功主路径;本文件钉住 login 的各拒绝分支, 作为 login 拆解的等价性验证器。 """ +import asyncio +import sqlite3 + import pytest from fastapi.testclient import TestClient @@ -15,6 +18,16 @@ USER_PW = 'UserPass123' ADMIN_PW = 'AdminPass123' +async def _fake_secret_key() -> str: + return SECRET + + +def _connect() -> sqlite3.Connection: + conn = sqlite3.connect(str(auth.db.DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + @pytest.fixture() def env(tmp_path, monkeypatch): db_path = tmp_path / 'site.db' @@ -24,9 +37,9 @@ def env(tmp_path, monkeypatch): monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', db_path) - auth.init_db() + asyncio.run(auth.init_db()) - conn = auth.get_conn() + conn = _connect() conn.execute('INSERT INTO users (username, password_hash, salt, role, duration_seconds, ' 'status, login_seq, created_at, email) VALUES (?,?,?,?,?,1,0,?,?)', ('mailuser', auth._hash_new(USER_PW), '', 'user', 97, 1730000000, @@ -37,7 +50,7 @@ def env(tmp_path, monkeypatch): conn.commit() conn.close() - monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) + monkeypatch.setattr('web.deps.secret_key', _fake_secret_key) import web.auth_state as auth_state auth_state._LOGIN_FAILS.clear() @@ -55,7 +68,7 @@ def _logs(conn, action: str) -> list[tuple[str, str]]: def test_overlong_password_rejected_without_pbkdf2(env, monkeypatch): """超长密码(>128):不碰 PBKDF2,直接按密码错误拒绝。""" - def _boom(*a, **k): + async def _boom(*a, **k): raise AssertionError('verify_login must not be called') monkeypatch.setattr(auth, 'verify_login', _boom) @@ -90,7 +103,7 @@ def test_locked_log_written_once(env): env.post('/auth/login', json={'username': 'mailuser', 'password': 'nope'}) for _ in range(3): env.post('/auth/login', json={'username': 'mailuser', 'password': USER_PW}) - conn = auth.get_conn() + conn = _connect() locked_logs = [d for t, d in _logs(conn, '登录') if d == '失败次数过多,临时锁定'] conn.close() assert len(locked_logs) == 1 @@ -98,21 +111,21 @@ def test_locked_log_written_once(env): def test_banned_account_message(env, monkeypatch): """封禁账号(status=2):文案带原因 + 失败日志。""" - conn = auth.get_conn() + conn = _connect() conn.execute("UPDATE users SET status=2, ban_reason='刷单' WHERE username='mailuser'") conn.commit() conn.close() r = env.post('/auth/login', json={'username': 'mailuser', 'password': USER_PW}) assert r.json()['message'] == '账号已被封禁:刷单,请联系管理员' - conn = auth.get_conn() + conn = _connect() assert ('fail', '封禁账号尝试登录: 刷单') in _logs(conn, '登录') conn.close() def test_disabled_account_message(env): """禁用账号(status=0):固定文案,无原因拼接。""" - conn = auth.get_conn() + conn = _connect() conn.execute("UPDATE users SET status=0 WHERE username='mailuser'") conn.commit() conn.close() @@ -126,7 +139,7 @@ def test_admin_without_email_blocked_fail_closed(env): r = env.post('/auth/login', json={'username': 'bossuser', 'password': ADMIN_PW}) assert r.json()['message'] == '管理员账号未绑定邮箱,请联系管理员先完成绑定' assert 'session' not in r.cookies - conn = auth.get_conn() + conn = _connect() assert ('fail', '未绑定邮箱,两步验证不可用') in _logs(conn, '管理员登录') conn.close() @@ -140,7 +153,7 @@ def test_geo_ban_bumps_seq_again(env, monkeypatch): r = env.post('/auth/login', json={'username': 'mailuser', 'password': USER_PW}) assert r.json()['message'] == '检测到账号在多个省份异常登录,已自动封禁,请联系管理员' - conn = auth.get_conn() + conn = _connect() seq = conn.execute("SELECT login_seq FROM users WHERE username='mailuser'").fetchone()[0] conn.close() assert seq == 2 # 会话递增 + 封禁再递增 @@ -148,7 +161,7 @@ def test_geo_ban_bumps_seq_again(env, monkeypatch): def test_unexpected_error_is_500(env, monkeypatch): """非预期异常:泛化文案 + HTTP 500。""" - def _boom(*a, **k): + async def _boom(*a, **k): raise RuntimeError('db busy') monkeypatch.setattr(auth, 'verify_login', _boom) diff --git a/tests/test_monitor_jobs.py b/tests/test_monitor_jobs.py index 2e30eec..b381ad7 100644 --- a/tests/test_monitor_jobs.py +++ b/tests/test_monitor_jobs.py @@ -13,6 +13,7 @@ 账本口径同 tests/test_zip_workers.py:一律走 ``reserve_and_open`` 真预扣。 """ +import asyncio import os import threading import time @@ -71,8 +72,8 @@ def sandbox(tmp_path, monkeypatch): monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(core_db, 'DATA_DIR', tmp_path) monkeypatch.setattr(core_db, 'DB_PATH', db_path) - auth.init_db() - auth.create_user('payer', PW, email='p@example.com', credits=CREDITS) + asyncio.run(auth.init_db()) + asyncio.run(auth.create_user('payer', PW, email='p@example.com', credits=CREDITS)) downloads = tmp_path / 'downloads' downloads.mkdir() @@ -207,8 +208,10 @@ def _live_worker() -> _WorkerHandle: def test_cancel_overtime_settles_dead_worker(sandbox, monkeypatch): """超硬上限且 worker 已结束:置取消、唤醒 cancel Event、代为退款。""" settled: list[tuple] = [] - monkeypatch.setattr(auth, 'ledger_settle', - lambda tid, ok, st: settled.append((tid, ok, st))) + async def _settle(tid, ok, st): + settled.append((tid, ok, st)) + + monkeypatch.setattr(auth, 'ledger_settle', _settle) task = _add_task('t-slow', status='running', start=time.time() - _HARD_TIMEOUT - 60, @@ -224,8 +227,10 @@ def test_cancel_overtime_without_worker_task_does_not_settle(sandbox, monkeypatc """无 worker_task 字段的任务不代结算——代结算的前提是「句柄曾存在且已结束」, 没有句柄就无法判断 worker 是否已在别处结算过,贸然退款会双退。""" settled: list[tuple] = [] - monkeypatch.setattr(auth, 'ledger_settle', - lambda tid, ok, st: settled.append((tid, ok, st))) + async def _settle(tid, ok, st): + settled.append((tid, ok, st)) + + monkeypatch.setattr(auth, 'ledger_settle', _settle) task = _add_task('t-noth', status='queued', start=time.time() - _HARD_TIMEOUT - 60) @@ -238,8 +243,10 @@ def test_cancel_overtime_without_worker_task_does_not_settle(sandbox, monkeypatc def test_cancel_overtime_skips_live_worker(sandbox, monkeypatch): """worker 仍在跑:只置取消,不代结算(交 worker 自己收敛,避免双退)。""" settled: list[tuple] = [] - monkeypatch.setattr(auth, 'ledger_settle', - lambda tid, ok, st: settled.append((tid, ok, st))) + async def _settle(tid, ok, st): + settled.append((tid, ok, st)) + + monkeypatch.setattr(auth, 'ledger_settle', _settle) task = _add_task('t-live', status='running', start=time.time() - _HARD_TIMEOUT - 60, worker_task=_live_worker()) @@ -260,10 +267,14 @@ def test_cancel_overtime_ignores_recent(sandbox): def test_ledger_jobs_respect_intervals(sandbox, monkeypatch): """时间戳门槛:到期执行一次并推进门槛,未到期不重复执行。""" calls: list[str] = [] - monkeypatch.setattr(auth, 'ledger_recover_orphans', - lambda max_age_seconds=0: calls.append(f'recover:{max_age_seconds}')) - monkeypatch.setattr(auth, 'ledger_cleanup_settled', - lambda days: calls.append(f'cleanup:{days}')) + async def _recover(max_age_seconds=0): + calls.append(f'recover:{max_age_seconds}') + + async def _cleanup(days): + calls.append(f'cleanup:{days}') + + monkeypatch.setattr(auth, 'ledger_recover_orphans', _recover) + monkeypatch.setattr(auth, 'ledger_cleanup_settled', _cleanup) now = time.time() services_tasks._run_periodic_ledger_jobs(now) @@ -281,7 +292,7 @@ def test_ledger_jobs_respect_intervals(sandbox, monkeypatch): def test_ledger_jobs_swallow_exception(sandbox, monkeypatch): """账本作业抛错由自身吞掉(不能让本轮其余作业一起落空)。""" - def boom(max_age_seconds=0): + async def boom(max_age_seconds=0): raise RuntimeError('库忙') monkeypatch.setattr(auth, 'ledger_recover_orphans', boom) diff --git a/tests/test_podcast_worker.py b/tests/test_podcast_worker.py index 905e80f..5ae604e 100644 --- a/tests/test_podcast_worker.py +++ b/tests/test_podcast_worker.py @@ -111,8 +111,8 @@ def sandbox(tmp_path, monkeypatch): monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(core_db, 'DATA_DIR', tmp_path) monkeypatch.setattr(core_db, 'DB_PATH', db_path) - auth.init_db() - auth.create_user('payer', PW, email='p@example.com', credits=CREDITS) + asyncio.run(auth.init_db()) + asyncio.run(auth.create_user('payer', PW, email='p@example.com', credits=CREDITS)) downloads = tmp_path / 'downloads' downloads.mkdir() @@ -131,13 +131,13 @@ def sandbox(tmp_path, monkeypatch): # ============ 工具 ============ def _uid(username: str = 'payer') -> int: - u = auth.get_user_by_name(username) + u = asyncio.run(auth.get_user_by_name(username)) assert u is not None return int(u['id']) def _balance(username: str = 'payer') -> int: - u = auth.get_user_by_name(username) + u = asyncio.run(auth.get_user_by_name(username)) assert u is not None return int(u['duration_seconds'] or 0) @@ -150,7 +150,7 @@ def _status(task_id: str) -> str: def _register_podcast(sandbox, *, program_id: int = 777, user_id: int, reserved: int = 1, **params: Any) -> tuple[str, dict[str, Any]]: tid = 'p' + str(int(time.time() * 1000000) % 10 ** 10) - left = auth.reserve_and_open(user_id, reserved, tid) + left = asyncio.run(auth.reserve_and_open(user_id, reserved, tid)) assert left is not None, '预扣失败(余额不足,检查 fixture 的 credits)' task: dict[str, Any] = { 'type': 'single', 'task_id': tid, @@ -238,7 +238,7 @@ def test_podcast_download_completes(sandbox, monkeypatch): assert delivered.parent == sandbox['downloads'] / 'tasks' / tid assert delivered.read_bytes() == b'audio-bytes' - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['status'] != 'open' assert row['delivered'] == 1 assert _balance() == before - 1, '交付成功:只扣预扣的 1 次,无退款' @@ -372,7 +372,7 @@ def test_missing_song_id_marks_error_and_refunds(sandbox, monkeypatch): assert task['msg'] == '该单集缺少音频信息(mainSong)' assert task['result'] is None assert _balance() == before, '失败应全额退回预扣' - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['delivered'] == 0 and row['status'] != 'open' assert not (sandbox['downloads'] / 'tasks' / tid).exists(), '失败应清掉任务目录' @@ -556,6 +556,6 @@ def test_ledger_open_when_worker_crashes_before_settle(sandbox, monkeypatch): th.join(timeout=30) assert _status(tid) == 'error' - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None assert row['status'] == 'open', f"应留给对账兜底,实际 status={row['status']}" diff --git a/tests/test_register.py b/tests/test_register.py index 09dd733..874e55b 100644 --- a/tests/test_register.py +++ b/tests/test_register.py @@ -8,6 +8,8 @@ - 找回密码:重置后可用新密码登录、旧会话失效 - 卡密充值 """ +import asyncio +import sqlite3 import time import pytest @@ -19,6 +21,17 @@ import core.db as db SECRET = 'register-test-secret' +async def _fake_secret_key() -> str: + return SECRET + + +def _connect() -> sqlite3.Connection: + """直连沙箱库做只读/播种(auth.get_conn 已异步化,测试用裸连接更直接)。""" + conn = sqlite3.connect(str(auth.db.DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + @pytest.fixture() def env(tmp_path, monkeypatch): db_path = tmp_path / 'site.db' @@ -28,8 +41,8 @@ def env(tmp_path, monkeypatch): monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', db_path) - auth.init_db() - monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) + asyncio.run(auth.init_db()) + monkeypatch.setattr('web.deps.secret_key', _fake_secret_key) import web.auth_state as auth_routes auth_routes._SEND_CODE_IP.clear() from web.app import create_app @@ -37,7 +50,7 @@ def env(tmp_path, monkeypatch): def _seed_code(email: str, code: str, purpose: str = 'register'): - conn = auth.get_conn() + conn = _connect() conn.execute('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose)) conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) ' 'VALUES (?, ?, ?, ?, ?)', @@ -84,7 +97,7 @@ def test_register_bad_code(env): def test_forgot_password_resets(env): # 建一个用户 - auth.create_user('forgetme', 'OldPass123', email='f@example.com', credits=5) + asyncio.run(auth.create_user('forgetme', 'OldPass123', email='f@example.com', credits=5)) _seed_code('f@example.com', '654321', purpose='reset') r = env.post('/auth/forgot_password', json={ 'email': 'f@example.com', 'code': '654321', 'password': 'NewPass456'}) @@ -96,7 +109,7 @@ def test_forgot_password_resets(env): def test_forgot_password_admin_blocked(env): - conn = auth.get_conn() + conn = _connect() conn.execute("UPDATE users SET role='admin', email='boss@example.com' WHERE id=1") conn.commit() conn.close() @@ -109,8 +122,8 @@ def test_forgot_password_admin_blocked(env): def test_redeem_card(env): # 造一张卡密并让用户登录 - auth.create_user('buyer', 'Passw0rd12', email='b@example.com', credits=0) - code = auth.generate_cards(1, 10, note='test')[0] + asyncio.run(auth.create_user('buyer', 'Passw0rd12', email='b@example.com', credits=0)) + code = asyncio.run(auth.generate_cards(1, 10, note='test'))[0] env.post('/auth/login', json={'username': 'buyer', 'password': 'Passw0rd12'}) r = env.post('/auth/redeem', json={'code': code}) assert r.status_code == 200 diff --git a/tests/test_register_flow.py b/tests/test_register_flow.py index cdf4bc7..c36967b 100644 --- a/tests/test_register_flow.py +++ b/tests/test_register_flow.py @@ -7,6 +7,7 @@ create_user 的三种异常映射,作为 register 拆解的等价性验证器 验证码由 check_verify_code 以 DELETE 原子消费:失败路径后 email_codes 行 仍然存在 ⇔ restore_verify_code 确实被调用。以此做实证断言,不 mock 还原。 """ +import asyncio import sqlite3 import time @@ -19,6 +20,17 @@ import core.db as db SECRET = 'register-flow-test-secret' +async def _fake_secret_key() -> str: + return SECRET + + +def _connect() -> sqlite3.Connection: + """直连沙箱库做只读/播种(auth.get_conn 已异步化,测试用裸连接更直接)。""" + conn = sqlite3.connect(str(auth.db.DB_PATH)) + conn.row_factory = sqlite3.Row + return conn + + @pytest.fixture() def env(tmp_path, monkeypatch): db_path = tmp_path / 'site.db' @@ -28,8 +40,8 @@ def env(tmp_path, monkeypatch): monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', db_path) - auth.init_db() - monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) + asyncio.run(auth.init_db()) + monkeypatch.setattr('web.deps.secret_key', _fake_secret_key) import web.auth_state as auth_state auth_state._SEND_CODE_IP.clear() from web.app import create_app @@ -37,7 +49,7 @@ def env(tmp_path, monkeypatch): def _seed_code(email: str, code: str, purpose: str = 'register'): - conn = auth.get_conn() + conn = _connect() conn.execute('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose)) conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) ' 'VALUES (?, ?, ?, ?, ?)', @@ -47,7 +59,7 @@ def _seed_code(email: str, code: str, purpose: str = 'register'): def _code_rows(email: str, purpose: str = 'register') -> list[str]: - conn = auth.get_conn() + conn = _connect() rows = conn.execute('SELECT code FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose)).fetchall() conn.close() @@ -55,7 +67,7 @@ def _code_rows(email: str, purpose: str = 'register') -> list[str]: def _register_logs() -> list[tuple[str, str]]: - conn = auth.get_conn() + conn = _connect() rows = conn.execute("SELECT type, detail FROM logs WHERE action = '注册'").fetchall() conn.close() return [(r['type'], r['detail']) for r in rows] @@ -86,7 +98,7 @@ def test_register_input_validation_order(env): def test_dup_username_restores_code_end_to_end(env): """用户名重名拒绝后,同一验证码无需重发即可再次使用(restore 实证)。""" - auth.create_user('taken', 'Passw0rd12') + asyncio.run(auth.create_user('taken', 'Passw0rd12')) _seed_code('x@example.com', '123456') r = _post(env, 'taken', 'x@example.com', '123456') @@ -98,7 +110,7 @@ def test_dup_username_restores_code_end_to_end(env): def test_dup_email_keeps_code_row(env): """邮箱重名拒绝后验证码行仍在(被 check_verify_code 消费后又还原)。""" - auth.create_user('holder', 'Passw0rd12', email='y@example.com') + asyncio.run(auth.create_user('holder', 'Passw0rd12', email='y@example.com')) _seed_code('y@example.com', '111111') r = _post(env, 'maildup', 'y@example.com', '111111') @@ -108,7 +120,10 @@ def test_dup_email_keeps_code_row(env): def test_ip_exceeded_restores_code_and_logs(env, monkeypatch): """IP 闸触发:还原验证码 + 写失败日志 + 专属文案。""" - monkeypatch.setattr(auth, 'register_ip_exceeded', lambda ip, **k: True) + async def _exceeded(ip, **k): + return True + + monkeypatch.setattr(auth, 'register_ip_exceeded', _exceeded) _seed_code('ip@example.com', '222222') r = _post(env, 'ipuser', 'ip@example.com', '222222') @@ -130,7 +145,7 @@ def test_device_used_grants_zero_credits(env): assert r2.json()['success'] is True assert r2.json()['message'] == '注册成功' - conn = auth.get_conn() + conn = _connect() row = conn.execute("SELECT duration_seconds FROM users WHERE username = 'seconddev'").fetchone() conn.close() assert row['duration_seconds'] == 0 @@ -167,7 +182,10 @@ def test_valueerror_maps_and_restores(env, monkeypatch): def test_user_none_maps_and_restores(env, monkeypatch): """建号事务提交却读不回:按注册失败处理 + 还原验证码 + 失败日志。""" - monkeypatch.setattr(auth, 'create_user', lambda *a, **k: None) + async def _none(*a, **k): + return None + + monkeypatch.setattr(auth, 'create_user', _none) _seed_code('none@example.com', '777777') r = _post(env, 'noneuser', 'none@example.com', '777777') diff --git a/tests/test_root_mode.py b/tests/test_root_mode.py index 2936347..39a1270 100644 --- a/tests/test_root_mode.py +++ b/tests/test_root_mode.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """纯 FastAPI 模式(api_prefix='')测试:前端全部路径由 FastAPI 服务。""" +import asyncio import warnings import pytest @@ -41,8 +42,12 @@ def root_client(tmp_path, monkeypatch): monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(db, 'DATA_DIR', tmp_path) monkeypatch.setattr(db, 'DB_PATH', tmp_path / 'site.db') - auth.init_db() - monkeypatch.setattr('web.deps.secret_key', lambda: 'root-mode-secret') + asyncio.run(auth.init_db()) + + async def _fake_secret_key() -> str: + return 'root-mode-secret' + + monkeypatch.setattr('web.deps.secret_key', _fake_secret_key) from web.app import create_app return TestClient(create_app(api_prefix='')) diff --git a/tests/test_single_worker.py b/tests/test_single_worker.py index 4ce34d7..837bb6c 100644 --- a/tests/test_single_worker.py +++ b/tests/test_single_worker.py @@ -101,8 +101,8 @@ def sandbox(tmp_path, monkeypatch): monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(core_db, 'DATA_DIR', tmp_path) monkeypatch.setattr(core_db, 'DB_PATH', db_path) - auth.init_db() - auth.create_user('payer', PW, email='p@example.com', credits=CREDITS) + asyncio.run(auth.init_db()) + asyncio.run(auth.create_user('payer', PW, email='p@example.com', credits=CREDITS)) downloads = tmp_path / 'downloads' downloads.mkdir() @@ -128,13 +128,13 @@ def sandbox(tmp_path, monkeypatch): # ============ 工具 ============ def _uid(username: str = 'payer') -> int: - u = auth.get_user_by_name(username) + u = asyncio.run(auth.get_user_by_name(username)) assert u is not None return int(u['id']) def _balance(username: str = 'payer') -> int: - u = auth.get_user_by_name(username) + u = asyncio.run(auth.get_user_by_name(username)) assert u is not None return int(u['duration_seconds'] or 0) @@ -148,7 +148,7 @@ def _register_single(sandbox, *, music_id: int = 1, user_id: int, reserved: int = 1, **params: Any) -> tuple[str, dict[str, Any]]: tid = 's' + str(int(time.time() * 1000000) % 10 ** 10) # reserve_and_open:真预扣 + 开账同事务(ledger_open 只记账不扣款,见模块 docstring) - left = auth.reserve_and_open(user_id, reserved, tid) + left = asyncio.run(auth.reserve_and_open(user_id, reserved, tid)) assert left is not None, '预扣失败(余额不足,检查 fixture 的 credits)' task: dict[str, Any] = { 'type': 'single', 'task_id': tid, @@ -215,7 +215,7 @@ def test_single_download_completes(sandbox, monkeypatch): assert delivered.read_bytes() == (sandbox['cache'] / '101.mp3').read_bytes() assert (sandbox['cache'] / '101.mp3').exists(), '共享缓存不应被搬走' - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['status'] != 'open' assert row['delivered'] == 1 assert _balance() == before - 1, '交付成功:只扣预扣的 1 次,无退款' @@ -354,7 +354,7 @@ def test_download_failure_marks_error_and_refunds(sandbox, monkeypatch): assert task['msg'] == '上游 404' assert task['result'] is None assert _balance() == before, '失败应全额退回预扣' - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['delivered'] == 0 and row['status'] != 'open' assert not (sandbox['downloads'] / 'tasks' / tid).exists(), '失败应清掉任务目录' @@ -399,7 +399,7 @@ def test_cancel_mid_download(sandbox, monkeypatch): assert task['msg'] == '任务已取消' assert task['result'] is None assert _balance() == before, '取消应全额退回预扣' - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['delivered'] == 0 assert not (sandbox['downloads'] / 'tasks' / tid).exists() @@ -586,6 +586,6 @@ def test_ledger_open_when_worker_crashes_before_settle(sandbox, monkeypatch): th = _run(tid) th.join(timeout=30) - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None assert row['status'] == 'open', f"应留给对账兜底,实际 status={row['status']}" diff --git a/tests/test_song_types.py b/tests/test_song_types.py index 99b86fa..0062506 100644 --- a/tests/test_song_types.py +++ b/tests/test_song_types.py @@ -33,6 +33,11 @@ SECRET = 'song-test-secret' PATH = '/song' +async def _fake_secret_key() -> str: + """web.deps.secret_key 已异步化:替身必须是协程函数,同步 lambda 会在 await 处报错。""" + return SECRET + + class FakeAPI: """music_api.NeteaseAPI 的替身:记录调用,返回预置结果或抛预置异常。""" @@ -121,7 +126,7 @@ def client(tmp_path, monkeypatch): monkeypatch.setattr(core_db, 'DB_PATH', path) monkeypatch.setattr(auth_system.db, 'DATA_DIR', tmp_path) monkeypatch.setattr(auth_system.db, 'DB_PATH', path) - monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) + monkeypatch.setattr('web.deps.secret_key', _fake_secret_key) from web.app import create_app app = TestClient(create_app(api_prefix='')) cookie = cs.dumps(SECRET, {'site_uid': 1, 'site_uid_seq': 7, 'site_uid_ts': int(time.time())}) diff --git a/tests/test_zip_workers.py b/tests/test_zip_workers.py index d4e0e5c..ed60aad 100644 --- a/tests/test_zip_workers.py +++ b/tests/test_zip_workers.py @@ -98,8 +98,8 @@ def sandbox(tmp_path, monkeypatch): monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') monkeypatch.setattr(core_db, 'DATA_DIR', tmp_path) monkeypatch.setattr(core_db, 'DB_PATH', db_path) - auth.init_db() - auth.create_user('payer', PW, email='p@example.com', credits=CREDITS) + asyncio.run(auth.init_db()) + asyncio.run(auth.create_user('payer', PW, email='p@example.com', credits=CREDITS)) downloads = tmp_path / 'downloads' downloads.mkdir() @@ -118,13 +118,13 @@ def sandbox(tmp_path, monkeypatch): # ============ 工具 ============ def _uid(username: str = 'payer') -> int: - u = auth.get_user_by_name(username) + u = asyncio.run(auth.get_user_by_name(username)) assert u is not None return int(u['id']) def _balance(username: str = 'payer') -> int: - u = auth.get_user_by_name(username) + u = asyncio.run(auth.get_user_by_name(username)) assert u is not None return int(u['duration_seconds'] or 0) @@ -156,7 +156,7 @@ def _register_batch(sandbox, ids: list[int], *, reserved: int, user_id: int, tid = 't' + str(int(time.time() * 1000000) % 10 ** 10) # 必须走 reserve_and_open(真预扣+开账同事务)——ledger_open 只记账不扣款, # 用它会让「退款」凭空给余额加次数,账本守恒断言失去意义 - left = auth.reserve_and_open(user_id, reserved, tid) + left = asyncio.run(auth.reserve_and_open(user_id, reserved, tid)) assert left is not None, '预扣失败(余额不足,检查 fixture 的 credits)' task: dict[str, Any] = { 'type': 'batch', 'task_id': tid, @@ -179,7 +179,7 @@ def _register_batch(sandbox, ids: list[int], *, reserved: int, user_id: int, def _register_podcast(sandbox, pids: list[int], *, reserved: int, user_id: int) -> tuple[str, dict[str, Any]]: tid = 'p' + str(int(time.time() * 1000000) % 10 ** 10) - left = auth.reserve_and_open(user_id, reserved, tid) + left = asyncio.run(auth.reserve_and_open(user_id, reserved, tid)) assert left is not None, '预扣失败(余额不足)' task: dict[str, Any] = { 'type': 'podcast_batch', 'task_id': tid, @@ -234,7 +234,7 @@ def test_batch_zip_completes(sandbox, monkeypatch): assert sum(1 for n in names if n.endswith('.mp3')) == 3, names assert sum(1 for n in names if n.endswith('.lrc')) == 3, names - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['status'] != 'open' assert row['delivered'] == 3 assert _balance() == before - 3, '全部成功:只扣预扣的 3 次,无退款' @@ -288,7 +288,7 @@ def test_batch_zip_pause_then_cancel(sandbox, monkeypatch): assert task['result'] is None, '未交付不应发布 ZIP' assert task['msg'] == '任务已取消(已退回 3 次下载次数)', task['msg'] assert _balance() == before, '取消后应全额退回预扣' - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['delivered'] == 0 and row['status'] != 'open' @@ -307,7 +307,7 @@ def test_batch_zip_all_fail_refunds(sandbox, monkeypatch): assert task['failed'] == 2 assert task['result'] is None, '全失败不应发布 ZIP' assert _balance() == before, '全失败应全额退回预扣' - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['delivered'] == 0 and row['status'] != 'open' @@ -336,7 +336,7 @@ def test_batch_zip_midway_cancel_refunds_all(sandbox, monkeypatch): assert _status(tid) == 'canceled', f"终态={_status(tid)} msg={task.get('msg')}" assert task['result'] is None, '当前实现:循环内取消不发布' assert _balance() == before, '未交付部分全额退回' - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['delivered'] == 0 @@ -415,7 +415,7 @@ def test_podcast_zip_completes(podcast_stub): assert task['done'] == 3 and task['failed'] == 0 names = _zip_names(task['result']['zip_path']) assert sum(1 for n in names if n.endswith('.mp3')) == 3, names - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['delivered'] == 3 assert _balance() == before - 3 @@ -467,7 +467,7 @@ def test_podcast_zip_all_fail_refunds(podcast_stub, monkeypatch): assert _status(tid) == 'error', f"终态={_status(tid)} msg={task.get('msg')}" assert _balance() == before, '全失败应全额退回' - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['delivered'] == 0 @@ -525,7 +525,7 @@ def test_batch_all_fail_message_and_ledger_status(sandbox, monkeypatch): th.join(timeout=30) assert task['msg'] == '全部失败(2 首),已退回 2 次', task['msg'] - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['status'] == 'error' @@ -555,7 +555,7 @@ def test_podcast_all_fail_message_and_ledger_status(podcast_stub, monkeypatch): ), task['msg'] assert task['result'] is None, '不发布空包' assert _balance() == before, '全失败应全额退回' - row = auth.ledger_get(tid) + row = asyncio.run(auth.ledger_get(tid)) assert row is not None and row['status'] == 'error' and row['delivered'] == 0 diff --git a/web/app.py b/web/app.py index 79ca67d..00d902c 100644 --- a/web/app.py +++ b/web/app.py @@ -55,7 +55,7 @@ def _make_lifespan(start_monitor: bool): 离线设备、超时任务不再取消、过期任务与下载缓存不再回收、账本孤儿对账 不再执行(预扣未退款的账永久悬挂)。详见 services.tasks.start_task_monitor。 """ - await asyncio.to_thread(auth_system.init_db) + await auth_system.init_db() if start_monitor: from services.tasks import start_task_monitor @@ -72,11 +72,17 @@ def _make_lifespan(start_monitor: bool): def _init_audio_secret() -> None: - """注入音频签名密钥(与 auth_system 同源,令直链 token 与服务端一致)。""" + """注入音频签名密钥(与 auth_system 同源,令直链 token 与服务端一致)。 + + create_app 是**同步**的应用工厂入口(可能在无事件循环的模块导入期调用), + 而 get_secret_key 已迁移为原生异步——此处用 asyncio.run 起一个临时 loop 完成 + 一次性的密钥读取(启动期一次性边界,非运行时同步门面)。若已处于事件循环中 + 无法 run,则跳过注入(由调用方另行初始化)。 + """ try: import auth_system - init_secret_key(auth_system.get_secret_key()) + init_secret_key(asyncio.run(auth_system.get_secret_key())) except Exception: pass diff --git a/web/billing.py b/web/billing.py index 73a37c6..2a7041a 100644 --- a/web/billing.py +++ b/web/billing.py @@ -9,7 +9,6 @@ 金额相关逻辑一律走 auth_system 既有函数,本模块不新造账务语义。 """ -import asyncio import logging import secrets from collections.abc import AsyncGenerator, Callable @@ -36,16 +35,12 @@ class Billing: key = self.ledger_key if not key: return - - def _do() -> None: - try: - row = auth_system.ledger_get(key) - if row and row['status'] == 'open': - auth_system.ledger_settle(key, 0, reason) - except Exception: - pass - - await asyncio.to_thread(_do) + try: + row = await auth_system.ledger_get(key) + if row and row['status'] == 'open': + await auth_system.ledger_settle(key, 0, reason) + except Exception: + pass async def settle(self, delivered: int, status: str = 'delivered') -> None: """交付结算(json 模式等无 task_id 的场景)。""" @@ -53,16 +48,16 @@ class Billing: if not key: return try: - await asyncio.to_thread(auth_system.ledger_settle, key, delivered, status) + await auth_system.ledger_settle(key, delivered, status) except Exception as e: logger.error('账本结算失败(重启对账兜底): %s %s', key, e) async def rename_to(self, task_id: str) -> bool: """把请求级账本行改挂 task_id(任务接手账目)。无请求级行时开 reserved=0 的账。""" if not self.ledger_key: - await asyncio.to_thread(auth_system.ledger_open, task_id, self.user.id, 0) + await auth_system.ledger_open(task_id, self.user.id, 0) return True - return await asyncio.to_thread(auth_system.ledger_rename, self.ledger_key, task_id) + return await auth_system.ledger_rename(self.ledger_key, task_id) @asynccontextmanager async def scope(self) -> AsyncGenerator['Billing', None]: @@ -100,7 +95,7 @@ async def require_billing( if user is None: raise AppError('请先登录后再使用解析功能', 401, error_code='unauthorized') - fresh = await asyncio.to_thread(auth_system.get_user_by_id, user.id) + fresh = await auth_system.get_user_by_id(user.id) if fresh is None: raise AppError('登录已失效,请重新登录', 401, error_code='unauthorized') @@ -118,10 +113,10 @@ async def require_billing( need = 1 key = 'req-' + secrets.token_hex(8) - remaining = await asyncio.to_thread(auth_system.reserve_and_open, fresh['id'], need, key) + remaining = await auth_system.reserve_and_open(fresh['id'], need, key) if remaining is None: - await asyncio.to_thread(auth_system.add_log, 'fail', fresh['username'], ip, - action or '下载', f'剩余次数不足(本次需 {need} 次)') + await auth_system.add_log('fail', fresh['username'], ip, + action or '下载', f'剩余次数不足(本次需 {need} 次)') raise AppError(f'下载次数已用尽(本次需 {need} 次,当前 {credits} 次),' '请充值卡密后继续下载', 401, error_code='expired') return Billing(user=user, ledger_key=key, need=need, free=False) diff --git a/web/deps.py b/web/deps.py index df39e4f..af7a7ec 100644 --- a/web/deps.py +++ b/web/deps.py @@ -21,10 +21,10 @@ from web.errors import AppError _SESSION_COOKIE = sess.COOKIE_NAME -def secret_key() -> str: +async def secret_key() -> str: """会话签名密钥(密钥来源:data/secret.key)。""" import auth_system - return auth_system.get_secret_key() + return await auth_system.get_secret_key() @dataclass @@ -43,17 +43,17 @@ class SiteUser: return self.is_admin or self.credits == -1 -def _parse_cookie(session_cookie: str | None) -> dict[str, Any] | None: +async def _parse_cookie(session_cookie: str | None) -> dict[str, Any] | None: # 服务端叠加签发时间校验(SESSION_MAX_AGE):Cookie 自身的 max_age 只约束 # 浏览器,被存下来的旧 Cookie 仍能通过签名校验,故过期必须由服务端把关。 - return sess.loads(secret_key(), session_cookie or '', max_age=sess.SESSION_MAX_AGE) + return sess.loads(await secret_key(), session_cookie or '', max_age=sess.SESSION_MAX_AGE) async def current_user( session_cookie: Annotated[str | None, Cookie(alias=_SESSION_COOKIE)] = None, ) -> SiteUser | None: """当前登录用户(未登录/失效返回 None)。""" - data = _parse_cookie(session_cookie) + data = await _parse_cookie(session_cookie) uid = sess.get_uid(data) if not uid: return None diff --git a/web/routers/admin_site.py b/web/routers/admin_site.py index d4f240d..b8432e2 100644 --- a/web/routers/admin_site.py +++ b/web/routers/admin_site.py @@ -3,8 +3,9 @@ 鉴权:与 auth_system.admin_required 同口径(403 + error_code='forbidden')。 -说明:这些端点多为对 auth_system 同步函数(SQLite / SMTP / 文件)的薄封装, -统一经 asyncio.to_thread 执行以不阻塞事件循环;操作者名取当前管理员用户名。 +说明:这些端点多为对 auth_system 的薄封装。auth_system 已迁移为**原生异步** +(aiosqlite / aiosmtplib 语义下的 to_thread 卸载),故直接 await;仅 PBKDF2 校验 +(CPU 密集)与本地文件 stat 仍经 asyncio.to_thread 卸载。操作者名取当前管理员用户名。 """ import asyncio import math @@ -74,14 +75,14 @@ def _int_or(value: Any, default: int = 0) -> int: async def _log(admin: SiteUser, action: str, detail: str = '') -> None: - await asyncio.to_thread(auth.add_log, 'success', admin.username, '-', action, detail) + await auth.add_log('success', admin.username, '-', action, detail) # ============ 用户管理 ============ @router.post('/admin/site/users') async def users(_admin: AdminUser): - rows = await asyncio.to_thread(auth.list_users) + rows = await auth.list_users() return success({'users': rows}) @@ -90,8 +91,8 @@ async def user_delete(admin: AdminUser, request: Request): data = await _request_data(request) uid = _int_or(data.get('id')) try: - target = await asyncio.to_thread(auth.get_user_by_id, uid) - ok = await asyncio.to_thread(auth.delete_user, uid) + target = await auth.get_user_by_id(uid) + ok = await auth.delete_user(uid) if not ok: return error('用户不存在或为管理员') await _log(admin, '删除用户', target['username'] if target else str(uid)) @@ -114,12 +115,12 @@ async def user_duration(admin: AdminUser, request: Request): if count < -1: return error('次数不能小于 -1(-1 表示不限次数)') try: - target = await asyncio.to_thread(auth.get_user_by_id, uid) + target = await auth.get_user_by_id(uid) if not target: return error('用户不存在') if target['role'] == 'admin': return error('管理员不限次数') - await asyncio.to_thread(auth.set_user_credits, uid, count) + await auth.set_user_credits(uid, count) await _log(admin, '修改用户次数', f'{target["username"]} → {count} 次') return success(message=f'已修改为 {count} 次') except Exception as e: @@ -147,12 +148,11 @@ async def user_create(admin: AdminUser, request: Request): if len(password) < 8 or len(password) > 128: return error('密码长度需在 8-128 位之间') try: - if await asyncio.to_thread(auth.get_user_by_name, username): + if await auth.get_user_by_name(username): return error('用户名已存在') - if email and await asyncio.to_thread(auth.get_user_by_email, email): + if email and await auth.get_user_by_email(email): return error('该邮箱已被注册,请更换邮箱') - await asyncio.to_thread( - auth.create_user, username, password, email=email, credits=credits, + await auth.create_user(username, password, email=email, credits=credits, guard=auth.RegistrationGuard(ip='-', ua='admin创建')) await _log(admin, '添加用户', f'{username} · {credits} 次') return success(message=f'用户 {username} 创建成功({credits} 次)') @@ -168,7 +168,7 @@ async def user_reset_password(admin: AdminUser, request: Request): if len(newpwd) < 8 or len(newpwd) > 128: return error('新密码长度需在 8-128 位之间') try: - target = await asyncio.to_thread(auth.get_user_by_id, uid) + target = await auth.get_user_by_id(uid) if not target: return error('用户不存在') if target['role'] == 'admin': @@ -179,10 +179,10 @@ async def user_reset_password(admin: AdminUser, request: Request): target['password_hash'], oldpwd, target['salt']) if not ok: return error('原密码错误') - await asyncio.to_thread(auth.reset_password, uid, newpwd) + await auth.reset_password(uid, newpwd) await _log(admin, '修改自己的密码', '管理员') return success(message='密码已修改,下次登录请使用新密码(其他设备已全部下线)') - await asyncio.to_thread(auth.reset_password, uid, newpwd) + await auth.reset_password(uid, newpwd) await _log(admin, '重置密码', target['username']) return success(message=f'已重置 {target["username"]} 的密码') except Exception as e: @@ -194,14 +194,14 @@ async def user_toggle(admin: AdminUser, request: Request): data = await _request_data(request) uid = _int_or(data.get('id')) try: - target = await asyncio.to_thread(auth.get_user_by_id, uid) + target = await auth.get_user_by_id(uid) if not target: return error('用户不存在') if target['role'] == 'admin': return error('不能对管理员账号执行该操作') cur = target['status'] if 'status' in target.keys() else 1 new_status = 0 if cur else 1 - await asyncio.to_thread(auth.set_user_status, uid, new_status) + await auth.set_user_status(uid, new_status) await _log(admin, '禁用/启用', f'{target["username"]} → {"启用" if new_status else "禁用"}') return success({'status': new_status}, @@ -217,17 +217,17 @@ async def user_ban(admin: AdminUser, request: Request): action = str(data.get('action') or 'ban') reason = str(data.get('reason') or '') try: - row = await asyncio.to_thread(auth.get_user_by_id, uid) + row = await auth.get_user_by_id(uid) if not row: return error('用户不存在') if row['role'] == 'admin': return error('不能对管理员执行该操作') if action == 'ban': - if not await asyncio.to_thread(auth.ban_user, uid, reason or '管理员手动封禁'): + if not await auth.ban_user(uid, reason or '管理员手动封禁'): return error('封禁失败') await _log(admin, '封禁用户', f'{row["username"]}: {reason or "手动封禁"}') return success(message=f'已封禁用户「{row["username"]}」,该账号已强制下线') - if not await asyncio.to_thread(auth.unban_user, uid): + if not await auth.unban_user(uid): return error('解封失败') await _log(admin, '解封用户', row['username']) return success(message=f'已解封用户「{row["username"]}」') @@ -239,7 +239,7 @@ async def user_ban(admin: AdminUser, request: Request): async def user_cookie(_admin: AdminUser, request: Request): data = await _request_data(request) username = str(data.get('username') or '') - user = await asyncio.to_thread(auth.get_user_by_name, username) + user = await auth.get_user_by_name(username) if not user: return error('用户不存在', 404) return success({'username': username, 'cookie': user['netease_cookie'] or ''}) @@ -249,7 +249,7 @@ async def user_cookie(_admin: AdminUser, request: Request): @router.post('/admin/site/smtp/get') async def smtp_get(_admin: AdminUser): - cfg = await asyncio.to_thread(auth.get_smtp_config) + cfg = await auth.get_smtp_config() configured = bool(cfg.get('host') and cfg.get('sender') and cfg.get('password')) return success({'host': cfg.get('host', ''), 'port': cfg.get('port', ''), 'sender': cfg.get('sender', ''), 'configured': configured}) @@ -269,9 +269,9 @@ async def smtp_save(admin: AdminUser, request: Request): return error('SMTP 服务器与发件邮箱不能为空') try: if not password: - cfg = await asyncio.to_thread(auth.get_smtp_config) + cfg = await auth.get_smtp_config() password = cfg.get('password', '') - await asyncio.to_thread(auth.save_smtp_config, host, port, sender, password) + await auth.save_smtp_config(host, port, sender, password) await _log(admin, '保存SMTP配置', f'{host}:{port}') return success(message='SMTP 配置已保存') except Exception as e: @@ -287,12 +287,12 @@ async def smtp_test(admin: AdminUser, request: Request): if not _EMAIL_RE.match(to): return error('收件邮箱格式不正确') try: - await asyncio.to_thread(auth.send_email, to, 'SMTP 测试邮件', + await auth.send_email(to, 'SMTP 测试邮件', '这是一封来自云村小助手的测试邮件,收到即说明 SMTP 配置成功。') await _log(admin, 'SMTP测试', to) return success(message='测试邮件已发送,请查收') except Exception as e: - await asyncio.to_thread(auth.add_log, 'fail', admin.username, '-', 'SMTP测试', str(e)) + await auth.add_log('fail', admin.username, '-', 'SMTP测试', str(e)) return error(f'发送失败: {e!s}', 500) @@ -303,7 +303,7 @@ async def logs(_admin: AdminUser, request: Request): data = await _request_data(request) log_type = 'fail' if str(data.get('type')) == 'fail' else 'success' page = max(1, _int_or(data.get('page'), 1)) - rows, total = await asyncio.to_thread(auth.query_logs, log_type, 10, (page - 1) * 10) + rows, total = await auth.query_logs(log_type, 10, (page - 1) * 10) return success({'logs': rows, 'type': log_type, 'page': page, 'total': total, 'pages': max(1, (total + 9) // 10)}) @@ -312,7 +312,7 @@ async def logs(_admin: AdminUser, request: Request): async def logs_clear(admin: AdminUser, request: Request): data = await _request_data(request) log_type = 'fail' if str(data.get('type')) == 'fail' else 'success' - deleted = await asyncio.to_thread(auth.clear_logs, log_type) + deleted = await auth.clear_logs(log_type) await _log(admin, '清除日志', f'{log_type} · {deleted} 条') return success(message=f'已清除 {"失败" if log_type == "fail" else "成功"}日志 {deleted} 条') @@ -335,8 +335,7 @@ async def cards_generate(admin: AdminUser, request: Request): if credits <= 0: return error('卡密次数必须大于 0') try: - codes = await asyncio.to_thread( - auth.generate_cards, count, credits, created_by=admin.username, + codes = await auth.generate_cards(count, credits, created_by=admin.username, prefix=str(data.get('prefix') or 'VIP'), note=str(data.get('note') or ''), valid_days=valid_days) await _log(admin, '生成卡密', f'{count} 张 · {credits} 次/张 · 有效{valid_days or "永久"}天') @@ -351,7 +350,7 @@ async def cards_list(_admin: AdminUser, request: Request): data = await _request_data(request) limit = _int_or(data.get('limit'), 100) or 100 offset = _int_or(data.get('offset'), 0) - cards, total = await asyncio.to_thread(auth.list_cards, limit=limit, offset=offset) + cards, total = await auth.list_cards(limit=limit, offset=offset) return success({'cards': cards, 'total': total}) @@ -363,7 +362,7 @@ async def cards_disable(admin: AdminUser, request: Request): return error('缺少卡密') disable = str(data.get('disabled') or '').strip().lower() in ('1', 'true') try: - await asyncio.to_thread(auth.set_card_disabled, code, disable) + await auth.set_card_disabled(code, disable) await _log(admin, '禁用卡密' if disable else '恢复卡密', code[:4] + '****') return success(message=('已禁用 ' if disable else '已恢复 ') + code[:4] + '****') except ValueError as e: @@ -379,7 +378,7 @@ async def cards_delete(admin: AdminUser, request: Request): if not code: return error('缺少卡密') try: - await asyncio.to_thread(auth.delete_card, code) + await auth.delete_card(code) await _log(admin, '删除卡密', code[:4] + '****') return success(message='已删除 ' + code[:4] + '****') except ValueError as e: @@ -392,14 +391,14 @@ async def cards_delete(admin: AdminUser, request: Request): @router.post('/admin/site/ann/get') async def ann_get(_admin: AdminUser): - return success(await asyncio.to_thread(auth.get_announcement)) + return success(await auth.get_announcement()) @router.post('/admin/site/ann/save') async def ann_save(admin: AdminUser, request: Request): data = await _request_data(request) try: - await asyncio.to_thread(auth.save_announcement, str(data.get('title') or ''), + await auth.save_announcement(str(data.get('title') or ''), str(data.get('content') or ''), str(data.get('popup_mode') or 'off')) await _log(admin, '保存公告', str(data.get('title') or '')) return success(message='公告已保存') @@ -411,17 +410,17 @@ async def ann_save(admin: AdminUser, request: Request): @router.post('/admin/site/play_key/get') async def play_key_get(_admin: AdminUser): - key = await asyncio.to_thread(auth.get_setting, 'playlist_play_key', '') + key = await auth.get_setting('playlist_play_key', '') if not key: key = secrets.token_urlsafe(16) - await asyncio.to_thread(auth.set_setting, 'playlist_play_key', key) + await auth.set_setting('playlist_play_key', key) return success({'key': key}) @router.post('/admin/site/play_key/rotate') async def play_key_rotate(admin: AdminUser): key = secrets.token_urlsafe(16) - await asyncio.to_thread(auth.set_setting, 'playlist_play_key', key) + await auth.set_setting('playlist_play_key', key) await _log(admin, '刷新播放API密钥', '旧密钥已失效') return success({'key': key}, '密钥已刷新,旧密钥已全部失效') @@ -431,8 +430,8 @@ async def play_key_rotate(admin: AdminUser): @router.post('/admin/site/backup/create') async def backup_create(admin: AdminUser): try: - name = await asyncio.to_thread(auth.create_backup) - await asyncio.to_thread(auth.set_setting, 'last_backup_ts', str(time.time())) + name = await auth.create_backup() + await auth.set_setting('last_backup_ts', str(time.time())) await _log(admin, '数据库备份', name) return success({'name': name}, f'备份完成:{name}') except Exception as e: @@ -441,8 +440,8 @@ async def backup_create(admin: AdminUser): @router.post('/admin/site/backup/list') async def backup_list(_admin: AdminUser): - backups = await asyncio.to_thread(auth.list_backups) - interval = await asyncio.to_thread(auth.get_setting, 'backup_interval_hours', '0') + backups = await auth.list_backups() + interval = await auth.get_setting('backup_interval_hours', '0') def _size() -> int: return auth.db.DB_PATH.stat().st_size if auth.db.DB_PATH.exists() else 0 @@ -454,7 +453,7 @@ async def backup_list(_admin: AdminUser): @router.get('/admin/site/backup/download') async def backup_download(_admin: AdminUser, request: Request): name = request.query_params.get('name') or '' - p = await asyncio.to_thread(auth.backup_path, name) + p = await auth.backup_path(name) if not p: return error('备份文件不存在', 404) resp = FileResponse(str(p), filename=name) @@ -492,7 +491,7 @@ async def backup_upload(admin: AdminUser, request: Request): return error('不是有效的 SQLite 数据库文件') await _log(admin, '上传数据库', dest.name) try: - await asyncio.to_thread(auth.prune_backups, 20, 5) + await auth.prune_backups(20, 5) except Exception: pass return success({'name': dest.name}, '上传成功,可在下方选择该备份进行恢复') @@ -510,13 +509,13 @@ async def backup_restore(admin: AdminUser, request: Request): data = await _request_data(request) name = str(data.get('name') or '') try: - await asyncio.to_thread(auth.restore_backup, name) + await auth.restore_backup(name) await _log(admin, '恢复数据库', name) return success(message='数据库已恢复,请刷新页面') except ValueError as e: return error(f'恢复失败: {e!s}', 400) except Exception as e: - await asyncio.to_thread(auth.add_log, 'fail', admin.username, '-', '恢复数据库', str(e)) + await auth.add_log('fail', admin.username, '-', '恢复数据库', str(e)) return error(f'恢复失败: {e!s}', 500) @@ -525,7 +524,7 @@ async def backup_delete(_admin: AdminUser, request: Request): data = await _request_data(request) name = str(data.get('name') or '') try: - if await asyncio.to_thread(auth.delete_backup, name): + if await auth.delete_backup(name): return success(message='备份已删除') return error('备份文件不存在', 404) except Exception as e: @@ -544,7 +543,7 @@ async def backup_auto(_admin: AdminUser, request: Request): if hours < 0: return error('间隔不能为负') try: - await asyncio.to_thread(auth.set_setting, 'backup_interval_hours', str(hours)) + await auth.set_setting('backup_interval_hours', str(hours)) return success(message=f'定时备份已开启,每 {hours} 小时自动备份' if hours > 0 else '定时备份已关闭') except Exception as e: diff --git a/web/routers/auth.py b/web/routers/auth.py index 99a7ec1..02b66e1 100644 --- a/web/routers/auth.py +++ b/web/routers/auth.py @@ -10,11 +10,10 @@ 设计: - 登录失败锁定与管理员 2FA 票据表**复用 web.auth_state 的模块级状态**,全进程共享同一份 计数/票据,避免「换个框架打就绕过锁定」。 -- 密码校验(PBKDF2,10 万次迭代,CPU 密集)与库操作经 asyncio.to_thread 执行, - 不阻塞事件循环。 +- 密码校验(PBKDF2,10 万次迭代,CPU 密集)已在 auth_system.verify_login 内部经 + asyncio.to_thread 卸载;其余 auth_system 调用已迁移为原生异步(aiosqlite)直接 await。 - 会话 Cookie 由 core.session 签发(沿用同一格式与密钥,存量会话继续有效),删 Cookie 亦同口径。 """ -import asyncio import os import time from typing import Annotated @@ -51,10 +50,10 @@ def _cookie_kwargs() -> dict: 'path': '/', 'secure': secure} -def _login_response(payload: dict, message: str, uid: int, seq: int) -> JSONResponse: +async def _login_response(payload: dict, message: str, uid: int, seq: int) -> JSONResponse: """构造带会话 Cookie 的成功响应(沿用同一 Cookie 格式)。""" resp = JSONResponse(content={'status': 200, 'success': True, 'message': message, 'data': payload}) - cookie = cs.dumps(deps.secret_key(), { + cookie = cs.dumps(await deps.secret_key(), { 'site_uid': uid, 'site_uid_seq': seq, 'site_uid_ts': int(time.time()), }) resp.set_cookie(SESSION_COOKIE, cookie, **_cookie_kwargs()) @@ -109,7 +108,7 @@ async def _resolve_login_identity(username: str) -> tuple: """ if '@' not in username: return None, username - iuser = await asyncio.to_thread(auth.get_user_by_email, username) + iuser = await auth.get_user_by_email(username) lock_name = iuser['username'] if iuser else (auth._normalize_email(username) or username) return iuser, lock_name @@ -124,7 +123,7 @@ async def _allow_login_attempt(lock_name: str, ip: str) -> bool: if _login_fail_check(lock_name): return True if _login_fail_locked_log_once(lock_name): - await asyncio.to_thread(auth.add_log, 'fail', lock_name, ip, + await auth.add_log('fail', lock_name, ip, '登录', '失败次数过多,临时锁定') return False @@ -135,7 +134,7 @@ async def _check_account_status(user, lock_name: str, ip: str): status = user['status'] if 'status' in keys else 1 if status == 2: reason = user['ban_reason'] if 'ban_reason' in keys else '' - await asyncio.to_thread(auth.add_log, 'fail', lock_name, ip, + await auth.add_log('fail', lock_name, ip, '登录', f'封禁账号尝试登录: {reason}') return error('账号已被封禁' + (f':{reason}' if reason else '') + ',请联系管理员') if status == 0: @@ -152,11 +151,11 @@ async def _admin_two_factor_response(user, lock_name: str, ip: str) -> JSONRespo admin_email = user['email'] if 'email' in user.keys() else '' if not admin_email: - await asyncio.to_thread(auth.add_log, 'fail', lock_name, ip, + await auth.add_log('fail', lock_name, ip, '管理员登录', '未绑定邮箱,两步验证不可用') return error('管理员账号未绑定邮箱,请联系管理员先完成绑定') ticket = _admin2fa_issue(user) - await asyncio.to_thread(auth.add_log, 'success', lock_name, ip, + await auth.add_log('success', lock_name, ip, '管理员登录', '密码验证通过,待邮箱验证') return success({'ticket': ticket, 'email_masked': _admin2fa_mask(admin_email), 'username': user['username'], 'admin_2fa': True}, @@ -180,7 +179,7 @@ async def _user_session_response(user, ip: str) -> JSONResponse: return error('检测到账号在多个省份异常登录,已自动封禁,请联系管理员') fresh = await repo.get_user_by_id(uid) or {} - return _login_response({ + return await _login_response({ 'user': _user_public(fresh), 'duration_text': _format_credits(int(fresh.get('duration_seconds') or 0)), 'redirect': 'admin' if fresh.get('role') == 'admin' else 'user', @@ -209,7 +208,7 @@ async def login(request: Request): return error('尝试过于频繁,该账号已临时锁定,请 10 分钟后再试') verify_name = iuser['username'] if iuser is not None else username - user = await asyncio.to_thread(auth.verify_login, verify_name, password) + user = await auth.verify_login(verify_name, password) if not user: from web.auth_state import _login_fail_record _login_fail_record(lock_name) @@ -256,7 +255,7 @@ async def admin_login_email(request: Request): if (not email_input or not v.get('email') or not _hmac.compare_digest(email_input, v['email'])): v['attempts'] += 1 - await asyncio.to_thread(auth.add_log, 'fail', v['username'], ip, '管理员登录', '绑定邮箱不匹配') + await auth.add_log('fail', v['username'], ip, '管理员登录', '绑定邮箱不匹配') if v['attempts'] >= _ADMIN_2FA_MAX_ATTEMPTS: _ADMIN_2FA.pop(ticket, None) return error('错误次数过多,请重新开始登录') @@ -266,9 +265,9 @@ async def admin_login_email(request: Request): if time.time() - last_send < _ADMIN2FA_SEND_GAP: return error('验证码发送过于频繁,请 1 分钟后再试') try: - await asyncio.to_thread(auth.send_verify_code, v['email'], 'admin2fa') + await auth.send_verify_code(v['email'], 'admin2fa') _ADMIN2FA_LAST_SEND[v['username']] = time.time() - await asyncio.to_thread(auth.add_log, 'success', v['username'], ip, '管理员登录', + await auth.add_log('success', v['username'], ip, '管理员登录', f'绑定邮箱验证通过,验证码已发送({_admin2fa_mask(v["email"])})') return success(message='验证码已发送至管理员邮箱,请查收') except (ValueError, RuntimeError) as e: @@ -293,22 +292,22 @@ async def admin_login_verify(request: Request): return error('登录会话已过期,请重新开始登录', 401, error_code='admin_2fa_expired') code = str(data.get('code') or '').strip() - if not await asyncio.to_thread(auth.check_verify_code, v['email'], code, 'admin2fa'): + if not await auth.check_verify_code(v['email'], code, 'admin2fa'): v['attempts'] += 1 if v['attempts'] >= _ADMIN_2FA_MAX_ATTEMPTS: _ADMIN_2FA.pop(ticket, None) return error('错误次数过多,请重新开始登录') - await asyncio.to_thread(auth.add_log, 'fail', v['username'], ip, '管理员登录', '验证码错误') + await auth.add_log('fail', v['username'], ip, '管理员登录', '验证码错误') return error('验证码错误或已过期') _ADMIN_2FA.pop(ticket, None) # 一次性 - user = await asyncio.to_thread(auth.get_user_by_id, v['user_id']) + user = await auth.get_user_by_id(v['user_id']) if not user or user['role'] != 'admin': return error('登录会话已过期,请重新开始登录', 401, error_code='admin_2fa_expired') keys = user.keys() status = user['status'] if 'status' in keys else 1 if status == 2: - await asyncio.to_thread(auth.add_log, 'fail', v['username'], ip, '管理员登录', '封禁账号尝试登录') + await auth.add_log('fail', v['username'], ip, '管理员登录', '封禁账号尝试登录') return error('账号已被封禁,请联系管理员') if status == 0: return error('账号已被禁用,请联系管理员') @@ -326,8 +325,8 @@ async def admin_login_verify(request: Request): return error('检测到账号在多个省份异常登录,已自动封禁,请联系管理员') fresh = await repo.get_user_by_id(uid) or {} - await asyncio.to_thread(auth.add_log, 'success', v['username'], ip, '管理员登录', '两步验证通过') - return _login_response({ + await auth.add_log('success', v['username'], ip, '管理员登录', '两步验证通过') + return await _login_response({ 'user': _user_public(fresh), 'duration_text': _format_credits(int(fresh.get('duration_seconds') or 0)), 'redirect': 'admin', diff --git a/web/routers/music.py b/web/routers/music.py index d6025f4..17260bf 100644 --- a/web/routers/music.py +++ b/web/routers/music.py @@ -219,7 +219,7 @@ async def _resolve_user_cookies( if mu and gmu and hmac.compare_digest(mu, gmu): try: import auth_system - await asyncio.to_thread(auth_system.add_log, 'fail', + await auth_system.add_log('fail', user.username if user else '匿名', ratelimit.client_ip(request), '全局账号冒用拦截', '浏览器提交的 MUSIC_U 与全局账号一致,已按未登录拒绝') diff --git a/web/routers/register.py b/web/routers/register.py index 2ccda60..51e0be2 100644 --- a/web/routers/register.py +++ b/web/routers/register.py @@ -9,12 +9,11 @@ - POST /api/v1/auth/forgot_password ↔ Flask /auth/forgot_password 说明: -- 同步的 auth_system 调用(SQLite 事务、邮件 SMTP、PBKDF2)经 asyncio.to_thread 执行, - 不阻塞事件循环;行为与历史实现完全一致。 +- auth_system 已迁移为**原生异步**(aiosqlite),此处直接 await,不再经 asyncio.to_thread + 桥接;PBKDF2 等 CPU 密集与 smtplib 阻塞发送已在 auth_system 内部就卸载。 - 发码 IP 限流复用 web.auth_state 的模块级状态 `_SEND_CODE_IP`。 - 失败锁定/验证码消费等安全口径逐字保留。 """ -import asyncio import os import sqlite3 import time @@ -43,11 +42,11 @@ _SESSION_MAX_AGE = cs.SESSION_MAX_AGE -def _login_cookie_response(payload: dict, message: str, uid: int, seq: int, - secure: bool) -> JSONResponse: +async def _login_cookie_response(payload: dict, message: str, uid: int, seq: int, + secure: bool) -> JSONResponse: resp = JSONResponse(content={'status': 200, 'success': True, 'message': message, 'data': payload}) - cookie = cs.dumps(deps.secret_key(), {'site_uid': uid, 'site_uid_seq': seq, - 'site_uid_ts': int(time.time())}) + cookie = cs.dumps(await deps.secret_key(), {'site_uid': uid, 'site_uid_seq': seq, + 'site_uid_ts': int(time.time())}) resp.set_cookie(SESSION_COOKIE, cookie, max_age=_SESSION_MAX_AGE, httponly=True, samesite='lax', path='/', secure=secure) return resp @@ -74,7 +73,7 @@ async def send_code(request: Request): if purpose not in ('register', 'reset'): return error('验证码用途不正确') try: - await asyncio.to_thread(auth.send_verify_code, email, purpose) + await auth.send_verify_code(email, purpose) return success(message='验证码已发送,请查收邮箱') except (ValueError, RuntimeError) as e: return error(str(e)) @@ -97,14 +96,14 @@ async def redeem(user: Annotated[SiteUser | None, Depends(current_user)], reques data = await _request_data(request) code = str(data.get('code') or '') try: - credits, remaining = await asyncio.to_thread(auth.redeem_card, code, user.username) - await asyncio.to_thread(auth.add_log, 'success', user.username, ip, '卡密充值', + credits, remaining = await auth.redeem_card(code, user.username) + await auth.add_log('success', user.username, ip, '卡密充值', f'{code[:4]}**** · +{credits} 次') return success({'credits': credits, 'duration_seconds': remaining, 'duration_text': auth.format_credits(remaining)}, f'充值成功,+{credits} 次') except ValueError as e: - await asyncio.to_thread(auth.add_log, 'fail', user.username, ip, '卡密充值', str(e)) + await auth.add_log('fail', user.username, ip, '卡密充值', str(e)) return error(str(e)) except Exception: return error('充值失败,请稍后重试', 500) @@ -122,16 +121,16 @@ async def forgot_password(request: Request): if not email: return error('缺少邮箱') try: - if not await asyncio.to_thread(auth.check_verify_code, email, code, 'reset'): + if not await auth.check_verify_code(email, code, 'reset'): return error('邮箱验证码错误或已过期') - user = await asyncio.to_thread(auth.get_user_by_email, email) + user = await auth.get_user_by_email(email) if not user: return error('邮箱验证码错误或已过期') if user['role'] == 'admin': # 管理员不走自助找回(2FA 保护,统一防枚举文案) return error('邮箱验证码错误或已过期') - await asyncio.to_thread(auth.reset_password, user['id'], newpwd) - await asyncio.to_thread(auth.add_log, 'success', user['username'], + await auth.reset_password(user['id'], newpwd) + await auth.add_log('success', user['username'], ratelimit.client_ip(request), '自助找回密码', '邮箱验证码重置') return success(message='密码已重置,请使用新密码登录(所有设备已下线)') except Exception: @@ -162,9 +161,9 @@ async def _reject_registration(message: str, *, email: str, code: str, ttl: int, 验证码在 check_verify_code 里已被 DELETE 原子消费,凡走到建号/查重/防刷 之后才失败的路径都必须还原,否则用户被迫重新收码。 """ - await asyncio.to_thread(auth.restore_verify_code, email, code, ttl, 'register') + await auth.restore_verify_code(email, code, ttl, 'register') if log_detail: - await asyncio.to_thread(auth.add_log, 'fail', username, ip, '注册', log_detail) + await auth.add_log('fail', username, ip, '注册', log_detail) return error(message) @@ -185,10 +184,10 @@ async def _issue_registration_session(user, credits: int, username: str, fresh = await repo.get_user_by_id(uid) or {} seq = int(fresh.get('login_seq') or 1) credits = min(credits, max(0, int(fresh.get('duration_seconds') or 0))) - await asyncio.to_thread(auth.add_log, 'success', username, ip, '注册账号', + await auth.add_log('success', username, ip, '注册账号', f'赠送 {credits} 次' if credits else '未赠送次数') secure = os.environ.get('ENABLE_SECURE_COOKIE', '').strip().lower() in ('1', 'true', 'yes') - return _login_cookie_response({ + return await _login_cookie_response({ 'user': _user_public(fresh), 'duration_text': auth.format_credits(int(fresh.get('duration_seconds') or 0)), 'redirect': 'admin' if fresh.get('role') == 'admin' else 'user', @@ -217,25 +216,24 @@ async def register(request: Request): return error(bad_input) try: - code_ttl = await asyncio.to_thread(auth.check_verify_code, email, code, 'register') + code_ttl = await auth.check_verify_code(email, code, 'register') if not code_ttl: return error('邮箱验证码错误或已过期') rej = {'email': email, 'code': code, 'ttl': code_ttl} - if await asyncio.to_thread(auth.get_user_by_name, username): + if await auth.get_user_by_name(username): return await _reject_registration('用户名已被注册', **rej) - if email and await asyncio.to_thread(auth.get_user_by_email, email): + if email and await auth.get_user_by_email(email): return await _reject_registration('该邮箱已被注册,请更换邮箱或联系管理员', **rej) - device_used_flag = bool(device_id) and await asyncio.to_thread(auth.device_used, device_id) - if ip and await asyncio.to_thread(auth.register_ip_exceeded, ip): + device_used_flag = bool(device_id) and await auth.device_used(device_id) + if ip and await auth.register_ip_exceeded(ip): return await _reject_registration( '当前网络注册过于频繁,请 24 小时后再试或联系管理员', username=username, ip=ip, log_detail='同 IP 24h 注册次数超限', **rej) credits = await _register_credits(device_used_flag) try: - user = await asyncio.to_thread( - auth.create_user, username, password, email=email, credits=credits, + user = await auth.create_user(username, password, email=email, credits=credits, guard=auth.RegistrationGuard( device_id=device_id, ip=ip, ua=str(request.headers.get('user-agent') or ''), diff --git a/web/routers/user.py b/web/routers/user.py index cb377ab..2da4ad9 100644 --- a/web/routers/user.py +++ b/web/routers/user.py @@ -135,7 +135,7 @@ async def user_qr_check( try: if user is not None: import auth_system - await asyncio.to_thread(auth_system.set_user_netease_cookie, user.username, cookie) + await auth_system.set_user_netease_cookie(user.username, cookie) except Exception: pass logger.info('个人账号扫码登录成功(Cookie 已返回浏览器本地保存)') diff --git a/web/tasks_registry.py b/web/tasks_registry.py index 92024ef..98ea0e9 100644 --- a/web/tasks_registry.py +++ b/web/tasks_registry.py @@ -79,4 +79,4 @@ async def start_task(task_id: str, worker: Callable[..., Awaitable[None]], 任务句柄记在任务字典里供监控循环判断「worker 已结束但没人结算」的兜底场景。 """ from services._taskcore import _start_task - return _start_task(task_id, worker, args) + return await _start_task(task_id, worker, args)