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:
snowy-yang committed 2026-09-28 20:13:19 +08:00
1 parent 69a50c2c31
commit 96af56cc27
43 files changed
+1171 -1009

No files matched your search

-4
View File
@@ -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',
+116 -64
View File
@@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
"""数据库备份、恢复与自动备份线程。
(自 auth_system.py 拆出,行为与原实现一致。)
(自 auth_system.py 拆出,行为与原实现一致;DB 备份改用 aiosqlite 在线备份
API,文件系统/复制等阻塞 IO 经 asyncio.to_thread 卸载。)
"""
import asyncio
import math
import os
import shutil
@@ -14,6 +16,8 @@ import time
from pathlib import Path
from typing import Any
import aiosqlite
import auth_system.db as db # 绝对导入子模块:不经过包属性,避免与包 __init__ 互指成环
from .settings import (
@@ -24,17 +28,16 @@ from .settings import (
# ============ 数据库备份 ============
# 备份命名专用锁:create_backup 不再持有全局 _DB_LOCK(不阻塞写请求),
# 但同秒并发的两次备份仍需保证文件名唯一
# 备份命名专用锁:占位命名不阻塞写请求,但同秒并发的两次备份仍需保证文件名唯一
_BACKUP_NAME_LOCK = threading.Lock()
def create_backup() -> str:
"""创建备份(唯一文件名——同秒冲突自动追加序号,不再互相覆盖)。
def _reserve_backup_path() -> Path:
"""锁内挑选唯一文件名并创建占位文件(同步微秒级本地 IO,仅建一个空文件)。
WAL 模式下用独立只读连接走 SQLite 在线备份 API:拿到的是一致性快照,
且不持有 _DB_LOCK——原实现备份期间阻塞全站写请求,大库时会卡住所有用户。"""
db.BACKUP_DIR.mkdir(parents=True, exist_ok=True)
先占位(而不是备份完再命名)保证同秒并发下命名唯一:备份前的空文件与
sqlite3.connect 建出的空库等价,随后在线备份写入其内容。
"""
with _BACKUP_NAME_LOCK:
base = 'backup_' + time.strftime('%Y%m%d_%H%M%S')
name = base + '.db'
@@ -42,32 +45,46 @@ def create_backup() -> str:
while (db.BACKUP_DIR / name).exists():
name = f'{base}-{k}.db'
k += 1
# 占位创建目标文件(锁内),保证并发下同秒命名仍唯一
dest = sqlite3.connect(str(db.BACKUP_DIR / name))
conn = db.get_conn()
dest = db.BACKUP_DIR / name
dest.touch()
return dest
async def create_backup() -> str:
"""创建备份(唯一文件名——同秒冲突自动追加序号,不再互相覆盖)。
WAL 模式下用独立只读连接走 SQLite 在线备份 API(aiosqlite Connection.backup):
拿到的是一致性快照,且不持有写锁——原实现备份期间阻塞全站写请求,
大库时会卡住所有用户。"""
db.BACKUP_DIR.mkdir(parents=True, exist_ok=True)
dest_path = await asyncio.to_thread(_reserve_backup_path)
name = dest_path.name
try:
conn = await db.connect()
try:
conn.backup(dest)
dest = await aiosqlite.connect(str(dest_path))
try:
await conn.backup(dest)
finally:
await dest.close()
finally:
dest.close()
await conn.close()
# 任何备份产生后统一裁剪(手动/自动/上传同配额)
try:
prune_backups(20, 5)
await prune_backups(20, 5)
except Exception:
pass
return name
except Exception:
# 备份失败:清掉占位文件,不留半个坏备份
try:
(db.BACKUP_DIR / name).unlink(missing_ok=True)
await asyncio.to_thread(dest_path.unlink, missing_ok=True)
except OSError:
pass
raise
finally:
conn.close()
def list_backups() -> list[dict[str, Any]]:
def _list_backups_sync() -> list[dict[str, Any]]:
"""备份列表(按文件修改时间倒序——按名字排序会让 upload_ 永远排前,误删新自动备份)"""
if not db.BACKUP_DIR.exists():
return []
@@ -83,13 +100,21 @@ def list_backups() -> list[dict[str, Any]]:
return out
def backup_path(name: str) -> Path | None:
async def list_backups() -> list[dict[str, Any]]:
return await asyncio.to_thread(_list_backups_sync)
def _backup_path_sync(name: str) -> Path | None:
name = Path(name).name # 防目录穿越
p = db.BACKUP_DIR / name
return p if p.exists() and p.suffix == '.db' else None
def _backup_is_valid_db(src: Path) -> bool:
async def backup_path(name: str) -> Path | None:
return await asyncio.to_thread(_backup_path_sync, name)
def _backup_is_valid_db_sync(src: Path) -> bool:
"""深度校验备份文件:SQLite 头 + 能真正打开 + 含 users 表,三者缺一不可"""
try:
with open(src, 'rb') as f:
@@ -105,22 +130,33 @@ def _backup_is_valid_db(src: Path) -> bool:
return False
async def _backup_is_valid_db(src: Path) -> bool:
"""同步 sqlite3 探测(打开只读库)经 to_thread 卸载,不阻塞事件循环。"""
return await asyncio.to_thread(_backup_is_valid_db_sync, src)
# 恢复前静止钩子——由应用层注册(取消全部在途下载任务),避免 worker 按旧库账目结算
_before_restore_hook = None
def register_before_restore_hook(fn) -> None:
"""注册恢复前钩子(纯内存登记,无 IO,保持同步)。"""
global _before_restore_hook
_before_restore_hook = fn
def restore_backup(name: str) -> None:
async def restore_backup(name: str) -> None:
"""恢复备份(先验证、留回滚、后替换、失败自动回滚,源备份文件保留不移动)
恢复前先通过钩子取消全部在途任务(worker 不再引用旧库用户结算)"""
src = backup_path(name)
恢复前先通过钩子取消全部在途任务(worker 不再引用旧库用户结算)
整个替换过程持**当前事件循环的写锁**(经 db._write_lock())串行化;新库
结构初始化调用 db._init_db_locked(不自行取锁)以避免 asyncio.Lock 非重入
造成的自锁死。
"""
src = await backup_path(name)
if not src:
raise ValueError('备份文件不存在')
if not _backup_is_valid_db(src):
if not await _backup_is_valid_db(src):
raise ValueError('不是有效的站点数据库备份(校验失败,未做任何修改)')
if _before_restore_hook is not None:
try:
@@ -128,44 +164,53 @@ def restore_backup(name: str) -> None:
except Exception:
pass
safety = db.BACKUP_DIR / ('pre_restore_' + time.strftime('%Y%m%d_%H%M%S') + '.db')
with db._DB_LOCK:
async with db._write_lock():
# 0) 先尽力清 WAL 并合并进主库(被占用导致 checkpoint 忙时明确失败,不假装成功);
# 必须在留安全副本之前做——否则副本缺 WAL 里未合并的最近提交,回滚点不完整
conn = db.get_conn()
conn = await db.connect()
try:
row = conn.execute('PRAGMA wal_checkpoint(TRUNCATE)').fetchone()
row = await (await conn.execute('PRAGMA wal_checkpoint(TRUNCATE)')).fetchone()
busy = bool(row and row[0] == 1) # 1 = busy
if busy:
raise RuntimeError('数据库当前有其他读写在途(checkpoint 忙),请稍后重试恢复')
# 1) checkpoint 后留安全副本(包含全部已提交数据,可回滚)
await asyncio.to_thread(shutil.copy2, str(db.DB_PATH), str(safety))
# 2) 源备份复制到临时文件(不动源备份本身)
tmp = db.DB_PATH.with_suffix('.db.restore_tmp')
await asyncio.to_thread(shutil.copy2, str(src), str(tmp))
finally:
conn.close()
if busy:
raise RuntimeError('数据库当前有其他读写在途(checkpoint 忙),请稍后重试恢复')
# 1) checkpoint 后留安全副本(包含全部已提交数据,可回滚)
shutil.copy2(str(db.DB_PATH), str(safety))
# 2) 源备份复制到临时文件(不动源备份本身)
tmp = db.DB_PATH.with_suffix('.db.restore_tmp')
shutil.copy2(str(src), str(tmp))
# 先关闭旧库连接:Windows 下替换被打开的库文件会失败
await conn.close()
# 3) 原子替换 + 立即校验
os.replace(str(tmp), str(db.DB_PATH))
await asyncio.to_thread(os.replace, str(tmp), str(db.DB_PATH))
newconn = await db.connect()
try:
db.init_db()
await db._init_db_locked(newconn)
except Exception:
await newconn.close()
# 替换后初始化失败:回滚安全副本,恢复原库
shutil.copy2(str(safety), str(db.DB_PATH))
db.init_db()
await asyncio.to_thread(shutil.copy2, str(safety), str(db.DB_PATH))
rc = await db.connect()
try:
await db._init_db_locked(rc)
finally:
await rc.close()
raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') from None
# 裁剪移出 _DB_LOCK——glob/unlink 的 I/O 不阻塞全站写
finally:
await newconn.close()
# 裁剪移出写锁——glob/unlink 的 I/O 不阻塞全站写
try:
prune_backups(20, 5)
await prune_backups(20, 5)
except Exception:
pass
def delete_backup(name: str) -> bool:
p = backup_path(name)
async def delete_backup(name: str) -> bool:
p = await backup_path(name)
# 允许删除 pre_restore_ 安全副本(配额裁剪已保证至少保留
# 最近 5 份;原实现一刀切拒删导致恢复频繁时无界累积且管理员手动也删不掉)
if p:
p.unlink()
await asyncio.to_thread(p.unlink)
return True
return False
@@ -176,43 +221,49 @@ _backup_thread_started = False
_BACKUP_ERR_TS = {'ts': 0.0, 'n': 0} # 备份失败限频记录(失败不能零痕迹)
def prune_backups(keep_normal: int = 20, keep_pre_restore: int = 5) -> None:
async def prune_backups(keep_normal: int = 20, keep_pre_restore: int = 5) -> None:
"""备份配额裁剪(从自动备份循环抽出为独立函数)——
非pre_restore 保留最近 N 份、pre_restore_ 安全副本保留最近 M 份。
在 create_backup/上传备份/恢复备份后统一调用(原实现只在自动备份循环里
裁剪,手动备份/上传/恢复会让 data/backups 无界累积)。"""
baks = [b for b in list_backups() if not b['name'].startswith('pre_restore_')]
baks = [b for b in await list_backups() if not b['name'].startswith('pre_restore_')]
for b in baks[keep_normal:]:
try:
delete_backup(b['name'])
await delete_backup(b['name'])
except Exception:
pass
pre = [b for b in list_backups() if b['name'].startswith('pre_restore_')]
pre = [b for b in await list_backups() if b['name'].startswith('pre_restore_')]
for b in pre[keep_pre_restore:]:
try:
(db.BACKUP_DIR / b['name']).unlink()
await asyncio.to_thread((db.BACKUP_DIR / b['name']).unlink)
except OSError:
pass
async def _auto_backup_tick() -> None:
"""自动备份单次检查(在独立事件循环里运行,见 _auto_backup_loop)。"""
interval = float(await get_setting('backup_interval_hours', '0') or 0)
# 非有限值(NaN/inf)或负数一律视为关闭
if not math.isfinite(interval):
interval = 0
await set_setting('backup_interval_hours', '0')
if interval > 0:
last = float(await get_setting('last_backup_ts', '0') or 0)
if not math.isfinite(last):
last = 0
if time.time() - last >= interval * 3600:
await create_backup()
await set_setting('last_backup_ts', str(time.time()))
# 仅保留最近 20 份(按修改时间,不按文件名)
# pre_restore_* 安全副本不计入 20 份配额,仅各自保留最近 5 份
await prune_backups(20, 5)
def _auto_backup_loop() -> None:
"""自动备份后台**线程**主循环(线程无法 await,每次 tick 用 asyncio.run 起独立 loop)。"""
while True:
try:
interval = float(get_setting('backup_interval_hours', '0') or 0)
# 非有限值(NaN/inf)或负数一律视为关闭
if not math.isfinite(interval):
interval = 0
set_setting('backup_interval_hours', '0')
if interval > 0:
last = float(get_setting('last_backup_ts', '0') or 0)
if not math.isfinite(last):
last = 0
if time.time() - last >= interval * 3600:
create_backup()
set_setting('last_backup_ts', str(time.time()))
# 仅保留最近 20 份(按修改时间,不按文件名)
# pre_restore_* 安全副本不计入 20 份配额,仅各自保留最近 5 份
prune_backups(20, 5)
asyncio.run(_auto_backup_tick())
except Exception:
# 定时备份失败绝不能静默(磁盘满/权限问题时 owner 以为有备份、
# 出事时没有)——限频告警,每 10 分钟最多一条(对齐监控线程口径)
@@ -228,6 +279,7 @@ def _auto_backup_loop() -> None:
def start_auto_backup() -> None:
"""启动自动备份后台线程(纯线程启动,无 IO,保持同步)。"""
global _backup_thread_started
if _backup_thread_started:
return
+67 -97
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""次数卡密生成 / 兑换 / 管理。
(自 auth_system.py 拆出,行为与原实现一致。)
(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。)
"""
import secrets
@@ -9,16 +9,15 @@ import time
from typing import Any
from .db import (
_DB_LOCK,
_q,
get_conn,
write_transaction,
)
# ============ 卡密 ============
def generate_cards(count: int, credits: int, created_by: str = '',
prefix: str = '', note: str = '', valid_days: int = 0) -> list[str]:
async def generate_cards(count: int, credits: int, created_by: str = '',
prefix: str = '', note: str = '', valid_days: int = 0) -> list[str]:
"""生成次数卡密(credits = 每张卡可下载次数)
prefix: 自定义卡密前缀(默认 VIP-)
note: 备注信息
@@ -28,29 +27,24 @@ def generate_cards(count: int, credits: int, created_by: str = '',
prefix = ''.join(ch for ch in (prefix or 'VIP') if ch.isalnum()).upper()[:10] or 'VIP'
expire_at = int(time.time()) + int(valid_days) * 86400 if valid_days and int(valid_days) > 0 else 0
codes = []
with _DB_LOCK:
conn = get_conn()
try:
for _ in range(count):
code = prefix + '-' + secrets.token_hex(5).upper()
conn.execute(
'INSERT INTO cards (code, minutes, created_by, created_at, note, expire_at) '
'VALUES (?, ?, ?, ?, ?, ?)',
(code, int(credits), created_by, int(time.time()), note or '', expire_at))
codes.append(code)
conn.commit()
finally:
conn.close()
async with write_transaction() as conn:
for _ in range(count):
code = prefix + '-' + secrets.token_hex(5).upper()
await conn.execute(
'INSERT INTO cards (code, minutes, created_by, created_at, note, expire_at) '
'VALUES (?, ?, ?, ?, ?, ?)',
(code, int(credits), created_by, int(time.time()), note or '', expire_at))
codes.append(code)
return codes
def list_cards(limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
async def list_cards(limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
"""卡密列表(支持分页 limit/offset,管理路由透传)
返回 (当页卡密列表, 卡密总数)——总数供前端分页控件用,调用方按元组解包"""
limit = max(1, min(int(limit), 500))
offset = max(0, int(offset))
rows = _q('SELECT * FROM cards ORDER BY id DESC LIMIT ? OFFSET ?', (limit, offset))
total_row = _q('SELECT COUNT(*) AS c FROM cards')
rows = await _q('SELECT * FROM cards ORDER BY id DESC LIMIT ? OFFSET ?', (limit, offset))
total_row = await _q('SELECT COUNT(*) AS c FROM cards')
total = int(total_row[0]['c']) if total_row else 0
now = time.time()
cards = []
@@ -62,89 +56,65 @@ def list_cards(limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]],
return cards, total
def set_card_disabled(code: str, disabled: bool) -> None:
async def set_card_disabled(code: str, disabled: bool) -> None:
"""禁用/恢复未使用卡密(已使用卡密无禁用意义,拒绝操作)"""
code = (code or '').strip().upper()
with _DB_LOCK:
conn = get_conn()
try:
row = conn.execute('SELECT used_by, disabled FROM cards WHERE code = ?', (code,)).fetchone()
if not row:
raise ValueError('卡密不存在')
if row['used_by']:
raise ValueError('卡密已被使用,无需禁用')
if bool(row['disabled']) == bool(disabled):
raise ValueError('卡密已处于该状态')
conn.execute('UPDATE cards SET disabled = ? WHERE code = ?',
(1 if disabled else 0, code))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
async with write_transaction() as conn:
row = await (await conn.execute('SELECT used_by, disabled FROM cards WHERE code = ?', (code,))).fetchone()
if not row:
raise ValueError('卡密不存在')
if row['used_by']:
raise ValueError('卡密已被使用,无需禁用')
if bool(row['disabled']) == bool(disabled):
raise ValueError('卡密已处于该状态')
await conn.execute('UPDATE cards SET disabled = ? WHERE code = ?',
(1 if disabled else 0, code))
def delete_card(code: str) -> None:
async def delete_card(code: str) -> None:
"""删除未使用卡密(已使用卡密含兑换凭证:使用者/时间,按 owner 规则不可删)"""
code = (code or '').strip().upper()
with _DB_LOCK:
conn = get_conn()
try:
row = conn.execute('SELECT used_by FROM cards WHERE code = ?', (code,)).fetchone()
if not row:
raise ValueError('卡密不存在')
if row['used_by']:
raise ValueError('已使用的卡密不可删除(保留兑换凭证)')
conn.execute('DELETE FROM cards WHERE code = ?', (code,))
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
async with write_transaction() as conn:
row = await (await conn.execute('SELECT used_by FROM cards WHERE code = ?', (code,))).fetchone()
if not row:
raise ValueError('卡密不存在')
if row['used_by']:
raise ValueError('已使用的卡密不可删除(保留兑换凭证)')
await conn.execute('DELETE FROM cards WHERE code = ?', (code,))
def redeem_card(code: str, username: str) -> tuple[int, int]:
async def redeem_card(code: str, username: str) -> tuple[int, int]:
"""兑换卡密,返回 (卡密次数, 新剩余次数)(标记已用与加余额同事务,失败整体回滚不吞卡)"""
code = (code or '').strip().upper()
with _DB_LOCK:
conn = get_conn()
try:
row = conn.execute('SELECT * FROM cards WHERE code = ?', (code,)).fetchone()
if not row:
raise ValueError('卡密不存在')
if row['used_by']:
raise ValueError('卡密已被使用')
if row['disabled']:
raise ValueError('卡密已被禁用,请联系管理员')
if row['expire_at'] and row['expire_at'] < time.time():
raise ValueError('卡密已过期')
# 卡密标记 + 余额增加同事务:任一失败整体回滚,卡密可重试兑换。
# WHERE 带 used_by=""/disabled=0 防重标记 + rowcount 校验:命中 0 行
# (跨进程并发抢卡)立即回滚报错,绝不出现"未标记已用却加了余额"
cur = conn.execute(
'UPDATE cards SET used_by = ?, used_at = ? '
'WHERE code = ? AND used_by = "" AND disabled = 0',
(username, int(time.time()), code))
if cur.rowcount != 1:
raise ValueError('卡密已被使用')
urow = conn.execute('SELECT role, duration_seconds FROM users WHERE username = ?',
(username,)).fetchone()
if not urow:
raise ValueError('用户不存在')
minutes = int(row['minutes'])
remaining = int(urow['duration_seconds'] or 0)
if urow['role'] == 'admin' or remaining == -1:
remaining = -1
else:
remaining = remaining + minutes
conn.execute('UPDATE users SET duration_seconds = ? WHERE username = ?',
(remaining, username))
conn.commit()
return minutes, remaining
except Exception:
conn.rollback()
raise
finally:
conn.close()
async with write_transaction() as conn:
row = await (await conn.execute('SELECT * FROM cards WHERE code = ?', (code,))).fetchone()
if not row:
raise ValueError('卡密不存在')
if row['used_by']:
raise ValueError('卡密已被使用')
if row['disabled']:
raise ValueError('卡密已被禁用,请联系管理员')
if row['expire_at'] and row['expire_at'] < time.time():
raise ValueError('卡密已过期')
# 卡密标记 + 余额增加同事务:任一失败整体回滚,卡密可重试兑换。
# WHERE 带 used_by=""/disabled=0 防重标记 + rowcount 校验:命中 0 行
# (跨进程并发抢卡)立即回滚报错,绝不出现"未标记已用却加了余额"
cur = await conn.execute(
'UPDATE cards SET used_by = ?, used_at = ? '
'WHERE code = ? AND used_by = "" AND disabled = 0',
(username, int(time.time()), code))
if cur.rowcount != 1:
raise ValueError('卡密已被使用')
urow = await (await conn.execute('SELECT role, duration_seconds FROM users WHERE username = ?',
(username,))).fetchone()
if not urow:
raise ValueError('用户不存在')
minutes = int(row['minutes'])
remaining = int(urow['duration_seconds'] or 0)
if urow['role'] == 'admin' or remaining == -1:
remaining = -1
else:
remaining = remaining + minutes
await conn.execute('UPDATE users SET duration_seconds = ? WHERE username = ?',
(remaining, username))
return minutes, remaining
+43 -57
View File
@@ -1,15 +1,14 @@
# -*- coding: utf-8 -*-
"""下载次数增减与格式化。
(自 auth_system.py 拆出,行为与原实现一致。)
(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。)
"""
import time
from .db import (
_DB_LOCK,
_x,
get_conn,
write_transaction,
)
from .users import (
get_user_by_id,
@@ -19,92 +18,79 @@ from .users import (
# ============ 使用次数(按次计费:一首歌 = 1 次) ============
def refund_credits(uid: int, count: int) -> int:
async def refund_credits(uid: int, count: int) -> int:
"""退回下载次数(批量打包失败部分自动退费)。返回剩余次数(-1 表示不限)"""
user = get_user_by_id(uid)
user = await get_user_by_id(uid)
if user and (user['duration_seconds'] or 0) == -1:
return -1 # 不限次数用户无需退费
count = max(0, int(count))
if count <= 0:
return (user['duration_seconds'] or 0) if user else 0
_x('UPDATE users SET duration_seconds = COALESCE(duration_seconds, 0) + ? WHERE id = ?',
(count, uid))
user = get_user_by_id(uid)
await _x('UPDATE users SET duration_seconds = COALESCE(duration_seconds, 0) + ? WHERE id = ?',
(count, uid))
user = await get_user_by_id(uid)
return (user['duration_seconds'] or 0) if user else 0
def charge_credits(uid: int, count: int = 1) -> int:
async def charge_credits(uid: int, count: int = 1) -> int:
"""按次扣减使用次数(管理员与不限次数(-1)用户不扣减,返回 -1)。返回剩余次数
扣减在 _DB_LOCK 内"读取-判断-写入"一次完成,防止并发双击导致超扣/双花"""
user = get_user_by_id(uid)
扣减在写事务内"读取-判断-写入"一次完成,防止并发双击导致超扣/双花"""
user = await get_user_by_id(uid)
if not user:
return 0
if user['role'] == 'admin' or (user['duration_seconds'] or 0) == -1:
_x('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid))
await _x('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid))
return -1
count = max(1, int(count))
with _DB_LOCK:
conn = get_conn()
try:
row = conn.execute('SELECT duration_seconds FROM users WHERE id = ?', (uid,)).fetchone()
cur = int(row['duration_seconds'] or 0) if row else 0
if cur == -1:
conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid))
conn.commit()
return -1
newval = max(0, cur - count)
conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?',
(newval, int(time.time()), uid))
conn.commit()
return newval
finally:
conn.close()
async with write_transaction() as conn:
row = await (await conn.execute('SELECT duration_seconds FROM users WHERE id = ?', (uid,))).fetchone()
cur = int(row['duration_seconds'] or 0) if row else 0
if cur == -1:
await conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid))
return -1
newval = max(0, cur - count)
await conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?',
(newval, int(time.time()), uid))
return newval
def reserve_credits(uid: int, count: int = 1):
async def reserve_credits(uid: int, count: int = 1):
"""原子预扣下载次数(下载类接口在路由执行前调用,防止并发双击双花)。
足额:扣减并返回剩余次数;不限次数(-1)/管理员:不扣减返回 -1;
不足:返回 None(调用方应拒绝请求)"""
count = max(1, int(count))
with _DB_LOCK:
conn = get_conn()
try:
row = conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?', (uid,)).fetchone()
if not row:
return None
if row['role'] == 'admin':
conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid))
conn.commit()
return -1
cur = int(row['duration_seconds'] or 0)
if cur == -1:
conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid))
conn.commit()
return -1
if cur < count:
return None
conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?',
(cur - count, int(time.time()), uid))
conn.commit()
return cur - count
finally:
conn.close()
async with write_transaction() as conn:
row = await (await conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?', (uid,))).fetchone()
if not row:
return None
if row['role'] == 'admin':
await conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid))
return -1
cur = int(row['duration_seconds'] or 0)
if cur == -1:
await conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (int(time.time()), uid))
return -1
if cur < count:
return None
await conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?',
(cur - count, int(time.time()), uid))
return cur - count
def add_credits(username: str, count: int) -> int:
async def add_credits(username: str, count: int) -> int:
"""增加次数(卡密充值/试用领取),返回新剩余次数;不限次数(-1)用户保持不限
累加用 SQL 原生自增(原子),避免并发充值互相覆盖"""
user = get_user_by_name(username)
user = await get_user_by_name(username)
if not user:
return 0
if user['role'] == 'admin' or (user['duration_seconds'] or 0) == -1:
return -1
_x('UPDATE users SET duration_seconds = COALESCE(duration_seconds, 0) + ? WHERE id = ?',
(max(0, int(count)), user['id']))
user = get_user_by_name(username)
await _x('UPDATE users SET duration_seconds = COALESCE(duration_seconds, 0) + ? WHERE id = ?',
(max(0, int(count)), user['id']))
user = await get_user_by_name(username)
return (user['duration_seconds'] or 0) if user else 0
+174 -75
View File
@@ -1,17 +1,31 @@
# -*- coding: utf-8 -*-
"""路径常量、连接与建表。
"""路径常量、连接与建表(原生异步 SQLite 访问层:aiosqlite + WAL)。
(自 auth_system.py 拆出,行为与原实现一致。)
(自 auth_system.py 拆出;本文件由同步 sqlite3 栈迁移为原生异步,设计照
`core/db.py`——同一套「读写同时进行」口径:)
- WAL 模式:读事务与写事务互不阻塞(读快照隔离);
- 读路径(`_q`):每次操作独立连接、无锁、可并发;
- 写路径(`_x` / `write_transaction`):SQLite 全库同一时刻只允许一个写者,
用 `asyncio.Lock` 串行化写事务,但写者不阻塞读者;
- `busy_timeout`:多进程/多连接下遇短暂写锁自旋等待而非立刻 SQLITE_BUSY。
路径常量仍是本模块的**唯一真源**:DB 访问函数在**调用时**读取本模块的
`DB_PATH` 等全局(测试 monkeypatch `auth_system.db.DB_PATH` 依旧生效)。
"""
import asyncio
import os
import secrets
import sqlite3
import sys
import threading
import time
from collections.abc import AsyncGenerator, Sequence
from contextlib import asynccontextmanager
from pathlib import Path
import aiosqlite
from .security import (
_hash_new,
_normalize_email,
@@ -37,68 +51,136 @@ SESSION_KEY = 'site_uid'
DEFAULT_REGISTER_CREDITS = 3 # 注册直接赠送 3 次下载次数(无需领取;TRIAL_CREDITS 死常量已删)
_DB_LOCK = threading.RLock()
# 锁按事件循环隔离:asyncio.Lock 绑定创建它的 loop,跨 loop 复用会抛
# "bound to a different event loop"(应用主 loop + 测试各自的 loop 并存,必须分开持有)。
_LOOP_LOCKS: dict[tuple[int, str], asyncio.Lock] = {}
def _loop_lock(kind: str) -> asyncio.Lock:
key = (id(asyncio.get_running_loop()), kind)
lock = _LOOP_LOCKS.get(key)
if lock is None:
lock = asyncio.Lock()
_LOOP_LOCKS[key] = lock
return lock
def _write_lock() -> asyncio.Lock:
"""写事务串行锁(每事件循环一个)。"""
return _loop_lock('write')
def _wal_lock() -> asyncio.Lock:
"""WAL 切换专用锁(不能用 _write_lock:execute() 已持它再调
connect→_configure 会自锁死)。"""
return _loop_lock('wal')
# journal_mode=WAL 持久化在库文件头里,按路径设一次即可。每连接重复执行
# 实测多付 ~1ms(新连接首条语句要建 WAL 索引读锁),且并发首连会争排他锁
# 报 database is locked(core/db.py 同款教训)。专用锁 + 二次复查防护。
_WAL_READY: set[str] = set()
_WAL_SETUP_LOCK = threading.Lock()
_WAL_READY_FOR: str | None = None
# ============ 数据库 ============
def get_conn() -> sqlite3.Connection:
key = str(DB_PATH)
# exists() 检查(~10us)必须在 connect 之前:connect 会创建空文件,
# 之后 exists 恒为 True,删库重建的场景就检测不到了
async def _configure(conn: aiosqlite.Connection, force_wal: bool = False) -> None:
global _WAL_READY_FOR
conn.row_factory = aiosqlite.Row
# busy_timeout 先设:让后续语句遇锁自旋等待,而不是立刻报 database is locked
await conn.execute('PRAGMA busy_timeout=15000')
key = str(DB_PATH) # 调用时读取模块全局——测试 monkeypatch 后仍生效
if force_wal or _WAL_READY_FOR != key:
# 切换日志模式需要排他锁,且 `_WAL_READY_FOR` 的检查不是原子的——
# 并发连接须在专用锁内串行完成「切换 + 标记」,并二次复查,否则批量并发
# 连接会同时执行 PRAGMA journal_mode=WAL 争锁(实测 database is locked)
async with _wal_lock():
if force_wal or _WAL_READY_FOR != key:
await conn.execute('PRAGMA journal_mode=WAL')
_WAL_READY_FOR = key
await conn.execute('PRAGMA synchronous=NORMAL') # WAL 下兼顾安全与吞吐
await conn.execute('PRAGMA foreign_keys=ON')
async def connect() -> aiosqlite.Connection:
"""新建一个已配置好的连接(调用方负责 close)。"""
# exists() 必须在 aiosqlite.connect 之前判断:connect 会创建空文件,之后
# exists 恒为 True,库文件被删重建的场景就检测不到了(journal_mode 不持久
# 于新文件,必须重设)。
force_wal = not DB_PATH.exists()
# mkdir 移出 happy path(实测 190us/次):connect 直接失败(目录缺失)
# 才补建重试。库文件被删的场景同样由此兜底重建。
# mkdir 移出 happy path(实测 190us/次):connect 直接失败(目录缺失)才补建
# 重试。库文件被删的场景同样由此兜底重建。用 DB_PATH.parent 而非 DATA_DIR:
# 生产两者相同,测试 monkeypatch 只换 DB_PATH 时兜底仍能命中正确目录
# (core/db.py 用 DATA_DIR 的前提是两处都换)。
try:
conn = sqlite3.connect(str(DB_PATH), timeout=15)
conn = await aiosqlite.connect(str(DB_PATH), timeout=15)
except sqlite3.OperationalError:
# DB_PATH.parent 而非 DATA_DIR:生产两者相同,测试 monkeypatch 只换
# DB_PATH 时兜底仍能命中正确目录
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH), timeout=15)
conn.row_factory = sqlite3.Row
if key not in _WAL_READY or force_wal:
# 读路径不持 _DB_LOCK,多线程可能同时首连:锁内串行执行 journal_mode
# 切换(防并发争排他锁报 database is locked)。锁内不复查 DB_PATH.exists()——
# connect 已把文件建出来,exists 恒真;force_wal 是本线程的观测结论,
# 重复执行 PRAGMA 在已是 WAL 的库上幂等且廉价(~2us)
with _WAL_SETUP_LOCK:
conn.execute('PRAGMA journal_mode=WAL')
_WAL_READY.add(key)
conn = await aiosqlite.connect(str(DB_PATH), timeout=15)
await _configure(conn, force_wal)
return conn
def _q(sql: str, params: tuple = ()) -> list[sqlite3.Row]:
# 注:同步时代的公开连接门面 get_conn() 已删除——它在迁移后只剩「转调 connect()」
# 一层空壳且全仓零消费者(测试直接开裸连接,备份用 db.connect())。保留它等于留一条
# 「拿裸连接自己写」的口子,与本次「拆掉同步门面、写路径统一走 _x/write_transaction」
# 的目标相悖。
# ============ 读路径(并发,无锁) ============
async def _q(sql: str, params: Sequence = ()) -> list[aiosqlite.Row]:
"""单语句读:**不持锁**。
WAL 模式下每个连接读到一致性快照,读与写互不阻塞;旧实现对读也加 _DB_LOCK,
WAL 模式下每个连接读到一致性快照,读与写互不阻塞;旧实现对读也加锁,
等于把全部读串行化并被写挡住。此处仅放开单语句读——写事务(_x 与各
`with _DB_LOCK` 块)仍全程串行,故不引入写-写竞争。
`write_transaction` 块)仍全程串行,故不引入写-写竞争。
"""
conn = get_conn()
conn = await connect()
try:
return conn.execute(sql, params).fetchall()
cur = await conn.execute(sql, params)
return list(await cur.fetchall())
finally:
conn.close()
await conn.close()
def _x(sql: str, params: tuple = ()) -> int:
"""执行写操作,返回 lastrowid / rowcount"""
with _DB_LOCK:
conn = get_conn()
# ============ 写路径(串行,不阻塞读) ============
async def _x(sql: str, params: Sequence = ()) -> int:
"""执行单条写语句,返回 lastrowid(INSERT)或 rowcount(UPDATE/DELETE)"""
async with _write_lock():
conn = await connect()
try:
cur = conn.execute(sql, params)
conn.commit()
cur = await conn.execute(sql, params)
await conn.commit()
return cur.lastrowid if cur.lastrowid else cur.rowcount
finally:
conn.close()
await conn.close()
@asynccontextmanager
async def write_transaction() -> AsyncGenerator[aiosqlite.Connection, None]:
"""写事务上下文:多条写语句原子提交(串行锁内)。
用法:
async with write_transaction() as conn:
await conn.execute(...)
await conn.execute(...)
# 退出即 commit;异常则 rollback
"""
async with _write_lock():
conn = await connect()
try:
yield conn
await conn.commit()
except Exception:
await conn.rollback()
raise
finally:
await conn.close()
_SCHEMA_SQL = '''
@@ -148,30 +230,30 @@ _SCHEMA_SQL = '''
'''
def _create_schema(conn: sqlite3.Connection) -> None:
async def _create_schema(conn: aiosqlite.Connection) -> None:
"""建表 + 单行配置种子行(幂等:全部 IF NOT EXISTS / INSERT OR IGNORE)。"""
conn.executescript(_SCHEMA_SQL)
await conn.executescript(_SCHEMA_SQL)
# 单行配置表
conn.execute('''CREATE TABLE IF NOT EXISTS smtp_config (
await conn.execute('''CREATE TABLE IF NOT EXISTS smtp_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
host TEXT DEFAULT '', port INTEGER DEFAULT 587,
sender TEXT DEFAULT '', password TEXT DEFAULT '',
updated_at INTEGER DEFAULT 0
)''')
conn.execute('INSERT OR IGNORE INTO smtp_config (id, port) VALUES (1, 587)')
await conn.execute('INSERT OR IGNORE INTO smtp_config (id, port) VALUES (1, 587)')
# 键值设置表
conn.execute('''CREATE TABLE IF NOT EXISTS settings (
await conn.execute('''CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, value TEXT DEFAULT ''
)''')
conn.execute('''CREATE TABLE IF NOT EXISTS announcements (
await conn.execute('''CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY CHECK (id = 1),
title TEXT DEFAULT '', content TEXT DEFAULT '',
popup_mode TEXT DEFAULT 'off',
updated_at INTEGER DEFAULT 0
)''')
conn.execute('INSERT OR IGNORE INTO announcements (id) VALUES (1)')
await conn.execute('INSERT OR IGNORE INTO announcements (id) VALUES (1)')
# 计费账本(任务实扣/交付/退款/终态的持久化唯一记录,结算幂等)
conn.execute('''CREATE TABLE IF NOT EXISTS billing_ledger (
await conn.execute('''CREATE TABLE IF NOT EXISTS billing_ledger (
task_id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
reserved INTEGER DEFAULT 0,
@@ -183,11 +265,13 @@ def _create_schema(conn: sqlite3.Connection) -> None:
)''')
def _apply_column_migrations(conn: sqlite3.Connection) -> None:
async def _apply_column_migrations(conn: aiosqlite.Connection) -> None:
"""轻量迁移:新增列。仅"列已存在"可忽略(其余迁移失败必须暴露,不能吞掉)"""
_already: set[str] = set()
for trow in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall():
for crow in conn.execute(f"PRAGMA table_info({trow['name']})").fetchall():
trows = await (await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")).fetchall()
for trow in trows:
crows = await (await conn.execute(f"PRAGMA table_info({trow['name']})")).fetchall()
for crow in crows:
_already.add(f"{trow['name']}.{crow['name']}")
for alter_sql, tbl_col in (
('ALTER TABLE users ADD COLUMN status INTEGER DEFAULT 1', 'users.status'),
@@ -202,82 +286,87 @@ def _apply_column_migrations(conn: sqlite3.Connection) -> None:
if tbl_col in _already:
continue # 幂等:列已存在
try:
conn.execute(alter_sql)
await conn.execute(alter_sql)
except sqlite3.OperationalError as e:
if 'duplicate column' not in str(e).lower():
# 磁盘/锁/结构等真实故障:抛出,禁止静默带病初始化
raise
def _ensure_email_unique_index(conn: sqlite3.Connection) -> None:
async def _ensure_email_unique_index(conn: aiosqlite.Connection) -> None:
"""非空邮箱唯一(部分索引,不约束历史空串)。
存量库若已有重复非空邮箱,索引创建失败仅告警不阻断启动
(查重逻辑在注册/建号路由另有兜底)。
"""
try:
conn.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty '
'ON users(email) WHERE email != \'\'')
await conn.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty '
'ON users(email) WHERE email != \'\'')
except sqlite3.IntegrityError:
print('[警告] users.email 存在重复的非空邮箱,唯一索引未创建(请人工清理后重启)')
def _migrate_user_emails(conn: sqlite3.Connection) -> None:
async def _migrate_user_emails(conn: aiosqlite.Connection) -> None:
"""users.email 口径统一为归一化(lower + 剥 Gmail +tag)——与
get_user_by_email/找回/邮箱登录的查询口径对齐。逐行迁移,归一化后与他
人撞唯一索引的行保留原值跳过(与"存量重复仅告警"同口径)。幂等:已
归一化的行零写入。
"""
for urow in conn.execute("SELECT id, email FROM users WHERE email != ''").fetchall():
urows = await (await conn.execute("SELECT id, email FROM users WHERE email != ''")).fetchall()
for urow in urows:
fixed = _normalize_email(urow['email'])
if not fixed or fixed == urow['email']:
continue
try:
conn.execute('UPDATE users SET email = ? WHERE id = ?', (fixed, urow['id']))
await conn.execute('UPDATE users SET email = ? WHERE id = ?', (fixed, urow['id']))
except sqlite3.IntegrityError:
print(f"[警告] 用户 {urow['id']} 的邮箱归一化后与他人重复,保留原值")
def _ensure_initial_admin(conn: sqlite3.Connection) -> None:
async def _ensure_initial_admin(conn: aiosqlite.Connection) -> None:
"""初始管理员(新装不再使用公开默认口令,随机生成并写入日志一次)。
引导条件改为"不存在任何管理员角色"——按用户名查会在
管理员改名(如 admin→qfbsh)后误判缺失,重启即重建出一个新的 admin 账号。
"""
row = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
row = await (await conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1")).fetchone()
if row:
return
initial_pass = secrets.token_urlsafe(12)
conn.execute(
await conn.execute(
'INSERT INTO users (username, password_hash, salt, role, duration_seconds, created_at) '
'VALUES (?, ?, ?, ?, -1, ?)',
(DEFAULT_ADMIN_USER, _hash_new(initial_pass), '',
'admin', int(time.time())))
# ——非 ASCII 输出须防 GBK 重定向下 UnicodeEncodeError
# ——非 ASCII 输出须防 GBK 重定向下的 UnicodeEncodeError
try:
print(f'[初始化] 已创建管理员账号 admin,初始密码:{initial_pass}(请立即登录并修改,本提示不再显示)')
except UnicodeEncodeError:
print('[init] admin account created, initial password printed above failed to encode')
def init_db() -> None:
async def _init_db_locked(conn: aiosqlite.Connection) -> None:
"""在**已持有写锁**的连接上执行全部结构演进(不自行取锁)。
restore_backup 需要在自己的写锁临界区里对新库跑一遍初始化,不能调用会
再次取写锁的 init_db(asyncio.Lock 非重入,会自锁死),故拆出本函数。
"""
await _create_schema(conn)
await _apply_column_migrations(conn)
await _ensure_email_unique_index(conn)
await _migrate_user_emails(conn)
await _ensure_initial_admin(conn)
async def init_db() -> None:
"""初始化表结构与默认数据"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
with _DB_LOCK:
conn = get_conn()
try:
_create_schema(conn)
_apply_column_migrations(conn)
_ensure_email_unique_index(conn)
_migrate_user_emails(conn)
_ensure_initial_admin(conn)
conn.commit()
finally:
conn.close()
DATA_DIR.mkdir(parents=True, exist_ok=True) # noqa: ASYNC240 - 启动期一次的目录创建,无需线程池
async with write_transaction() as conn:
await _init_db_locked(conn)
def get_secret_key() -> str:
"""获取(或生成)持久化的会话签名密钥(原子写入 + 失败显式告警)"""
def _get_secret_key_sync() -> str:
"""同步实现(见 get_secret_key 的异步包装):原子写入 + 失败显式告警"""
key = ''
try:
if SECRET_KEY_PATH.exists():
@@ -303,3 +392,13 @@ def get_secret_key() -> str:
except UnicodeEncodeError:
print('[warn] secret key persistence failed; using temporary key for this process', file=sys.stderr)
return new_key
async def get_secret_key() -> str:
"""获取(或生成)持久化的会话签名密钥(原子写入 + 失败显式告警)。
内部是**同步阻塞文件 IO**(exists/read/write/os.replace),整段经
`asyncio.to_thread` 卸载到线程——这是正当的阻塞 IO 卸载(登录链路每次
签会话都要读一次密钥),不是把异步包装成同步的门面。
"""
return await asyncio.to_thread(_get_secret_key_sync)
+7 -9
View File
@@ -1,10 +1,9 @@
# -*- coding: utf-8 -*-
"""登录 IP 归属地探测与异地自动封禁。
(自 auth_system.py 拆出,行为与原实现一致。)
(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。)
"""
import asyncio
import json
import sqlite3
import time
@@ -78,8 +77,8 @@ async def record_login_location(user: sqlite3.Row, ip: str) -> tuple:
"""记录本次登录省份并检测异常:24h 内出现 >=3 个不同省份自动封禁。
返回 (是否被封禁, 说明)
落库仍走同步 sqlite(auth_system 是同步 DB 层),但调用方在事件循环里,
故经 asyncio.to_thread 卸载——避免 sqlite 的 busy 等待(最长 15s)冻结整个服务。
落库走原生异步通道(auth_system 已迁移为异步 DB 层),直接 await,
不再经 asyncio.to_thread 桥接。
"""
if user['role'] == 'admin':
return False, ''
@@ -97,12 +96,11 @@ async def record_login_location(user: sqlite3.Row, ip: str) -> tuple:
for h in history:
if h.get('p') and h['p'] not in provinces:
provinces.append(h['p'])
await asyncio.to_thread(
_x, 'UPDATE users SET recent_provinces = ? WHERE id = ?',
(json.dumps(history, ensure_ascii=False), user['id']))
await _x('UPDATE users SET recent_provinces = ? WHERE id = ?',
(json.dumps(history, ensure_ascii=False), user['id']))
if len(provinces) >= PROVINCE_BAN_LIMIT:
reason = f'24小时内检测到 {len(provinces)} 个不同省份登录({"/".join(provinces[-3:])}),疑似账号倒卖,已自动封禁'
await asyncio.to_thread(ban_user, user['id'], reason)
await asyncio.to_thread(add_log, 'fail', user['username'], ip, '自动封禁', reason)
await ban_user(user['id'], reason)
await add_log('fail', user['username'], ip, '自动封禁', reason)
return True, reason
return False, province
+86 -115
View File
@@ -1,17 +1,16 @@
# -*- coding: utf-8 -*-
"""预扣账本(开账 / 改名 / 补扣 / 结算 / 对账)。
(自 auth_system.py 拆出,行为与原实现一致。)
(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。)
"""
import time
from typing import Any
from .db import (
_DB_LOCK,
_q,
_x,
get_conn,
write_transaction,
)
from .logs import (
add_log,
@@ -20,80 +19,62 @@ from .logs import (
# ============ 计费账本(实扣、交付、退款、终态的持久化唯一记录) ============
def ledger_open(task_id: str, user_id: int, reserved: int) -> None:
async def ledger_open(task_id: str, user_id: int, reserved: int) -> None:
"""任务计费开账:记录本任务实际预扣次数(admin/-1 用户为 0)。
task_id 唯一;重复 task_id 由任务系统在提交侧拒绝,这里幂等保护。"""
with _DB_LOCK:
conn = get_conn()
try:
conn.execute(
'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) '
'VALUES (?, ?, ?, \'open\', ?)',
(str(task_id), int(user_id), max(0, int(reserved)), int(time.time())))
conn.commit()
finally:
conn.close()
await _x(
'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) '
'VALUES (?, ?, ?, \'open\', ?)',
(str(task_id), int(user_id), max(0, int(reserved)), int(time.time())))
def reserve_and_open(uid: int, count: int, ledger_key: str) -> int | None:
async def reserve_and_open(uid: int, count: int, ledger_key: str) -> int | None:
"""预扣与开账同一事务(原两步之间存在崩溃丢账窗口)。
足额:扣减+开账并返回剩余;不限/管理员:开账(reserved=0)返回 -1;不足:返回 None(不开账)。"""
count = max(1, int(count))
key = str(ledger_key)
with _DB_LOCK:
conn = get_conn()
try:
row = conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?', (uid,)).fetchone()
if not row:
return None
now = int(time.time())
if row['role'] == 'admin':
conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (now, uid))
conn.execute(
'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) '
'VALUES (?, ?, 0, \'open\', ?)', (key, int(uid), now))
conn.commit()
return -1
cur = int(row['duration_seconds'] or 0)
if cur == -1:
conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (now, uid))
conn.execute(
'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) '
'VALUES (?, ?, 0, \'open\', ?)', (key, int(uid), now))
conn.commit()
return -1
if cur < count:
return None
conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?',
(cur - count, now, uid))
conn.execute(
async with write_transaction() as conn:
row = await (await conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?', (uid,))).fetchone()
if not row:
return None
now = int(time.time())
if row['role'] == 'admin':
await conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (now, uid))
await conn.execute(
'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) '
'VALUES (?, ?, ?, \'open\', ?)', (key, int(uid), count, now))
conn.commit()
return cur - count
finally:
conn.close()
'VALUES (?, ?, 0, \'open\', ?)', (key, int(uid), now))
return -1
cur = int(row['duration_seconds'] or 0)
if cur == -1:
await conn.execute('UPDATE users SET last_active = ? WHERE id = ?', (now, uid))
await conn.execute(
'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) '
'VALUES (?, ?, 0, \'open\', ?)', (key, int(uid), now))
return -1
if cur < count:
return None
await conn.execute('UPDATE users SET duration_seconds = ?, last_active = ? WHERE id = ?',
(cur - count, now, uid))
await conn.execute(
'INSERT OR IGNORE INTO billing_ledger (task_id, user_id, reserved, status, created_at) '
'VALUES (?, ?, ?, \'open\', ?)', (key, int(uid), count, now))
return cur - count
def ledger_rename(old_key: str, new_key: str) -> bool:
async def ledger_rename(old_key: str, new_key: str) -> bool:
"""把请求级临时账本行改名为正式 task_id(任务接手账目)。
old 不存在/已关闭/new 已存在 → False。"""
with _DB_LOCK:
conn = get_conn()
try:
dup = conn.execute('SELECT 1 FROM billing_ledger WHERE task_id = ?', (str(new_key),)).fetchone()
if dup:
return False
cur = conn.execute(
'UPDATE billing_ledger SET task_id = ? WHERE task_id = ? AND status = \'open\'',
(str(new_key), str(old_key)))
conn.commit()
return cur.rowcount == 1
finally:
conn.close()
async with write_transaction() as conn:
dup = await (await conn.execute('SELECT 1 FROM billing_ledger WHERE task_id = ?', (str(new_key),))).fetchone()
if dup:
return False
cur = await conn.execute(
'UPDATE billing_ledger SET task_id = ? WHERE task_id = ? AND status = \'open\'',
(str(new_key), str(old_key)))
return cur.rowcount == 1
def ledger_supplement(task_id: str, extra: int) -> bool:
async def ledger_supplement(task_id: str, extra: int) -> bool:
"""整台播客等"先扣 1 后按实际数量补扣"场景:
原子补扣用户余额并同步累加账本 reserved。任一步失败返回 False(调用方必须让任务失败,
不允许无账本继续交付)。余额不足同样返回 False。"""
@@ -101,39 +82,34 @@ def ledger_supplement(task_id: str, extra: int) -> bool:
extra = int(extra)
if extra <= 0:
return True
with _DB_LOCK:
conn = get_conn()
try:
row = conn.execute('SELECT user_id, status FROM billing_ledger WHERE task_id = ?',
(task_id,)).fetchone()
try:
async with write_transaction() as conn:
row = await (await conn.execute('SELECT user_id, status FROM billing_ledger WHERE task_id = ?',
(task_id,))).fetchone()
if not row or row['status'] != 'open':
return False
urow = conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?',
(row['user_id'],)).fetchone()
urow = await (await conn.execute('SELECT role, duration_seconds FROM users WHERE id = ?',
(row['user_id'],))).fetchone()
if not urow or urow['role'] == 'admin' or int(urow['duration_seconds'] or 0) == -1:
return True # 不限用户:无需补扣,账本也不加
cur_credits = int(urow['duration_seconds'] or 0)
if cur_credits < extra:
return False # 余额不足:任务必须失败(不允许扣到 0 硬做)
conn.execute('UPDATE users SET duration_seconds = duration_seconds - ? WHERE id = ?',
(extra, row['user_id']))
conn.execute('UPDATE billing_ledger SET reserved = reserved + ? WHERE task_id = ?',
(extra, task_id))
conn.commit()
await conn.execute('UPDATE users SET duration_seconds = duration_seconds - ? WHERE id = ?',
(extra, row['user_id']))
await conn.execute('UPDATE billing_ledger SET reserved = reserved + ? WHERE task_id = ?',
(extra, task_id))
return True
except Exception:
conn.rollback()
return False
finally:
conn.close()
except Exception:
return False
def ledger_get(task_id: str) -> dict[str, Any] | None:
rows = _q('SELECT * FROM billing_ledger WHERE task_id = ?', (str(task_id),))
async def ledger_get(task_id: str) -> dict[str, Any] | None:
rows = await _q('SELECT * FROM billing_ledger WHERE task_id = ?', (str(task_id),))
return dict(rows[0]) if rows else None
def ledger_settle(task_id: str, delivered: int, final_status: str) -> int:
async def ledger_settle(task_id: str, delivered: int, final_status: str) -> int:
"""任务计费结算(幂等):按账本实扣与实际交付计算应退次数,原子退款并落终态。
- delivered:本次任务实际交付给用户的数量(单曲/单集 0 或 1;批量为成功入包数)
- 重复调用/重启后再次调用:账本已非 open 直接返回 0,不重复退款
@@ -143,58 +119,53 @@ def ledger_settle(task_id: str, delivered: int, final_status: str) -> int:
返回实际退款次数(落余额的部分;-1/admin 场景为 0)。"""
task_id = str(task_id)
delivered = max(0, int(delivered))
with _DB_LOCK:
conn = get_conn()
try:
row = conn.execute('SELECT * FROM billing_ledger WHERE task_id = ?', (task_id,)).fetchone()
if not row or row['status'] != 'open':
return 0
refund = max(0, int(row['reserved']) - delivered)
# 退款与账本终态同事务落库:要么都成,要么都不成。
# 注意 WHERE 必须排除 admin/-1:向 -1(不限次)账户加次数会把 -1 哨兵值破坏成
# -1+N,向 admin 加无意义——因此任务运行期间账户升级为 -1 时退款不落余额,
# 账本 refunded 记录的是"按账本应付"口径(该用户已有无限次数,作废可接受);
# 原注释"照样退"与实现相反,已更正为此口径。
if refund > 0:
conn.execute('UPDATE users SET duration_seconds = duration_seconds + ? '
'WHERE id = ? AND role != \'admin\' AND duration_seconds != -1',
(refund, row['user_id']))
conn.execute('UPDATE billing_ledger SET delivered = ?, refunded = ?, status = ?, '
'settled_at = ? WHERE task_id = ?',
(delivered, refund, str(final_status)[:20], int(time.time()), task_id))
conn.commit()
return refund
finally:
conn.close()
async with write_transaction() as conn:
row = await (await conn.execute('SELECT * FROM billing_ledger WHERE task_id = ?', (task_id,))).fetchone()
if not row or row['status'] != 'open':
return 0
refund = max(0, int(row['reserved']) - delivered)
# 退款与账本终态同事务落库:要么都成,要么都不成。
# 注意 WHERE 必须排除 admin/-1:向 -1(不限次)账户加次数会把 -1 哨兵值破坏成
# -1+N,向 admin 加无意义——因此任务运行期间账户升级为 -1 时退款不落余额,
# 账本 refunded 记录的是"按账本应付"口径(该用户已有无限次数,作废可接受);
# 原注释"照样退"与实现相反,已更正为此口径。
if refund > 0:
await conn.execute('UPDATE users SET duration_seconds = duration_seconds + ? '
'WHERE id = ? AND role != \'admin\' AND duration_seconds != -1',
(refund, row['user_id']))
await conn.execute('UPDATE billing_ledger SET delivered = ?, refunded = ?, status = ?, '
'settled_at = ? WHERE task_id = ?',
(delivered, refund, str(final_status)[:20], int(time.time()), task_id))
return refund
def ledger_recover_orphans(max_age_seconds: int = 2 * 3600) -> int:
async def ledger_recover_orphans(max_age_seconds: int = 2 * 3600) -> int:
"""启动对账:任务表在内存,进程重启后账本中仍 open 的行代表
"已扣款但任务已丢失"——退回全部实扣。仅处理超过宽限期的行,
避免误伤同库其他存活进程的进行中任务。返回退款笔数。"""
now = int(time.time())
recovered = 0
rows = _q('SELECT task_id FROM billing_ledger WHERE status = \'open\' AND created_at <= ?',
(now - int(max_age_seconds),))
rows = await _q('SELECT task_id FROM billing_ledger WHERE status = \'open\' AND created_at <= ?',
(now - int(max_age_seconds),))
for r in rows:
try:
refunded = ledger_settle(r['task_id'], 0, 'lost_restart')
refunded = await ledger_settle(r['task_id'], 0, 'lost_restart')
if refunded:
add_log('success', 'system', 'system', '重启对账退款',
f'任务 {r["task_id"]} 因进程重启丢失,退回 {refunded} 次')
await add_log('success', 'system', 'system', '重启对账退款',
f'任务 {r["task_id"]} 因进程重启丢失,退回 {refunded} 次')
else:
ledger_settle(r['task_id'], 0, 'lost_restart')
await ledger_settle(r['task_id'], 0, 'lost_restart')
recovered += 1
except Exception:
pass
return recovered
def ledger_cleanup_settled(max_age_seconds: int = 90 * 86400) -> int:
async def ledger_cleanup_settled(max_age_seconds: int = 90 * 86400) -> int:
"""清理已结算的历史账本行(每任务一行、只增不删会让表无限膨胀;
settled 状态行无对账价值,保留 90 天足够追溯)"""
try:
return _x('DELETE FROM billing_ledger WHERE status != \'open\' AND settled_at <= ?',
(int(time.time()) - int(max_age_seconds),))
return await _x('DELETE FROM billing_ledger WHERE status != \'open\' AND settled_at <= ?',
(int(time.time()) - int(max_age_seconds),))
except Exception:
return 0
+15 -21
View File
@@ -1,16 +1,15 @@
# -*- coding: utf-8 -*-
"""后台审计日志。
(自 auth_system.py 拆出,行为与原实现一致。)
(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。)
"""
import time
from .db import (
_DB_LOCK,
_q,
_x,
get_conn,
write_transaction,
)
# ============ 日志 ============
@@ -20,37 +19,32 @@ from .db import (
_LOG_WRITE_COUNTER = {'n': 0}
def add_log(log_type: str, username: str, ip: str, action: str, detail: str = '') -> None:
async def add_log(log_type: str, username: str, ip: str, action: str, detail: str = '') -> None:
try:
_x('INSERT INTO logs (type, username, ip, action, detail, time) VALUES (?, ?, ?, ?, ?, ?)',
(log_type, username or '', ip or '-', action or '', detail or '',
time.strftime('%Y-%m-%d %H:%M:%S')))
await _x('INSERT INTO logs (type, username, ip, action, detail, time) VALUES (?, ?, ?, ?, ?, ?)',
(log_type, username or '', ip or '-', action or '', detail or '',
time.strftime('%Y-%m-%d %H:%M:%S')))
# 控制日志表体积(每 50 次写入触发一次裁剪)
_LOG_WRITE_COUNTER['n'] += 1
if _LOG_WRITE_COUNTER['n'] % 50 == 0:
_x('DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT 5000)')
await _x('DELETE FROM logs WHERE id NOT IN (SELECT id FROM logs ORDER BY id DESC LIMIT 5000)')
except Exception:
pass
def query_logs(log_type: str, limit: int = 100, offset: int = 0):
async def query_logs(log_type: str, limit: int = 100, offset: int = 0):
"""分页查询日志,返回 (日志列表, 总条数)"""
limit = max(1, min(int(limit), 500))
offset = max(0, int(offset))
rows = _q('SELECT * FROM logs WHERE type = ? ORDER BY id DESC LIMIT ? OFFSET ?',
(log_type, limit, offset))
total_row = _q('SELECT COUNT(*) AS c FROM logs WHERE type = ?', (log_type,))
rows = await _q('SELECT * FROM logs WHERE type = ? ORDER BY id DESC LIMIT ? OFFSET ?',
(log_type, limit, offset))
total_row = await _q('SELECT COUNT(*) AS c FROM logs WHERE type = ?', (log_type,))
total = int(total_row[0]['c']) if total_row else 0
return [dict(r) for r in rows], total
def clear_logs(log_type: str) -> int:
async def clear_logs(log_type: str) -> int:
"""清除指定类型的全部日志,返回删除条数"""
with _DB_LOCK:
conn = get_conn()
try:
cur = conn.execute('DELETE FROM logs WHERE type = ?', (log_type,))
conn.commit()
return cur.rowcount
finally:
conn.close()
async with write_transaction() as conn:
cur = await conn.execute('DELETE FROM logs WHERE type = ?', (log_type,))
return cur.rowcount
+74 -82
View File
@@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
"""SMTP 配置、发信与邮箱验证码。
(自 auth_system.py 拆出,行为与原实现一致。)
(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步,
阻塞的 smtplib 发信经 asyncio.to_thread 卸载。)
"""
import asyncio
import base64
import re
import secrets
@@ -17,11 +19,10 @@ from email.utils import formataddr
from typing import Any
from .db import (
_DB_LOCK,
_q,
_x,
get_conn,
get_secret_key,
write_transaction,
)
from .security import (
_normalize_email,
@@ -46,54 +47,54 @@ def _code_fails_trim() -> None:
_SMTP_ENC_PREFIX = 'enc1:' # 信封加密标记前缀(存量明文读取时透明升级)
def _smtp_password_encrypt(plaintext: str) -> str:
async def _smtp_password_encrypt(plaintext: str) -> str:
"""用 data/secret.key 派生 Fernet 密钥加密 SMTP 授权码。
原实现明文落库、并随数据库备份一起导出,等于扩大泄露半径。"""
import hashlib as _hl
from cryptography.fernet import Fernet
key = _hl.sha256(get_secret_key().encode('utf-8')).digest()
key = _hl.sha256((await get_secret_key()).encode('utf-8')).digest()
f = Fernet(base64.urlsafe_b64encode(key))
return _SMTP_ENC_PREFIX + f.encrypt(plaintext.encode('utf-8')).decode('utf-8')
def _smtp_password_decrypt(ciphertext: str) -> str:
async def _smtp_password_decrypt(ciphertext: str) -> str:
if not ciphertext.startswith(_SMTP_ENC_PREFIX):
return ciphertext # 存量明文:透明兼容,下次保存时升级为密文
import hashlib as _hl
from cryptography.fernet import Fernet
key = _hl.sha256(get_secret_key().encode('utf-8')).digest()
key = _hl.sha256((await get_secret_key()).encode('utf-8')).digest()
f = Fernet(base64.urlsafe_b64encode(key))
return f.decrypt(ciphertext[len(_SMTP_ENC_PREFIX):].encode('utf-8')).decode('utf-8')
def get_smtp_config() -> dict[str, Any]:
rows = _q('SELECT * FROM smtp_config WHERE id = 1')
async def get_smtp_config() -> dict[str, Any]:
rows = await _q('SELECT * FROM smtp_config WHERE id = 1')
cfg = dict(rows[0]) if rows else {'host': '', 'port': 587, 'sender': '', 'password': ''}
# 读取时解密密码——调用方(SMTP 连接/后台展示)拿到的始终是明文
if cfg.get('password'):
try:
cfg['password'] = _smtp_password_decrypt(cfg['password'])
cfg['password'] = await _smtp_password_decrypt(cfg['password'])
except Exception:
cfg['password'] = ''
return cfg
def save_smtp_config(host: str, port: int, sender: str, password: str) -> None:
enc_pwd = _smtp_password_encrypt(password) if password else ''
_x('UPDATE smtp_config SET host = ?, port = ?, sender = ?, password = ?, updated_at = ? WHERE id = 1',
(host, int(port or 587), sender, enc_pwd, int(time.time())))
async def save_smtp_config(host: str, port: int, sender: str, password: str) -> None:
enc_pwd = await _smtp_password_encrypt(password) if password else ''
await _x('UPDATE smtp_config SET host = ?, port = ?, sender = ?, password = ?, updated_at = ? WHERE id = 1',
(host, int(port or 587), sender, enc_pwd, int(time.time())))
def smtp_configured() -> bool:
cfg = get_smtp_config()
async def smtp_configured() -> bool:
cfg = await get_smtp_config()
return bool(cfg['host'] and cfg['sender'] and cfg['password'])
def _smtp_connect() -> smtplib.SMTP:
cfg = get_smtp_config()
def _smtp_connect(cfg: dict[str, Any]) -> smtplib.SMTP:
"""连接并登录 SMTP(**阻塞网络 IO**):只应由 asyncio.to_thread 调用。"""
port = int(cfg['port'] or 587)
# 显式校验证书与主机名的 TLS 上下文(不依赖各运行时默认值)
ctx = ssl.create_default_context()
@@ -106,21 +107,27 @@ def _smtp_connect() -> smtplib.SMTP:
return server
def send_email(to: str, subject: str, body: str) -> None:
def _smtp_send(cfg: dict[str, Any], sender: str, to: str, raw: str) -> None:
"""同步发信(阻塞网络 IO):只应由 asyncio.to_thread 调用。"""
with _smtp_connect(cfg) as server:
server.sendmail(sender, [to], raw)
async def send_email(to: str, subject: str, body: str) -> None:
"""收件人格式校验下沉到本入口——未来任何新调用方漏校验也不再成为
CRLF/信封注入点(现有调用点的校验仅作 UX 提示)。"""
to = _normalize_email(to)
if not re.match(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$', to, re.ASCII):
raise ValueError('收件邮箱格式不正确')
cfg = get_smtp_config()
cfg = await get_smtp_config()
msg = MIMEText(body, 'plain', 'utf-8')
# email 库推荐写法:Header 对象由 Generator._fold 的 Header 分支做 RFC 2047 编码
# (typeshed 把 Message.__setitem__ 的值参数收窄成 str,属桩过窄;运行时已实测正确)
msg['Subject'] = Header(subject, 'utf-8') # type: ignore[assignment]
msg['From'] = formataddr(('云村小助手', cfg['sender']))
msg['To'] = to
with _smtp_connect() as server:
server.sendmail(cfg['sender'], [to], msg.as_string())
# smtplib 同步发信是阻塞网络 IO,卸载到线程(正当的阻塞网络 IO 卸载,非同步门面)
await asyncio.to_thread(_smtp_send, cfg, cfg['sender'], to, msg.as_string())
_EMAIL_SENT_24H: dict = {} # 同邮箱 24h 发送计数(内存态,重启清零可接受)
@@ -152,7 +159,7 @@ _CODE_EMAIL_TEMPLATES = {
}
def send_verify_code(email: str, purpose: str = 'register') -> None:
async def send_verify_code(email: str, purpose: str = 'register') -> None:
"""生成并发送邮箱验证码(10 分钟有效)(先发信成功再落库;同用途旧码即时作废)。
① 同邮箱 24h 硬上限 5 封(防邮件轰炸代理/发信配额消耗);
② email 先归一化(Gmail +tag 别名剥离)——别名刷号被邮箱查重堵死。
@@ -166,7 +173,7 @@ def send_verify_code(email: str, purpose: str = 'register') -> None:
# 原实现 5000 字符邮箱可入库)
if len(email) > 254 or not re.match(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$', email, re.ASCII):
raise ValueError('邮箱格式不正确')
if not smtp_configured():
if not await smtp_configured():
raise RuntimeError('邮箱服务未配置,请联系管理员')
# 同邮箱 24h ≤5 封(内存计数,不再依赖会被消费 DELETE 清零的 DB 行数);
# admin2fa 豁免 24h 上限与 60s 冷却(见 _CODE_PURPOSES 处注释)
@@ -176,14 +183,14 @@ def send_verify_code(email: str, purpose: str = 'register') -> None:
raise RuntimeError('该邮箱验证码发送已达上限(24 小时 5 封),请明日再试')
# 60 秒内限制重发(按同邮箱同用途的最近一封计)
if purpose != 'admin2fa':
rows = _q('SELECT created_at FROM email_codes WHERE email = ? AND purpose = ? ORDER BY id DESC LIMIT 1',
(email, purpose))
rows = await _q('SELECT created_at FROM email_codes WHERE email = ? AND purpose = ? ORDER BY id DESC LIMIT 1',
(email, purpose))
if rows and time.time() - rows[0]['created_at'] < 60:
raise RuntimeError('发送过于频繁,请 1 分钟后再试')
code = f'{secrets.randbelow(1000000):06d}'
subject, body_tpl = _CODE_EMAIL_TEMPLATES[purpose]
# 先发送:SMTP 失败不留任何有效验证码与冷却记录;成功后才计入 24h 额度(admin2fa 不计数)
send_email(email, subject, body_tpl.format(code=code))
await send_email(email, subject, body_tpl.format(code=code))
if purpose != 'admin2fa':
sent.append(now_ts)
_EMAIL_SENT_24H[email] = sent
@@ -195,20 +202,15 @@ def send_verify_code(email: str, purpose: str = 'register') -> None:
if len(_EMAIL_SENT_24H) > 5000:
for k in list(_EMAIL_SENT_24H.keys())[:len(_EMAIL_SENT_24H) // 2]:
_EMAIL_SENT_24H.pop(k, None)
with _DB_LOCK:
conn = get_conn()
try:
# 新码生效同时作废同邮箱同用途旧码;顺手清理全表过期码
conn.execute('DELETE FROM email_codes WHERE (email = ? AND purpose = ?) OR expires_at <= ?',
(email, purpose, int(time.time())))
conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)',
(email, code, int(time.time() + 600), int(time.time()), purpose))
conn.commit()
finally:
conn.close()
async with write_transaction() as conn:
# 新码生效同时作废同邮箱同用途旧码;顺手清理全表过期码
await conn.execute('DELETE FROM email_codes WHERE (email = ? AND purpose = ?) OR expires_at <= ?',
(email, purpose, int(time.time())))
await conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)',
(email, code, int(time.time() + 600), int(time.time()), purpose))
def check_verify_code(email: str, code: str, purpose: str = 'register') -> int:
async def check_verify_code(email: str, code: str, purpose: str = 'register') -> int:
"""校验验证码(单条 DELETE 原子消费,rowcount=1 才算通过,杜绝并发双读)。
成功返回剩余有效秒数(真值),失败返回 0。
同一邮箱累计错 5 次→该用途验证码作废(防 10 分钟窗口内挂机猜码)。
@@ -217,47 +219,43 @@ def check_verify_code(email: str, code: str, purpose: str = 'register') -> int:
return 0
email = _normalize_email(email or '')
fail_key = purpose + ':' + email # 失败计数按"用途:邮箱"隔离,互不连带
with _DB_LOCK:
conn = get_conn()
try:
cur = conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ? '
'RETURNING expires_at',
(email, (code or '').strip(), purpose, int(time.time())))
row = cur.fetchone()
conn.commit()
if row:
_CODE_FAILS.pop(fail_key, None)
return max(0, int(row[0]) - int(time.time()))
except sqlite3.OperationalError as _oe:
# 老版本 SQLite 无 RETURNING:回退为删除后判 rowcount。
# 仅"不支持 RETURNING"走回退——库忙(database is locked)原样
# 上抛,否则第一次等满 busy 15s、回退再等 15s,全程持锁冻结全站写路径
if 'RETURNING' not in str(_oe) and 'syntax' not in str(_oe).lower():
raise
conn.rollback()
cur = conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ?',
(email, (code or '').strip(), purpose, int(time.time())))
conn.commit()
row = None
try:
async with write_transaction() as conn:
cur = await conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ? '
'RETURNING expires_at',
(email, (code or '').strip(), purpose, int(time.time())))
row = await cur.fetchone()
except sqlite3.OperationalError as _oe:
# 老版本 SQLite 无 RETURNING:回退为删除后判 rowcount。
# 仅"不支持 RETURNING"走回退——库忙(database is locked)原样
# 上抛,否则第一次等满 busy 15s、回退再等 15s,全程持锁冻结全站写路径
if 'RETURNING' not in str(_oe) and 'syntax' not in str(_oe).lower():
raise
async with write_transaction() as conn:
cur = await conn.execute('DELETE FROM email_codes WHERE email = ? AND code = ? AND purpose = ? AND expires_at > ?',
(email, (code or '').strip(), purpose, int(time.time())))
if cur.rowcount == 1:
_CODE_FAILS.pop(fail_key, None)
return 60
row = None
finally:
conn.close()
# 猜错:失败计数 +1,累计 5 次直接作废该用途的验证码
n = _CODE_FAILS.get(fail_key, 0) + 1
_CODE_FAILS[fail_key] = n
_code_fails_trim()
if n >= 5:
_CODE_FAILS.pop(fail_key, None)
try:
_x('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose))
except Exception:
pass
row = None
if row:
_CODE_FAILS.pop(fail_key, None)
return max(0, int(row[0]) - int(time.time()))
# 猜错:失败计数 +1,累计 5 次直接作废该用途的验证码
n = _CODE_FAILS.get(fail_key, 0) + 1
_CODE_FAILS[fail_key] = n
_code_fails_trim()
if n >= 5:
_CODE_FAILS.pop(fail_key, None)
try:
await _x('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose))
except Exception:
pass
return 0
def restore_verify_code(email: str, code: str, ttl_seconds: int, purpose: str = 'register') -> None:
async def restore_verify_code(email: str, code: str, ttl_seconds: int, purpose: str = 'register') -> None:
"""注册流程后续步骤失败时还原已消费的验证码(第二轮:建号失败不应吞掉验证码)"""
if ttl_seconds <= 0:
return
@@ -267,11 +265,5 @@ def restore_verify_code(email: str, code: str, ttl_seconds: int, purpose: str =
code = (code or '').strip()
if not email or not code:
return
with _DB_LOCK:
conn = get_conn()
try:
conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)',
(email, code, int(time.time() + ttl_seconds), int(time.time()), purpose))
conn.commit()
finally:
conn.close()
await _x('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) VALUES (?, ?, ?, ?, ?)',
(email, code, int(time.time() + ttl_seconds), int(time.time()), purpose))
+11 -11
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""公告与站点设置。
(自 auth_system.py 拆出,行为与原实现一致。)
(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。)
"""
import time
@@ -15,28 +15,28 @@ from .db import (
# ============ 公告 ============
def get_announcement() -> dict[str, Any]:
rows = _q('SELECT * FROM announcements WHERE id = 1')
async def get_announcement() -> dict[str, Any]:
rows = await _q('SELECT * FROM announcements WHERE id = 1')
return dict(rows[0]) if rows else {'title': '', 'content': '', 'popup_mode': 'off'}
def save_announcement(title: str, content: str, popup_mode: str) -> None:
async def save_announcement(title: str, content: str, popup_mode: str) -> None:
if popup_mode not in ('off', 'every', 'once'):
popup_mode = 'off'
# 版本号精确到毫秒,同秒多次保存也能区分(once 已读判断依赖该字段)
updated = time.strftime('%Y-%m-%d %H:%M:%S') + f'.{int(time.time() * 1000) % 1000:03d}'
_x('UPDATE announcements SET title = ?, content = ?, popup_mode = ?, updated_at = ? WHERE id = 1',
(title, content, popup_mode, updated))
await _x('UPDATE announcements SET title = ?, content = ?, popup_mode = ?, updated_at = ? WHERE id = 1',
(title, content, popup_mode, updated))
# ============ 设置(键值) ============
def get_setting(key: str, default: str = '') -> str:
rows = _q('SELECT value FROM settings WHERE key = ?', (key,))
async def get_setting(key: str, default: str = '') -> str:
rows = await _q('SELECT value FROM settings WHERE key = ?', (key,))
return rows[0]['value'] if rows else default
def set_setting(key: str, value: str) -> None:
_x('INSERT INTO settings (key, value) VALUES (?, ?) '
'ON CONFLICT(key) DO UPDATE SET value = excluded.value', (key, str(value)))
async def set_setting(key: str, value: str) -> None:
await _x('INSERT INTO settings (key, value) VALUES (?, ?) '
'ON CONFLICT(key) DO UPDATE SET value = excluded.value', (key, str(value)))
+89 -97
View File
@@ -1,9 +1,10 @@
# -*- coding: utf-8 -*-
"""用户增删改查、登录校验与状态。
(自 auth_system.py 拆出,行为与原实现一致。)
(自 auth_system.py 拆出,行为与原实现一致;DB 访问已迁移为原生异步。)
"""
import asyncio
import json
import sqlite3
import time
@@ -11,10 +12,9 @@ from dataclasses import dataclass
from typing import Any
from .db import (
_DB_LOCK,
_q,
_x,
get_conn,
write_transaction,
)
from .security import (
_hash_new,
@@ -27,7 +27,7 @@ from .security import (
class RegistrationGuard:
"""注册风控上下文:同 IP 频率闸 + 设备赠送闸。
这些检查必须与建号 INSERT 在**同一把 _DB_LOCK 事务内**完成——锁外预检存在
这些检查必须与建号 INSERT 在**同一写事务内**完成——锁外预检存在
TOCTOU(并发同设备双注册各得一次赠送、并发同 IP 挤过限额),所以它们不是
「可选的附加参数」,而是事务内必须可见的输入。管理建号无风控需求,不传即可。
"""
@@ -40,9 +40,9 @@ class RegistrationGuard:
device_once: bool = False
def create_user(username: str, password: str, email: str = '', credits: int = 0,
role: str = 'user', *,
guard: RegistrationGuard | None = None) -> sqlite3.Row | None:
async def create_user(username: str, password: str, email: str = '', credits: int = 0,
role: str = 'user', *,
guard: RegistrationGuard | None = None) -> sqlite3.Row | None:
# 余额只接受 -1(不限)或 >= 0;非法负值一律按 0 处理,禁止出现 -2 这类"假不限"
try:
credits = int(credits)
@@ -51,165 +51,157 @@ def create_user(username: str, password: str, email: str = '', credits: int = 0,
if credits < -1:
credits = 0
# 注册入口传 guard=RegistrationGuard(ip_limit=3, device_once=True)——同 IP 24h
# 注册数检查在 _DB_LOCK 事务内与 INSERT 原子完成,堵住「锁外检查 + 并发插入」
# 注册数检查在写事务内与 INSERT 原子完成,堵住「锁外检查 + 并发插入」
# 的 TOCTOU 刷号;管理建号不传 guard(即不限制)。超限抛 ValueError 由调用方转 400/429。
guard = guard or RegistrationGuard()
now = int(time.time())
with _DB_LOCK:
conn = get_conn()
try:
if guard.ip_limit > 0 and guard.ip:
n = conn.execute('SELECT COUNT(*) AS n FROM users WHERE register_ip = ? AND created_at > ?',
(guard.ip, now - guard.ip_window)).fetchone()[0]
if n >= guard.ip_limit:
raise ValueError('当前网络注册过于频繁,请 24 小时后再试或联系管理员')
if guard.device_once and guard.device_id:
# 设备赠送闸并入同一事务——锁外 device_used 预检有 TOCTOU,
# 并发同设备双注册可各得一次赠送;锁内复查命中则本次建号不赠送(账号照常创建)
n = conn.execute("SELECT COUNT(*) AS n FROM users WHERE trial_device = ? AND role != 'admin'",
(guard.device_id,)).fetchone()[0]
if n > 0:
credits = 0
conn.execute(
'INSERT INTO users (username, password_hash, salt, email, role, duration_seconds, '
'trial_device, register_ip, register_ua, last_active, created_at) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(username, _hash_new(password), '', email, role,
-1 if role == 'admin' else int(credits),
guard.device_id, guard.ip, guard.ua, now, now))
conn.commit()
finally:
conn.close()
return get_user_by_name(username)
# PBKDF2 10 万次迭代是 CPU 密集(~50ms):在事务外先算好,避免持写锁时
# 阻塞事件循环(正当的 CPU 卸载,非同步门面)。
phash = await asyncio.to_thread(_hash_new, password)
async with write_transaction() as conn:
if guard.ip_limit > 0 and guard.ip:
cur = await conn.execute(
'SELECT COUNT(*) AS n FROM users WHERE register_ip = ? AND created_at > ?',
(guard.ip, now - guard.ip_window))
row = await cur.fetchone()
n = int(row['n']) if row else 0
if n >= guard.ip_limit:
raise ValueError('当前网络注册过于频繁,请 24 小时后再试或联系管理员')
if guard.device_once and guard.device_id:
# 设备赠送闸并入同一事务——锁外 device_used 预检有 TOCTOU,
# 并发同设备双注册可各得一次赠送;锁内复查命中则本次建号不赠送(账号照常创建)
cur = await conn.execute(
"SELECT COUNT(*) AS n FROM users WHERE trial_device = ? AND role != 'admin'",
(guard.device_id,))
row = await cur.fetchone()
if row and int(row['n']) > 0:
credits = 0
await conn.execute(
'INSERT INTO users (username, password_hash, salt, email, role, duration_seconds, '
'trial_device, register_ip, register_ua, last_active, created_at) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(username, phash, '', email, role,
-1 if role == 'admin' else int(credits),
guard.device_id, guard.ip, guard.ua, now, now))
return await get_user_by_name(username)
def get_user_by_name(username: str) -> sqlite3.Row | None:
rows = _q('SELECT * FROM users WHERE username = ?', (username,))
async def get_user_by_name(username: str) -> sqlite3.Row | None:
rows = await _q('SELECT * FROM users WHERE username = ?', (username,))
return rows[0] if rows else None
def get_user_by_email(email: str) -> sqlite3.Row | None:
async def get_user_by_email(email: str) -> sqlite3.Row | None:
"""按邮箱查用户(注册/建号邮箱查重的友好提示;库侧另有唯一索引兜底并发)"""
email = _normalize_email(email)
if not email:
return None
rows = _q('SELECT * FROM users WHERE email = ? LIMIT 1', (email,))
rows = await _q('SELECT * FROM users WHERE email = ? LIMIT 1', (email,))
return rows[0] if rows else None
def register_ip_exceeded(ip: str, limit: int = 3, window: int = 86400) -> bool:
async def register_ip_exceeded(ip: str, limit: int = 3, window: int = 86400) -> bool:
"""同 IP 24h 内注册数超限(默认 3 次)——防 IP 轮换刷免费赠送次数;
设备指纹可被客户端伪造,IP 维度是第二道闸。家庭 NAT 场景超限可由管理员建号兜底。"""
if not ip:
return False
rows = _q('SELECT COUNT(*) AS n FROM users WHERE register_ip = ? AND created_at > ?',
(ip, int(time.time() - window)))
rows = await _q('SELECT COUNT(*) AS n FROM users WHERE register_ip = ? AND created_at > ?',
(ip, int(time.time() - window)))
return bool(rows and rows[0]['n'] >= limit)
def get_user_by_id(uid: int) -> sqlite3.Row | None:
rows = _q('SELECT * FROM users WHERE id = ?', (uid,))
async def get_user_by_id(uid: int) -> sqlite3.Row | None:
rows = await _q('SELECT * FROM users WHERE id = ?', (uid,))
return rows[0] if rows else None
def verify_login(username: str, password: str) -> sqlite3.Row | None:
async def verify_login(username: str, password: str) -> sqlite3.Row | None:
"""校验用户名密码。密码正确即返回用户行(含被禁用/封禁账号——
状态语义由登录路由判断:禁用显示"已被禁用"、封禁显示封禁原因。
原实现把禁用账号提前过滤成 None,路由的"账号已被禁用"分支永不可达)"""
user = get_user_by_name(username)
user = await get_user_by_name(username)
if user:
ok, needs_upgrade = _verify_password(user['password_hash'], password, user['salt'])
# PBKDF2 校验是 CPU 密集(~50ms),卸载到线程避免阻塞事件循环(正当 CPU 卸载)
ok, needs_upgrade = await asyncio.to_thread(
_verify_password, user['password_hash'], password, user['salt'])
if ok:
if needs_upgrade:
# 存量 SHA-256 哈希在登录成功时透明升级为 PBKDF2
_x('UPDATE users SET password_hash = ?, salt = \'\' WHERE id = ?',
(_hash_new(password), user['id']))
new_hash = await asyncio.to_thread(_hash_new, password)
await _x('UPDATE users SET password_hash = ?, salt = \'\' WHERE id = ?',
(new_hash, user['id']))
return user
return None
def list_users() -> list[dict[str, Any]]:
rows = _q('SELECT * FROM users ORDER BY id DESC')
async def list_users() -> list[dict[str, Any]]:
rows = await _q('SELECT * FROM users ORDER BY id DESC')
return [_user_public(r, full=True) for r in rows]
def delete_user(uid: int) -> bool:
async def delete_user(uid: int) -> bool:
"""删除用户(卡密 used_by 为用户名字符串无外键——删除时同步
标注"(已注销#id)",管理端卡密列表不再显示悬空的已删用户名)"""
u = get_user_by_id(uid)
u = await get_user_by_id(uid)
if not u or u['role'] == 'admin':
return False
with _DB_LOCK:
conn = get_conn()
try:
name = u['username']
conn.execute('DELETE FROM users WHERE id = ? AND role != ?', (uid, 'admin'))
conn.execute("UPDATE cards SET used_by = used_by || ? WHERE used_by = ?",
(f'(已注销#{uid})', name))
conn.commit()
finally:
conn.close()
async with write_transaction() as conn:
name = u['username']
await conn.execute('DELETE FROM users WHERE id = ? AND role != ?', (uid, 'admin'))
await conn.execute("UPDATE cards SET used_by = used_by || ? WHERE used_by = ?",
(f'(已注销#{uid})', name))
return True
def reset_password(uid: int, new_password: str) -> bool:
async def reset_password(uid: int, new_password: str) -> bool:
"""重置密码(同时递增 login_seq,让所有已签发会话立即失效)"""
with _DB_LOCK:
conn = get_conn()
try:
conn.execute('UPDATE users SET password_hash = ?, salt = \'\', login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?',
(_hash_new(new_password), uid))
conn.commit()
return True
finally:
conn.close()
new_hash = await asyncio.to_thread(_hash_new, new_password)
await _x('UPDATE users SET password_hash = ?, salt = \'\', '
'login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?',
(new_hash, uid))
return True
def set_user_status(uid: int, status: int) -> bool:
async def set_user_status(uid: int, status: int) -> bool:
"""启用 / 禁用账号(1 启用,0 禁用);管理员账号不可禁用"""
if status not in (0, 1):
return False
row = _q('SELECT role FROM users WHERE id = ?', (uid,))
row = await _q('SELECT role FROM users WHERE id = ?', (uid,))
if row and row[0]['role'] == 'admin':
return False
return bool(_x('UPDATE users SET status = ? WHERE id = ?', (status, uid)))
return bool(await _x('UPDATE users SET status = ? WHERE id = ?', (status, uid)))
def ban_user(uid: int, reason: str = '') -> bool:
async def ban_user(uid: int, reason: str = '') -> bool:
"""封禁账号(管理员不可封)"""
row = _q('SELECT role FROM users WHERE id = ?', (uid,))
row = await _q('SELECT role FROM users WHERE id = ?', (uid,))
if not row or row[0]['role'] == 'admin':
return False
return bool(_x('UPDATE users SET status = 2, ban_reason = ? WHERE id = ?',
(str(reason or '')[:200], uid)))
return bool(await _x('UPDATE users SET status = 2, ban_reason = ? WHERE id = ?',
(str(reason or '')[:200], uid)))
def unban_user(uid: int) -> bool:
async def unban_user(uid: int) -> bool:
"""解封账号,恢复正常(清空触发封禁的省份历史,否则下次登录立即再封)"""
return bool(_x('UPDATE users SET status = 1, ban_reason = \'\', recent_provinces = \'[]\' WHERE id = ?', (uid,)))
return bool(await _x('UPDATE users SET status = 1, ban_reason = \'\', recent_provinces = \'[]\' WHERE id = ?', (uid,)))
def set_user_credits(uid: int, count: int) -> None:
async def set_user_credits(uid: int, count: int) -> None:
"""设置用户剩余次数(-1 表示不限)"""
count = -1 if count < 0 else int(count)
_x('UPDATE users SET duration_seconds = ? WHERE id = ?', (count, uid))
await _x('UPDATE users SET duration_seconds = ? WHERE id = ?', (count, uid))
def set_user_password(uid: int, password: str) -> None:
async def set_user_password(uid: int, password: str) -> None:
# 新哈希 + 递增 login_seq(所有旧会话立即失效)
with _DB_LOCK:
conn = get_conn()
try:
conn.execute('UPDATE users SET password_hash = ?, salt = \'\', '
'login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?',
(_hash_new(password), uid))
conn.commit()
finally:
conn.close()
new_hash = await asyncio.to_thread(_hash_new, password)
await _x('UPDATE users SET password_hash = ?, salt = \'\', '
'login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?',
(new_hash, uid))
def set_user_netease_cookie(username: str, cookie: str) -> None:
_x('UPDATE users SET netease_cookie = ? WHERE username = ?', (cookie, username))
async def set_user_netease_cookie(username: str, cookie: str) -> None:
await _x('UPDATE users SET netease_cookie = ? WHERE username = ?', (cookie, username))
def _user_public(r: sqlite3.Row, full: bool = False) -> dict[str, Any]:
@@ -249,8 +241,8 @@ def _last_province(r: sqlite3.Row) -> str:
# ============ 免费体验(设备识别) ============
def device_used(device_id: str) -> bool:
async def device_used(device_id: str) -> bool:
if not device_id:
return False
rows = _q("SELECT id FROM users WHERE trial_device = ? AND role != 'admin' LIMIT 1", (device_id,))
rows = await _q("SELECT id FROM users WHERE trial_device = ? AND role != 'admin' LIMIT 1", (device_id,))
return bool(rows)