Archived
refactor: auth_system 全面异步化(aiosqlite),拆掉 web/services 层的 to_thread 桥接
拆掉两类垫片的最后一处: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 已非声明依赖)。
This commit is contained in:
1 parent
69a50c2c31
commit
96af56cc27
43 files changed
+988
-826
No files matched your search
@@ -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',
|
||||
|
||||
+107
-55
@@ -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:
|
||||
dest.close()
|
||||
await dest.close()
|
||||
finally:
|
||||
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
|
||||
finally:
|
||||
conn.close()
|
||||
if busy:
|
||||
raise RuntimeError('数据库当前有其他读写在途(checkpoint 忙),请稍后重试恢复')
|
||||
# 1) checkpoint 后留安全副本(包含全部已提交数据,可回滚)
|
||||
shutil.copy2(str(db.DB_PATH), str(safety))
|
||||
await asyncio.to_thread(shutil.copy2, str(db.DB_PATH), str(safety))
|
||||
# 2) 源备份复制到临时文件(不动源备份本身)
|
||||
tmp = db.DB_PATH.with_suffix('.db.restore_tmp')
|
||||
shutil.copy2(str(src), str(tmp))
|
||||
await asyncio.to_thread(shutil.copy2, str(src), str(tmp))
|
||||
finally:
|
||||
# 先关闭旧库连接: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()
|
||||
raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') from None
|
||||
# 裁剪移出 _DB_LOCK——glob/unlink 的 I/O 不阻塞全站写
|
||||
await asyncio.to_thread(shutil.copy2, str(safety), str(db.DB_PATH))
|
||||
rc = await db.connect()
|
||||
try:
|
||||
prune_backups(20, 5)
|
||||
await db._init_db_locked(rc)
|
||||
finally:
|
||||
await rc.close()
|
||||
raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') from None
|
||||
finally:
|
||||
await newconn.close()
|
||||
# 裁剪移出写锁——glob/unlink 的 I/O 不阻塞全站写
|
||||
try:
|
||||
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
|
||||
|
||||
|
||||
def _auto_backup_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
interval = float(get_setting('backup_interval_hours', '0') or 0)
|
||||
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
|
||||
set_setting('backup_interval_hours', '0')
|
||||
await set_setting('backup_interval_hours', '0')
|
||||
if interval > 0:
|
||||
last = float(get_setting('last_backup_ts', '0') or 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:
|
||||
create_backup()
|
||||
set_setting('last_backup_ts', str(time.time()))
|
||||
await create_backup()
|
||||
await set_setting('last_backup_ts', str(time.time()))
|
||||
# 仅保留最近 20 份(按修改时间,不按文件名)
|
||||
# pre_restore_* 安全副本不计入 20 份配额,仅各自保留最近 5 份
|
||||
prune_backups(20, 5)
|
||||
await prune_backups(20, 5)
|
||||
|
||||
|
||||
def _auto_backup_loop() -> None:
|
||||
"""自动备份后台**线程**主循环(线程无法 await,每次 tick 用 asyncio.run 起独立 loop)。"""
|
||||
while True:
|
||||
try:
|
||||
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
|
||||
|
||||
+23
-53
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""次数卡密生成 / 兑换 / 管理。
|
||||
|
||||
(自 auth_system.py 拆出,行为与原实现一致。)
|
||||
(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。)
|
||||
"""
|
||||
|
||||
import secrets
|
||||
@@ -9,15 +9,14 @@ 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 = '',
|
||||
async def generate_cards(count: int, credits: int, created_by: str = '',
|
||||
prefix: str = '', note: str = '', valid_days: int = 0) -> list[str]:
|
||||
"""生成次数卡密(credits = 每张卡可下载次数)
|
||||
prefix: 自定义卡密前缀(默认 VIP-)
|
||||
@@ -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:
|
||||
async with write_transaction() as conn:
|
||||
for _ in range(count):
|
||||
code = prefix + '-' + secrets.token_hex(5).upper()
|
||||
conn.execute(
|
||||
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)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
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,56 +56,38 @@ 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()
|
||||
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('卡密已处于该状态')
|
||||
conn.execute('UPDATE cards SET disabled = ? WHERE code = ?',
|
||||
await conn.execute('UPDATE cards SET disabled = ? WHERE code = ?',
|
||||
(1 if disabled else 0, code))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
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()
|
||||
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('已使用的卡密不可删除(保留兑换凭证)')
|
||||
conn.execute('DELETE FROM cards WHERE code = ?', (code,))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
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()
|
||||
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']:
|
||||
@@ -123,14 +99,14 @@ def redeem_card(code: str, username: str) -> tuple[int, int]:
|
||||
# 卡密标记 + 余额增加同事务:任一失败整体回滚,卡密可重试兑换。
|
||||
# WHERE 带 used_by=""/disabled=0 防重标记 + rowcount 校验:命中 0 行
|
||||
# (跨进程并发抢卡)立即回滚报错,绝不出现"未标记已用却加了余额"
|
||||
cur = conn.execute(
|
||||
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 = conn.execute('SELECT role, duration_seconds FROM users WHERE username = ?',
|
||||
(username,)).fetchone()
|
||||
urow = await (await conn.execute('SELECT role, duration_seconds FROM users WHERE username = ?',
|
||||
(username,))).fetchone()
|
||||
if not urow:
|
||||
raise ValueError('用户不存在')
|
||||
minutes = int(row['minutes'])
|
||||
@@ -139,12 +115,6 @@ def redeem_card(code: str, username: str) -> tuple[int, int]:
|
||||
remaining = -1
|
||||
else:
|
||||
remaining = remaining + minutes
|
||||
conn.execute('UPDATE users SET duration_seconds = ? WHERE username = ?',
|
||||
await conn.execute('UPDATE users SET duration_seconds = ? WHERE username = ?',
|
||||
(remaining, username))
|
||||
conn.commit()
|
||||
return minutes, remaining
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
+24
-38
@@ -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 = ?',
|
||||
await _x('UPDATE users SET duration_seconds = COALESCE(duration_seconds, 0) + ? WHERE id = ?',
|
||||
(count, uid))
|
||||
user = get_user_by_id(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()
|
||||
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:
|
||||
conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid))
|
||||
conn.commit()
|
||||
await conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid))
|
||||
return -1
|
||||
newval = max(0, cur - count)
|
||||
conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?',
|
||||
await conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?',
|
||||
(newval, int(time.time()), uid))
|
||||
conn.commit()
|
||||
return newval
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
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()
|
||||
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':
|
||||
conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid))
|
||||
conn.commit()
|
||||
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:
|
||||
conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid))
|
||||
conn.commit()
|
||||
await conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid))
|
||||
return -1
|
||||
if cur < count:
|
||||
return None
|
||||
conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?',
|
||||
await 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()
|
||||
|
||||
|
||||
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 = ?',
|
||||
await _x('UPDATE users SET duration_seconds = COALESCE(duration_seconds, 0) + ? WHERE id = ?',
|
||||
(max(0, int(count)), user['id']))
|
||||
user = get_user_by_name(username)
|
||||
user = await get_user_by_name(username)
|
||||
return (user['duration_seconds'] or 0) if user else 0
|
||||
|
||||
|
||||
|
||||
+173
-74
@@ -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 '
|
||||
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)
|
||||
+6
-8
@@ -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 = ?',
|
||||
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
|
||||
+39
-68
@@ -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(
|
||||
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())))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
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()
|
||||
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':
|
||||
conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (now, uid))
|
||||
conn.execute(
|
||||
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))
|
||||
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(
|
||||
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))
|
||||
conn.commit()
|
||||
return -1
|
||||
if cur < count:
|
||||
return None
|
||||
conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?',
|
||||
await conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?',
|
||||
(cur - count, now, uid))
|
||||
conn.execute(
|
||||
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()
|
||||
|
||||
|
||||
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()
|
||||
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 = conn.execute(
|
||||
cur = await 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()
|
||||
|
||||
|
||||
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()
|
||||
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 = ?',
|
||||
await 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 = ?',
|
||||
await conn.execute('UPDATE billing_ledger SET reserved = reserved + ? WHERE task_id = ?',
|
||||
(extra, task_id))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
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,10 +119,8 @@ 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()
|
||||
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)
|
||||
@@ -156,45 +130,42 @@ def ledger_settle(task_id: str, delivered: int, final_status: str) -> int:
|
||||
# 账本 refunded 记录的是"按账本应付"口径(该用户已有无限次数,作废可接受);
|
||||
# 原注释"照样退"与实现相反,已更正为此口径。
|
||||
if refund > 0:
|
||||
conn.execute('UPDATE users SET duration_seconds = duration_seconds + ? '
|
||||
await 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 = ?, '
|
||||
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))
|
||||
conn.commit()
|
||||
return refund
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
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 <= ?',
|
||||
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', '重启对账退款',
|
||||
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 <= ?',
|
||||
return await _x('DELETE FROM billing_ledger WHERE status != \'open\' AND settled_at <= ?',
|
||||
(int(time.time()) - int(max_age_seconds),))
|
||||
except Exception:
|
||||
return 0
|
||||
+11
-17
@@ -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 (?, ?, ?, ?, ?, ?)',
|
||||
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 ?',
|
||||
rows = await _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,))
|
||||
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()
|
||||
async with write_transaction() as conn:
|
||||
cur = await conn.execute('DELETE FROM logs WHERE type = ?', (log_type,))
|
||||
return cur.rowcount
|
||||
finally:
|
||||
conn.close()
|
||||
+48
-56
@@ -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',
|
||||
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',
|
||||
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:
|
||||
async with write_transaction() as conn:
|
||||
# 新码生效同时作废同邮箱同用途旧码;顺手清理全表过期码
|
||||
conn.execute('DELETE FROM email_codes WHERE (email = ? AND purpose = ?) OR expires_at <= ?',
|
||||
await 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 (?, ?, ?, ?, ?)',
|
||||
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))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
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,33 +219,29 @@ 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()
|
||||
row = None
|
||||
try:
|
||||
cur = conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ? '
|
||||
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 = cur.fetchone()
|
||||
conn.commit()
|
||||
if row:
|
||||
_CODE_FAILS.pop(fail_key, None)
|
||||
return max(0, int(row[0]) - 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
|
||||
conn.rollback()
|
||||
cur = conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ?',
|
||||
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())))
|
||||
conn.commit()
|
||||
if cur.rowcount == 1:
|
||||
_CODE_FAILS.pop(fail_key, None)
|
||||
return 60
|
||||
row = None
|
||||
finally:
|
||||
conn.close()
|
||||
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
|
||||
@@ -251,13 +249,13 @@ def check_verify_code(email: str, code: str, purpose: str = 'register') -> int:
|
||||
if n >= 5:
|
||||
_CODE_FAILS.pop(fail_key, None)
|
||||
try:
|
||||
_x('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose))
|
||||
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 (?, ?, ?, ?, ?)',
|
||||
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))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -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',
|
||||
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 (?, ?) '
|
||||
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)))
|
||||
+69
-77
@@ -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,7 +40,7 @@ class RegistrationGuard:
|
||||
device_once: bool = False
|
||||
|
||||
|
||||
def create_user(username: str, password: str, email: str = '', credits: int = 0,
|
||||
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 这类"假不限"
|
||||
@@ -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:
|
||||
# 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:
|
||||
n = conn.execute('SELECT COUNT(*) AS n FROM users WHERE register_ip = ? AND created_at > ?',
|
||||
(guard.ip, now - guard.ip_window)).fetchone()[0]
|
||||
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,
|
||||
# 并发同设备双注册可各得一次赠送;锁内复查命中则本次建号不赠送(账号照常创建)
|
||||
n = conn.execute("SELECT COUNT(*) AS n FROM users WHERE trial_device = ? AND role != 'admin'",
|
||||
(guard.device_id,)).fetchone()[0]
|
||||
if n > 0:
|
||||
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
|
||||
conn.execute(
|
||||
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, _hash_new(password), '', email, role,
|
||||
(username, phash, '', 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)
|
||||
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 > ?',
|
||||
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:
|
||||
async with write_transaction() as conn:
|
||||
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 = ?",
|
||||
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))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
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()
|
||||
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
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
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 = ?',
|
||||
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 = \'\', '
|
||||
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 = ?',
|
||||
(_hash_new(password), uid))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
(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)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+3
-3
@@ -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:
|
||||
|
||||
+8
-3
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
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
|
||||
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 elapsed < 1.0, f'读被写锁阻塞了 {elapsed:.2f}s(WAL 下不应发生)'
|
||||
finally:
|
||||
release.set()
|
||||
t.join(2)
|
||||
assert rows[0]['n'] >= 0
|
||||
|
||||
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):
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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()
|
||||
|
||||
+23
-30
@@ -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()
|
||||
async def _open_rows():
|
||||
rows = await auth._q("SELECT task_id FROM billing_ledger WHERE status='open'")
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
+94
-63
@@ -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()
|
||||
async def _main():
|
||||
conn = await db.connect()
|
||||
try:
|
||||
conn.execute('CREATE TABLE t (v)').close()
|
||||
conn.commit()
|
||||
await conn.execute('CREATE TABLE t (v)')
|
||||
await conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
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)
|
||||
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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=''))
|
||||
|
||||
|
||||
+24
-11
@@ -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)
|
||||
|
||||
+24
-13
@@ -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)
|
||||
|
||||
@@ -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']}"
|
||||
+20
-7
@@ -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
|
||||
|
||||
+28
-10
@@ -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')
|
||||
|
||||
@@ -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=''))
|
||||
|
||||
@@ -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']}"
|
||||
@@ -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())})
|
||||
|
||||
+14
-14
@@ -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
|
||||
|
||||
|
||||
|
||||
+9
-3
@@ -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
|
||||
|
||||
|
||||
+8
-13
@@ -9,7 +9,6 @@
|
||||
|
||||
金额相关逻辑一律走 auth_system 既有函数,本模块不新造账务语义。
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import secrets
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
@@ -36,33 +35,29 @@ class Billing:
|
||||
key = self.ledger_key
|
||||
if not key:
|
||||
return
|
||||
|
||||
def _do() -> None:
|
||||
try:
|
||||
row = auth_system.ledger_get(key)
|
||||
row = await auth_system.ledger_get(key)
|
||||
if row and row['status'] == 'open':
|
||||
auth_system.ledger_settle(key, 0, reason)
|
||||
await auth_system.ledger_settle(key, 0, reason)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.to_thread(_do)
|
||||
|
||||
async def settle(self, delivered: int, status: str = 'delivered') -> None:
|
||||
"""交付结算(json 模式等无 task_id 的场景)。"""
|
||||
key = self.ledger_key
|
||||
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,9 +113,9 @@ 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,
|
||||
await auth_system.add_log('fail', fresh['username'], ip,
|
||||
action or '下载', f'剩余次数不足(本次需 {need} 次)')
|
||||
raise AppError(f'下载次数已用尽(本次需 {need} 次,当前 {credits} 次),'
|
||||
'请充值卡密后继续下载', 401, error_code='expired')
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+47
-48
@@ -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:
|
||||
|
||||
+20
-21
@@ -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',
|
||||
|
||||
@@ -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 与全局账号一致,已按未登录拒绝')
|
||||
|
||||
+22
-24
@@ -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,10 +42,10 @@ _SESSION_MAX_AGE = cs.SESSION_MAX_AGE
|
||||
|
||||
|
||||
|
||||
def _login_cookie_response(payload: dict, message: str, uid: int, seq: int,
|
||||
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,
|
||||
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)
|
||||
@@ -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 ''),
|
||||
|
||||
+1
-1
@@ -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 已返回浏览器本地保存)')
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user