This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/auth_system.py
T
snowy-yang 8446166120 refactor!: uv 管理环境 + 彻底删除技术债(Flask 与 requests 全部移除)
## uv 环境管理
- pyproject.toml 声明 dependencies + [dependency-groups].dev,uv.lock 精确锁定
- 删除 requirements.txt / requirements-fastapi.txt / requirements-dev.txt
- .python-version = 3.12;Dockerfile 改用 uv sync --frozen(可复现构建)
- 新增 jinja2(首页渲染)

## 删除 Flask 技术债
- 从 main.py 用 ast 精确剥离 56 个 Flask 节点(路由/中间件/错误处理/启动),
  框架无关的服务层落为 services/runtime.py(MusicAPIService、下载/打包 worker、
  任务监控、缓存清扫),零 Flask 依赖
- 删除 main.py(4527 行)与 auth_routes.py(1050 行)
- auth_system.py 移除会话/请求上下文层(login_session/current_user/login_required/
  admin_required/_client_ip 等),保留数据与业务层
- 新增 core/net.py(IP 解析)、web/auth_state.py(登录锁定/2FA 票据/发码限流)
- FastAPI 侧改直接依赖 services.runtime,不再 import main
- web/app.py 去掉 Flask fallback;入口默认根路径模式(API_PREFIX 可选)

## 删除 requests 技术债
- 新增 core/sync_http.py:requests 风格的同步门面(底层 aiohttp 连接池,
  流式读取经有界队列背压),含 Session/headers/(connect,read) 元组超时兼容
- core/utils、auth_system、podcast_api、music_downloader、services 全部切换
- 实测真实下载 5.2MB 音频完整成功

## 质量门禁归零
- ruff:0 错误(B904 全部修复,不再忽略)
- pyright:0 错误(类型收窄修复)
- pytest:140/140(含 golden 契约回归、root 模式 66 项路径覆盖)
- 真实 uvicorn 启动:/health、/、/static、受保护端鉴权全部正确

新增 tests/golden/responses.json:删除 Flask 前捕获的 32 例响应快照,
FastAPI 逐字段复现——删除巨石后行为回归仍有硬证据。
2026-09-26 11:10:17 +08:00

1544 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""站点认证与管理系统
提供网站级用户体系:
- 用户注册(邮箱验证码)/ 登录 / 登出 / 注册即赠下载次数(第 98 条口径:试用领取已并入注册)
- 按次计费(一首歌 = 1 次)、卡密(次数卡)充值
- 管理员后台:用户管理 / SMTP 配置 / 卡密生成 / 公告 / 日志 / 数据库备份
- 接口装饰器:login_required(强制登录 + 按次扣费)/ admin_required(仅管理员)
数据存储:SQLite(data/site.db),会话:Flask 签名 Cookie(secret_key 持久化)
"""
import base64
import hashlib
import json
import math
import os
import re
import secrets
import shutil
import smtplib
import sqlite3
import ssl
import sys
import threading
import time
from email.header import Header
from email.mime.text import MIMEText
from email.utils import formataddr
from pathlib import Path
from typing import Any
# ============ 路径与常量 ============
DATA_DIR = Path('data')
DB_PATH = DATA_DIR / 'site.db'
BACKUP_DIR = DATA_DIR / 'backups'
SECRET_KEY_PATH = DATA_DIR / 'secret.key'
DEFAULT_ADMIN_USER = 'admin'
SESSION_KEY = 'site_uid'
DEFAULT_REGISTER_CREDITS = 3 # 注册直接赠送 3 次下载次数(无需领取;TRIAL_CREDITS 死常量已删,第 98 条)
# F13:口令哈希升级。旧库 SHA-256(salt+pwd) 哈希在登录成功时透明升级为 PBKDF2
_PBKDF2_ITERS = 100_000
_DB_LOCK = threading.RLock()
# ============ 数据库 ============
def get_conn() -> sqlite3.Connection:
DATA_DIR.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(DB_PATH), timeout=15)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA journal_mode=WAL')
return conn
def _q(sql: str, params: tuple = ()) -> list[sqlite3.Row]:
"""单语句读:**不持锁**。
WAL 模式下每个连接读到一致性快照,读与写互不阻塞;旧实现对读也加 _DB_LOCK,
等于把全部读串行化并被写挡住。此处仅放开单语句读——写事务(_x 与各
`with _DB_LOCK` 块)仍全程串行,故不引入写-写竞争。
"""
conn = get_conn()
try:
return conn.execute(sql, params).fetchall()
finally:
conn.close()
def _x(sql: str, params: tuple = ()) -> int:
"""执行写操作,返回 lastrowid / rowcount"""
with _DB_LOCK:
conn = get_conn()
try:
cur = conn.execute(sql, params)
conn.commit()
return cur.lastrowid if cur.lastrowid else cur.rowcount
finally:
conn.close()
def init_db() -> None:
"""初始化表结构与默认数据"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
with _DB_LOCK:
conn = get_conn()
try:
conn.executescript('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
email TEXT DEFAULT '',
role TEXT DEFAULT 'user',
duration_seconds INTEGER DEFAULT 0,
trial_device TEXT DEFAULT '',
register_ip TEXT DEFAULT '',
register_ua TEXT DEFAULT '',
netease_cookie TEXT DEFAULT '',
ann_seen TEXT DEFAULT '',
last_active INTEGER DEFAULT 0,
created_at INTEGER DEFAULT 0,
last_login INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS email_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
code TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER DEFAULT 0,
purpose TEXT DEFAULT 'register'
);
CREATE TABLE IF NOT EXISTS cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
minutes INTEGER NOT NULL,
created_by TEXT DEFAULT '',
created_at INTEGER DEFAULT 0,
used_by TEXT DEFAULT '',
used_at INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT DEFAULT 'success',
username TEXT DEFAULT '',
ip TEXT DEFAULT '',
action TEXT DEFAULT '',
detail TEXT DEFAULT '',
time TEXT DEFAULT ''
);
''')
# 单行配置表
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)')
# 键值设置表
conn.execute('''CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, value TEXT DEFAULT ''
)''')
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)')
# 计费账本(F25/F26:任务实扣/交付/退款/终态的持久化唯一记录,结算幂等)
conn.execute('''CREATE TABLE IF NOT EXISTS billing_ledger (
task_id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
reserved INTEGER DEFAULT 0,
delivered INTEGER DEFAULT 0,
refunded INTEGER DEFAULT 0,
status TEXT DEFAULT 'open',
created_at INTEGER DEFAULT 0,
settled_at INTEGER DEFAULT 0
)''')
# 轻量迁移:新增列。仅"列已存在"可忽略(F93:其余迁移失败必须暴露,不能吞掉)
_already = 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():
_already.add(f"{trow['name']}.{crow['name']}")
for alter_sql, tbl_col in (
('ALTER TABLE users ADD COLUMN status INTEGER DEFAULT 1', 'users.status'),
('ALTER TABLE users ADD COLUMN login_seq INTEGER DEFAULT 0', 'users.login_seq'),
('ALTER TABLE users ADD COLUMN recent_provinces TEXT DEFAULT \'[]\'', 'users.recent_provinces'),
('ALTER TABLE users ADD COLUMN ban_reason TEXT DEFAULT \'\'', 'users.ban_reason'),
('ALTER TABLE cards ADD COLUMN note TEXT DEFAULT \'\'', 'cards.note'),
('ALTER TABLE cards ADD COLUMN expire_at INTEGER DEFAULT 0', 'cards.expire_at'),
('ALTER TABLE cards ADD COLUMN disabled INTEGER DEFAULT 0', 'cards.disabled'),
('ALTER TABLE email_codes ADD COLUMN purpose TEXT DEFAULT \'register\'', 'email_codes.purpose'),
):
if tbl_col in _already:
continue # 幂等:列已存在
try:
conn.execute(alter_sql)
except sqlite3.OperationalError as e:
if 'duplicate column' not in str(e).lower():
# 磁盘/锁/结构等真实故障:抛出,禁止静默带病初始化(F93)
raise
# P3-19:非空邮箱唯一(部分索引,不约束历史空串)。存量库若已有重复非空邮箱,
# 索引创建失败仅告警不阻断启动(查重逻辑在注册/建号路由另有兜底)
try:
conn.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty '
'ON users(email) WHERE email != \'\'')
except sqlite3.IntegrityError:
print('[警告] users.email 存在重复的非空邮箱,唯一索引未创建(请人工清理后重启)')
# 第 85 条:users.email 口径统一为归一化(lower + 剥 Gmail +tag)——与
# get_user_by_email/找回/邮箱登录的查询口径对齐。逐行迁移,归一化后与他
# 人撞唯一索引的行保留原值跳过(与"存量重复仅告警"同口径)。幂等:已
# 归一化的行零写入
for urow in conn.execute("SELECT id, email FROM users WHERE email != ''").fetchall():
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']))
except sqlite3.IntegrityError:
print(f"[警告] 用户 {urow['id']} 的邮箱归一化后与他人重复,保留原值")
# 初始管理员(F02:新装不再使用公开默认口令,随机生成并写入日志一次)
# R5(第 79/80 条):引导条件改为"不存在任何管理员角色"——按用户名查会在
# 管理员改名(如 admin→qfbsh)后误判缺失,重启即重建出一个新的 admin 账号
row = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
if not row:
initial_pass = secrets.token_urlsafe(12)
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())))
# P2-5:坑 11 口径——非 ASCII 输出须防 GBK 重定向下 UnicodeEncodeError
try:
print(f'[初始化] 已创建管理员账号 admin,初始密码:{initial_pass}(请立即登录并修改,本提示不再显示)')
except UnicodeEncodeError:
print('[init] admin account created, initial password printed above failed to encode')
conn.commit()
finally:
conn.close()
def get_secret_key() -> str:
"""获取(或生成)持久化的会话签名密钥(F102:原子写入 + 失败显式告警)"""
key = ''
try:
if SECRET_KEY_PATH.exists():
key = SECRET_KEY_PATH.read_text(encoding='utf-8').strip()
if key:
return key
except OSError:
pass
new_key = secrets.token_hex(32)
try:
SECRET_KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
# 先写临时文件再原子替换,避免部分写入留下损坏密钥
tmp = SECRET_KEY_PATH.with_suffix('.key.tmp')
tmp.write_text(new_key, encoding='utf-8')
os.replace(str(tmp), str(SECRET_KEY_PATH))
# 并发初始化者以"替换后文件里的值"为准(最后写入者胜出但全员一致)
final = SECRET_KEY_PATH.read_text(encoding='utf-8').strip()
return final or new_key
except OSError as e:
# 写入失败不能装作成功:显著告警,返回临时值(本进程会话可用,重启后失效)
try:
print(f'[警告] 会话密钥持久化失败({e}),本进程将使用临时密钥,重启后所有会话失效!', file=sys.stderr)
except UnicodeEncodeError:
print('[warn] secret key persistence failed; using temporary key for this process', file=sys.stderr)
return new_key
# ============ 密码与用户 ============
def _hash(password: str, salt: str) -> str:
"""旧版口令哈希:SHA-256(salt + password)。仅用于校验存量库,新哈希一律走 _hash_new"""
return hashlib.sha256((salt + password).encode('utf-8')).hexdigest()
def _hash_new(password: str, salt: str = '', iterations: int = _PBKDF2_ITERS) -> str:
"""新版口令哈希:PBKDF2-HMAC-SHA256(F13),格式 pbkdf2$iter$salt$digest"""
salt = salt or secrets.token_hex(8)
digest = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'),
salt.encode('utf-8'), iterations).hex()
return f'pbkdf2${iterations}${salt}${digest}'
def _verify_password(stored: str, password: str, salt: str) -> tuple[bool, bool]:
"""校验口令。返回 (是否正确, 是否需要升级到 PBKDF2)"""
if stored.startswith('pbkdf2$'):
try:
_, iters, psalt, digest = stored.split('$', 3)
calc = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'),
psalt.encode('utf-8'), int(iters)).hex()
return secrets.compare_digest(calc, digest), False
except Exception:
return False, False
ok = secrets.compare_digest(stored, _hash(password, salt))
return ok, ok # 旧哈希校验通过 → 登录时升级
def create_user(username: str, password: str, email: str = '', credits: int = 0,
role: str = 'user', device_id: str = '', ip: str = '', ua: str = '',
register_ip_limit: int = 0, register_ip_window: int = 86400,
register_device_once: bool = False) -> dict[str, Any]:
# F77:余额只接受 -1(不限)或 >= 0;非法负值一律按 0 处理,禁止出现 -2 这类"假不限"
try:
credits = int(credits)
except (TypeError, ValueError):
credits = 0
if credits < -1:
credits = 0
# R5(第 79 条):注册入口传 register_ip_limit=3 —— 同 IP 24h 注册数检查挪进
# _DB_LOCK 事务内与 INSERT 原子完成,堵住"锁外检查+并发插入"的 TOCTOU 刷号;
# 管理建号与不限制场景传 0 关闭。超限抛 ValueError 由调用方转 400/429。
now = int(time.time())
with _DB_LOCK:
conn = get_conn()
try:
if register_ip_limit > 0 and ip:
n = conn.execute('SELECT COUNT(*) AS n FROM users WHERE register_ip = ? AND created_at > ?',
(ip, now - register_ip_window)).fetchone()[0]
if n >= register_ip_limit:
raise ValueError('当前网络注册过于频繁,请 24 小时后再试或联系管理员')
if register_device_once and device_id:
# 第 100 条:设备赠送闸并入同一事务——锁外 device_used 预检有 TOCTOU,
# 并发同设备双注册可各得一次赠送;锁内复查命中则本次建号不赠送(账号照常创建)
n = conn.execute("SELECT COUNT(*) AS n FROM users WHERE trial_device = ? AND role != 'admin'",
(device_id,)).fetchone()[0]
if n > 0:
credits = 0
conn.execute(
'INSERT INTO users (username, password_hash, salt, email, role, duration_seconds, '
'trial_device, register_ip, register_ua, last_active, created_at) '
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
(username, _hash_new(password), '', email, role,
-1 if role == 'admin' else int(credits),
device_id, ip, ua, now, now))
conn.commit()
finally:
conn.close()
return get_user_by_name(username)
def get_user_by_name(username: str) -> sqlite3.Row | None:
rows = _q('SELECT * FROM users WHERE username = ?', (username,))
return rows[0] if rows else None
def get_user_by_email(email: str) -> sqlite3.Row | None:
"""按邮箱查用户(P3-19:注册/建号邮箱查重的友好提示;库侧另有唯一索引兜底并发)"""
email = _normalize_email(email)
if not email:
return None
rows = _q('SELECT * FROM users WHERE email = ? LIMIT 1', (email,))
return rows[0] if rows else None
def _normalize_email(email: str) -> str:
"""R5(第 79 条):邮箱归一化——剥离 Gmail 等服务商的 +tag 别名
(user+1@ 与 user@ 视为同一邮箱),堵住别名的无限刷号绕过;无 + 原样返回。
local 为空(如 '+a@gmail.com')时返回空串,由调用方按格式错误拒绝。"""
email = (email or '').strip().lower()
if '+' in email:
local, _, domain = email.partition('@')
local = local.split('+', 1)[0]
if local and domain:
email = f'{local}@{domain}'
elif not local:
return ''
return email
def register_ip_exceeded(ip: str, limit: int = 3, window: int = 86400) -> bool:
"""R5-P1:同 IP 24h 内注册数超限(默认 3 次)——防 IP 轮换刷免费赠送次数;
设备指纹可被客户端伪造,IP 维度是第二道闸。家庭 NAT 场景超限可由管理员建号兜底。"""
if not ip:
return False
rows = _q('SELECT COUNT(*) AS n FROM users WHERE register_ip = ? AND created_at > ?',
(ip, int(time.time() - window)))
return bool(rows and rows[0]['n'] >= limit)
def get_user_by_id(uid: int) -> sqlite3.Row | None:
rows = _q('SELECT * FROM users WHERE id = ?', (uid,))
return rows[0] if rows else None
def verify_login(username: str, password: str) -> sqlite3.Row | None:
"""校验用户名密码。密码正确即返回用户行(含被禁用/封禁账号——
状态语义由登录路由判断:禁用显示"已被禁用"、封禁显示封禁原因。
原实现把禁用账号提前过滤成 None,路由的"账号已被禁用"分支永不可达)"""
user = get_user_by_name(username)
if user:
ok, needs_upgrade = _verify_password(user['password_hash'], password, user['salt'])
if ok:
if needs_upgrade:
# F13:存量 SHA-256 哈希在登录成功时透明升级为 PBKDF2
_x('UPDATE users SET password_hash = ?, salt = \'\' WHERE id = ?',
(_hash_new(password), user['id']))
return user
return None
def list_users() -> list[dict[str, Any]]:
rows = _q('SELECT * FROM users ORDER BY id DESC')
return [_user_public(r, full=True) for r in rows]
def delete_user(uid: int) -> bool:
"""删除用户(第 93 条 P3-1:卡密 used_by 为用户名字符串无外键——删除时同步
标注"(已注销#id)",管理端卡密列表不再显示悬空的已删用户名)"""
u = get_user_by_id(uid)
if not u or u['role'] == 'admin':
return False
with _DB_LOCK:
conn = get_conn()
try:
name = u['username']
conn.execute('DELETE FROM users WHERE id = ? AND role != ?', (uid, 'admin'))
conn.execute("UPDATE cards SET used_by = used_by || ? WHERE used_by = ?",
(f'(已注销#{uid})', name))
conn.commit()
finally:
conn.close()
return True
def reset_password(uid: int, new_password: str) -> bool:
"""重置密码(F03:同时递增 login_seq,让所有已签发会话立即失效)"""
with _DB_LOCK:
conn = get_conn()
try:
conn.execute('UPDATE users SET password_hash = ?, salt = \'\', login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?',
(_hash_new(new_password), uid))
conn.commit()
return True
finally:
conn.close()
def set_user_status(uid: int, status: int) -> bool:
"""启用 / 禁用账号(1 启用,0 禁用);管理员账号不可禁用"""
if status not in (0, 1):
return False
row = _q('SELECT role FROM users WHERE id = ?', (uid,))
if row and row[0]['role'] == 'admin':
return False
return bool(_x('UPDATE users SET status = ? WHERE id = ?', (status, uid)))
def ban_user(uid: int, reason: str = '') -> bool:
"""封禁账号(管理员不可封)"""
row = _q('SELECT role FROM users WHERE id = ?', (uid,))
if not row or row[0]['role'] == 'admin':
return False
return bool(_x('UPDATE users SET status = 2, ban_reason = ? WHERE id = ?',
(str(reason or '')[:200], uid)))
def unban_user(uid: int) -> bool:
"""解封账号,恢复正常(F80:清空触发封禁的省份历史,否则下次登录立即再封)"""
return bool(_x('UPDATE users SET status = 1, ban_reason = \'\', recent_provinces = \'[]\' WHERE id = ?', (uid,)))
_PROVINCE_CACHE: dict[str, str] = {}
def _is_private_ip(ip: str) -> bool:
"""内网/回环地址判断(F47:172.16-31.* 才是私网,不能把整个 172.* 误判)"""
try:
import ipaddress
addr = ipaddress.ip_address(ip)
return addr.is_private or addr.is_loopback or addr.is_link_local
except ValueError:
return False
def query_ip_province(ip: str) -> str:
"""查询 IP 归属省份(内网/失败返回空串),带进程内缓存
F47:改用官方路径 /json/{query} 与 zh-CN 语言参数"""
ip = (ip or '').split(',')[0].strip()
if not ip or ip in ('-', '127.0.0.1', '0.0.0.0') or _is_private_ip(ip):
return ''
if ip in _PROVINCE_CACHE:
return _PROVINCE_CACHE[ip]
province = ''
try:
import urllib.parse as _ul
from core import sync_http as _rq
r = _rq.get('http://ip-api.com/json/' + _ul.quote(ip),
params={'lang': 'zh-CN', 'fields': 'status,regionName'},
timeout=5)
d = r.json()
if d.get('status') == 'success':
province = str(d.get('regionName') or '')
except Exception:
province = ''
# 简单容量上限,防止公网长期运行下无界增长
if len(_PROVINCE_CACHE) > 5000:
_PROVINCE_CACHE.clear()
# 第 99 条:查询失败(超时/限流/垃圾 JSON)的空省份不写缓存——旧口径一次
# ip-api 抖动窗口内登录的 IP 被永久标记"无省份",省份防倒卖静默失效
if province:
_PROVINCE_CACHE[ip] = province
return province
PROVINCE_WINDOW = 24 * 3600 # 省份变化检测窗口:24 小时
PROVINCE_BAN_LIMIT = 3 # 窗口内出现 >=3 个不同省份即封禁
def record_login_location(user: sqlite3.Row, ip: str) -> tuple:
"""记录本次登录省份并检测异常:24h 内出现 >=3 个不同省份自动封禁。
返回 (是否被封禁, 说明)"""
if user['role'] == 'admin':
return False, ''
province = query_ip_province(ip)
if not province:
return False, ''
now = int(time.time())
try:
history = json.loads(user['recent_provinces'] if 'recent_provinces' in user.keys() and user['recent_provinces'] else '[]')
except Exception:
history = []
history = [h for h in history if isinstance(h, dict) and now - int(h.get('t', 0)) <= PROVINCE_WINDOW]
history.append({'p': province, 't': now})
provinces = []
for h in history:
if h.get('p') and h['p'] not in provinces:
provinces.append(h['p'])
_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:])}),疑似账号倒卖,已自动封禁'
ban_user(user['id'], reason)
add_log('fail', user['username'], ip, '自动封禁', reason)
return True, reason
return False, province
def set_user_credits(uid: int, count: int) -> None:
"""设置用户剩余次数(-1 表示不限)"""
count = -1 if count < 0 else int(count)
_x('UPDATE users SET duration_seconds = ? WHERE id = ?', (count, uid))
def set_user_password(uid: int, password: str) -> None:
# F03/F13:新哈希 + 递增 login_seq(所有旧会话立即失效)
with _DB_LOCK:
conn = get_conn()
try:
conn.execute('UPDATE users SET password_hash = ?, salt = \'\', '
'login_seq = COALESCE(login_seq, 0) + 1 WHERE id = ?',
(_hash_new(password), uid))
conn.commit()
finally:
conn.close()
def set_user_netease_cookie(username: str, cookie: str) -> None:
_x('UPDATE users SET netease_cookie = ? WHERE username = ?', (cookie, username))
def _user_public(r: sqlite3.Row, full: bool = False) -> dict[str, Any]:
d = {
'id': r['id'],
'username': r['username'],
'email': r['email'],
'role': r['role'],
'duration_seconds': r['duration_seconds'],
'created_at': r['created_at'],
'last_login': r['last_login'],
'ann_seen': r['ann_seen'],
}
if full:
d.update({
'status': r['status'] if 'status' in r.keys() else 1,
'trial_device': r['trial_device'],
'register_ip': r['register_ip'],
'register_ua': r['register_ua'],
# P2-4:列表不再携带明文网易云 Cookie(前端只用布尔值;原文按需走
# /admin/site/user/cookie 单查接口,避免一次列表请求扩散全部用户凭据)
'has_netease_cookie': bool(r['netease_cookie']),
'ban_reason': r['ban_reason'] if 'ban_reason' in r.keys() else '',
'last_province': _last_province(r),
})
return d
def _last_province(r: sqlite3.Row) -> str:
try:
history = json.loads(r['recent_provinces'] if 'recent_provinces' in r.keys() and r['recent_provinces'] else '[]')
return history[-1].get('p', '') if history else ''
except Exception:
return ''
# ============ 使用次数(按次计费:一首歌 = 1 次) ============
def refund_credits(uid: int, count: int) -> int:
"""退回下载次数(批量打包失败部分自动退费)。返回剩余次数(-1 表示不限)"""
user = 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)
return (user['duration_seconds'] or 0) if user else 0
def charge_credits(uid: int, count: int = 1) -> int:
"""按次扣减使用次数(管理员与不限次数(-1)用户不扣减,返回 -1)。返回剩余次数
扣减在 _DB_LOCK 内"读取-判断-写入"一次完成,防止并发双击导致超扣/双花"""
user = 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))
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()
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()
def add_credits(username: str, count: int) -> int:
"""增加次数(卡密充值/试用领取),返回新剩余次数;不限次数(-1)用户保持不限
累加用 SQL 原生自增(原子),避免并发充值互相覆盖"""
user = 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)
return (user['duration_seconds'] or 0) if user else 0
def format_credits(count: int) -> str:
if count < 0:
return '不限次数'
if count <= 0:
return '已用完'
return f'{count} 次'
# ============ 计费账本(F25/F26:实扣、交付、退款、终态的持久化唯一记录) ============
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()
def reserve_and_open(uid: int, count: int, ledger_key: str) -> int | None:
"""A2/B8:预扣与开账同一事务(原两步之间存在崩溃丢账窗口)。
足额:扣减+开账并返回剩余;不限/管理员:开账(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(
'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:
"""A2/B8:把请求级临时账本行改名为正式 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()
def ledger_supplement(task_id: str, extra: int) -> bool:
"""整台播客等"先扣 1 后按实际数量补扣"场景(F21):
原子补扣用户余额并同步累加账本 reserved。任一步失败返回 False(调用方必须让任务失败,
不允许无账本继续交付)。余额不足同样返回 False。"""
task_id = str(task_id)
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()
if not row or row['status'] != 'open':
return False
urow = 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()
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),))
return dict(rows[0]) if rows else None
def ledger_settle(task_id: str, delivered: int, final_status: str) -> int:
"""任务计费结算(幂等):按账本实扣与实际交付计算应退次数,原子退款并落终态。
- delivered:本次任务实际交付给用户的数量(单曲/单集 0 或 1;批量为成功入包数)
- 重复调用/重启后再次调用:账本已非 open 直接返回 0,不重复退款
- admin/-1 提交(reserved=0):退款自然为 0(F26:不看结算时的账户状态)
- P3-1:以账本 reserved 为准计算应退;R5-10 口径更正:-1(不限次)账户不落余额
(加次数会破坏 -1 哨兵值),账本 refunded 记录应付口径,作废可接受
返回实际退款次数(落余额的部分;-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 记录的是"按账本应付"口径(该用户已有无限次数,作废可接受);
# R5-10:原注释"照样退"与实现相反,已更正为此口径。
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()
def ledger_recover_orphans(max_age_seconds: int = 2 * 3600) -> int:
"""启动对账(F26):任务表在内存,进程重启后账本中仍 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),))
for r in rows:
try:
refunded = ledger_settle(r['task_id'], 0, 'lost_restart')
if refunded:
add_log('success', 'system', 'system', '重启对账退款',
f'任务 {r["task_id"]} 因进程重启丢失,退回 {refunded} 次')
else:
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:
"""P3-20:清理已结算的历史账本行(每任务一行、只增不删会让表无限膨胀;
settled 状态行无对账价值,保留 90 天足够追溯)"""
try:
return _x('DELETE FROM billing_ledger WHERE status != \'open\' AND settled_at <= ?',
(int(time.time()) - int(max_age_seconds),))
except Exception:
return 0
# ============ 日志 ============
# 日志裁剪计数器(P3-2:原实现用 secrets.randbelow 触发——密码学随机数浪费熵池)
_LOG_WRITE_COUNTER = {'n': 0}
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')))
# 控制日志表体积(每 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)')
except Exception:
pass
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,))
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:
"""清除指定类型的全部日志,返回删除条数"""
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()
# ============ 邮箱验证码 / SMTP ============
# R5-P3:验证码猜错计数(内存态,重启清零可接受)——同邮箱累计错 5 次作废验证码
# 第 97 条(P3):键='purpose:任意邮箱' 无上限可被慢速灌大——超限清最旧
_CODE_FAILS: dict = {}
_CODE_FAILS_MAX = 5000
def _code_fails_trim() -> None:
"""超上限时按写入序丢弃最旧的一半(仅防内存无界,不承担安全语义)"""
if len(_CODE_FAILS) > _CODE_FAILS_MAX:
for k in list(_CODE_FAILS.keys())[:len(_CODE_FAILS) // 2]:
_CODE_FAILS.pop(k, None)
_SMTP_ENC_PREFIX = 'enc1:' # 第 95 条:信封加密标记前缀(存量明文读取时透明升级)
def _smtp_password_encrypt(plaintext: str) -> str:
"""用 data/secret.key 派生 Fernet 密钥加密 SMTP 授权码(第 95 条 P2-7:
原明文落库+随备份导出=泄露半径扩大;注释编号笔误已按第 97 条纠正)。"""
import hashlib as _hl
from cryptography.fernet import Fernet
key = _hl.sha256(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:
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()
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')
cfg = dict(rows[0]) if rows else {'host': '', 'port': 587, 'sender': '', 'password': ''}
# 第 95 条:读取时解密密码——调用方(SMTP 连接/后台展示)拿到的始终是明文
if cfg.get('password'):
try:
cfg['password'] = _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())))
def smtp_configured() -> bool:
cfg = get_smtp_config()
return bool(cfg['host'] and cfg['sender'] and cfg['password'])
def _smtp_connect() -> smtplib.SMTP:
cfg = get_smtp_config()
port = int(cfg['port'] or 587)
# F15:显式校验证书与主机名的 TLS 上下文(不依赖各运行时默认值)
ctx = ssl.create_default_context()
if port == 465:
server = smtplib.SMTP_SSL(cfg['host'], port, timeout=15, context=ctx)
else:
server = smtplib.SMTP(cfg['host'], port, timeout=15)
server.starttls(context=ctx)
server.login(cfg['sender'], cfg['password'])
return server
def send_email(to: str, subject: str, body: str) -> None:
"""R5-P3:收件人格式校验下沉到本入口——未来任何新调用方漏校验也不再成为
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()
msg = MIMEText(body, 'plain', 'utf-8')
msg['Subject'] = Header(subject, 'utf-8')
msg['From'] = formataddr(('云村小助手', cfg['sender']))
msg['To'] = to
with _smtp_connect() as server:
server.sendmail(cfg['sender'], [to], msg.as_string())
_EMAIL_SENT_24H: dict = {} # R5-F2:同邮箱 24h 发送计数(内存态,重启清零可接受)
# 第 85 条:验证码用途隔离——注册码不能用于找回密码,反之亦然(库行带 purpose,
# 消费/作废/还原均按用途匹配)。邮件主题与正文按用途区分,用户一眼可辨。
# 第 86 条:新增 admin2fa(管理员两步验证)——发码前置条件是密码已验证正确,
# 故豁免 24h 5 封与 60s 冷却(否则管理员每天最多登录 5 次会被自己锁死);
# 滥用面由"票据仅密码正确时签发 + 票据尝试上限 + 登录限流"约束
_CODE_PURPOSES = ('register', 'reset', 'admin2fa')
_CODE_EMAIL_TEMPLATES = {
'register': ('云村小助手 注册验证码',
'您正在注册云村小助手账号。\n\n'
'验证码:{code}\n\n'
'验证码 10 分钟内有效,请勿泄露给他人。\n'
'如非本人操作,请忽略本邮件。'),
'reset': ('云村小助手 找回密码验证码',
'您正在找回云村小助手账号的登录密码。\n\n'
'验证码:{code}\n\n'
'验证码 10 分钟内有效,仅用于重置密码,请勿泄露给他人。\n'
'如非本人操作,请忽略本邮件(您的密码不会被修改)。'),
'admin2fa': ('云村小助手 管理员登录验证码',
'您正在登录云村小助手管理员账号(两步验证第二步)。\n\n'
'验证码:{code}\n\n'
'验证码 10 分钟内有效,请勿泄露给任何人。\n'
'如非本人操作,请立即修改管理员密码并检查邮箱安全。'),
}
def send_verify_code(email: str, purpose: str = 'register') -> None:
"""生成并发送邮箱验证码(10 分钟有效)(F14:先发信成功再落库;同用途旧码即时作废)。
R5(第 79 条):① 同邮箱 24h 硬上限 5 封(防邮件轰炸代理/发信配额消耗);
② email 先归一化(Gmail +tag 别名剥离)——别名刷号被邮箱查重堵死。
第 85 条:purpose 区分注册/找回(文案不同 + 库行用途隔离);24h 计数移到
发信成功之后(原实现 SMTP 失败也烧掉额度)。"""
if purpose not in _CODE_PURPOSES:
raise ValueError('验证码用途不正确')
email = _normalize_email(email or '')
# 第 93 条(P3):re.ASCII——\w 的 Unicode 语义会放行"中文邮箱@例子.com"这类
# 非 ASCII 地址(SMTP 阶段才失败,路由兜底成 500);加 254 长度上限(RFC 口径,
# 原实现 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():
raise RuntimeError('邮箱服务未配置,请联系管理员')
# R5-F2:同邮箱 24h ≤5 封(内存计数,不再依赖会被消费 DELETE 清零的 DB 行数);
# admin2fa 豁免 24h 上限与 60s 冷却(见 _CODE_PURPOSES 处注释)
now_ts = time.time()
sent = [t for t in _EMAIL_SENT_24H.get(email, []) if now_ts - t < 86400]
if purpose != 'admin2fa' and len(sent) >= 5:
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))
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))
if purpose != 'admin2fa':
sent.append(now_ts)
_EMAIL_SENT_24H[email] = sent
# 第 97 条(P3):键只增不减(旧时间戳被过滤但空键留存)——顺手剔除过期
# 空键并做容量上限,防慢速灌任意格式邮箱造成无界增长
if len(_EMAIL_SENT_24H) > 5000:
for k in [k for k, v in _EMAIL_SENT_24H.items() if not v][:2500]:
_EMAIL_SENT_24H.pop(k, 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()
def check_verify_code(email: str, code: str, purpose: str = 'register') -> int:
"""校验验证码(F14:单条 DELETE 原子消费,rowcount=1 才算通过,杜绝并发双读)。
成功返回剩余有效秒数(真值),失败返回 0。
R5-P3:同一邮箱累计错 5 次→该用途验证码作废(防 10 分钟窗口内挂机猜码)。
第 85 条:WHERE 带 purpose——注册码与找回码互不可用。"""
if purpose not in _CODE_PURPOSES:
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。
# 第 99 条:仅"不支持 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()
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
return 0
def restore_verify_code(email: str, code: str, ttl_seconds: int, purpose: str = 'register') -> None:
"""注册流程后续步骤失败时还原已消费的验证码(F14 第二轮:建号失败不应吞掉验证码)"""
if ttl_seconds <= 0:
return
if purpose not in _CODE_PURPOSES:
return
email = _normalize_email(email or '')
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()
# ============ 卡密 ============
def generate_cards(count: int, credits: int, created_by: str = '',
prefix: str = '', note: str = '', valid_days: int = 0) -> list[str]:
"""生成次数卡密(credits = 每张卡可下载次数)
prefix: 自定义卡密前缀(默认 VIP-)
note: 备注信息
valid_days: 有效天数(0 = 永久有效,过期后不可兑换)
"""
count = max(1, min(int(count), 500))
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()
return codes
def list_cards(limit: int = 100, offset: int = 0) -> list[dict[str, Any]]:
"""卡密列表(F85:支持分页 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')
total = int(total_row[0]['c']) if total_row else 0
now = time.time()
cards = []
for r in rows:
d = dict(r)
# 过期判定以服务端时钟为准(前端时钟偏差会误标已过期/未使用)
d['expired'] = bool(d.get('expire_at') and d['expire_at'] < now)
cards.append(d)
return cards, total
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()
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()
def redeem_card(code: str, username: str) -> tuple[int, int]:
"""兑换卡密,返回 (卡密次数, 新剩余次数)(F25:标记已用与加余额同事务,失败整体回滚不吞卡)"""
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()
# ============ 公告 ============
def get_announcement() -> dict[str, Any]:
rows = _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:
if popup_mode not in ('off', 'every', 'once'):
popup_mode = 'off'
# F81:版本号精确到毫秒,同秒多次保存也能区分(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))
# ============ 设置(键值) ============
def get_setting(key: str, default: str = '') -> str:
rows = _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)))
# ============ 数据库备份 ============
# 备份命名专用锁:create_backup 不再持有全局 _DB_LOCK(不阻塞写请求),
# 但同秒并发的两次备份仍需保证文件名唯一(F44)
_BACKUP_NAME_LOCK = threading.Lock()
def create_backup() -> str:
"""创建备份(F44:唯一文件名——同秒冲突自动追加序号,不再互相覆盖)。
WAL 模式下用独立只读连接走 SQLite 在线备份 API:拿到的是一致性快照,
且不持有 _DB_LOCK——原实现备份期间阻塞全站写请求,大库时会卡住所有用户。"""
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
with _BACKUP_NAME_LOCK:
base = 'backup_' + time.strftime('%Y%m%d_%H%M%S')
name = base + '.db'
k = 1
while (BACKUP_DIR / name).exists():
name = f'{base}-{k}.db'
k += 1
# 占位创建目标文件(锁内),保证并发下同秒命名仍唯一
dest = sqlite3.connect(str(BACKUP_DIR / name))
conn = get_conn()
try:
try:
conn.backup(dest)
finally:
dest.close()
# 第 93 条(P2-3):任何备份产生后统一裁剪(手动/自动/上传同配额)
try:
prune_backups(20, 5)
except Exception:
pass
return name
except Exception:
# 备份失败:清掉占位文件,不留半个坏备份
try:
(BACKUP_DIR / name).unlink(missing_ok=True)
except OSError:
pass
raise
finally:
conn.close()
def list_backups() -> list[dict[str, Any]]:
"""备份列表(F44:按文件修改时间倒序——按名字排序会让 upload_ 永远排前,误删新自动备份)"""
if not BACKUP_DIR.exists():
return []
out = []
for p in BACKUP_DIR.glob('*.db'):
st = p.stat()
out.append({'name': p.name, 'size': st.st_size,
'time': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(st.st_mtime)),
'_mtime': st.st_mtime})
out.sort(key=lambda x: x['_mtime'], reverse=True)
for o in out:
o.pop('_mtime', None)
return out
def backup_path(name: str) -> Path | None:
name = Path(name).name # 防目录穿越
p = BACKUP_DIR / name
return p if p.exists() and p.suffix == '.db' else None
def _backup_is_valid_db(src: Path) -> bool:
"""深度校验备份文件(F43):SQLite 头 + 能真正打开 + 含 users 表,三者缺一不可"""
try:
with open(src, 'rb') as f:
if f.read(16) != b'SQLite format 3\x00'[:16]:
return False
probe = sqlite3.connect(f'file:{src}?mode=ro', uri=True)
try:
tables = {r[0] for r in probe.execute("SELECT name FROM sqlite_master WHERE type='table'")}
return 'users' in tables
finally:
probe.close()
except Exception:
return False
# S5:恢复前静止钩子——由应用层注册(取消全部在途下载任务),避免 worker 按旧库账目结算
_before_restore_hook = None
def register_before_restore_hook(fn) -> None:
global _before_restore_hook
_before_restore_hook = fn
def restore_backup(name: str) -> None:
"""恢复备份(F43:先验证、留回滚、后替换、失败自动回滚,源备份文件保留不移动)
S5:恢复前先通过钩子取消全部在途任务(worker 不再引用旧库用户结算)"""
src = backup_path(name)
if not src:
raise ValueError('备份文件不存在')
if not _backup_is_valid_db(src):
raise ValueError('不是有效的站点数据库备份(校验失败,未做任何修改)')
if _before_restore_hook is not None:
try:
_before_restore_hook()
except Exception:
pass
safety = BACKUP_DIR / ('pre_restore_' + time.strftime('%Y%m%d_%H%M%S') + '.db')
with _DB_LOCK:
# 0) 先尽力清 WAL 并合并进主库(被占用导致 checkpoint 忙时明确失败,不假装成功);
# 必须在留安全副本之前做——否则副本缺 WAL 里未合并的最近提交,回滚点不完整
conn = get_conn()
try:
row = 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_PATH), str(safety))
# 2) 源备份复制到临时文件(不动源备份本身)
tmp = DB_PATH.with_suffix('.db.restore_tmp')
shutil.copy2(str(src), str(tmp))
# 3) 原子替换 + 立即校验
os.replace(str(tmp), str(DB_PATH))
try:
init_db()
except Exception:
# 替换后初始化失败:回滚安全副本,恢复原库
shutil.copy2(str(safety), str(DB_PATH))
init_db()
raise RuntimeError('恢复后数据库初始化失败,已自动回滚到恢复前状态') from None
# 第 94 条(P3-4):裁剪移出 _DB_LOCK——glob/unlink 的 I/O 不阻塞全站写
try:
prune_backups(20, 5)
except Exception:
pass
def delete_backup(name: str) -> bool:
p = backup_path(name)
# 第 93 条(P2-3):允许删除 pre_restore_ 安全副本(配额裁剪已保证至少保留
# 最近 5 份;原实现一刀切拒删导致恢复频繁时无界累积且管理员手动也删不掉)
if p:
p.unlink()
return True
return False
# 自动备份线程
_backup_thread_started = False
_BACKUP_ERR_TS = {'ts': 0.0, 'n': 0} # 备份失败限频记录(P2-6:失败不能零痕迹)
def prune_backups(keep_normal: int = 20, keep_pre_restore: int = 5) -> None:
"""备份配额裁剪(第 93 条 P2-3:从自动备份循环抽出为独立函数)——
非pre_restore 保留最近 N 份、pre_restore_ 安全副本保留最近 M 份。
在 create_backup/上传备份/恢复备份后统一调用(原实现只在自动备份循环里
裁剪,手动备份/上传/恢复会让 data/backups 无界累积)。"""
baks = [b for b in list_backups() if not b['name'].startswith('pre_restore_')]
for b in baks[keep_normal:]:
try:
delete_backup(b['name'])
except Exception:
pass
pre = [b for b in list_backups() if b['name'].startswith('pre_restore_')]
for b in pre[keep_pre_restore:]:
try:
(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)
# F46:非有限值(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 份(F44:按修改时间,不按文件名)
# S7:pre_restore_* 安全副本不计入 20 份配额,仅各自保留最近 5 份
prune_backups(20, 5)
except Exception:
# P2-6:定时备份失败绝不能静默(磁盘满/权限问题时 owner 以为有备份、
# 出事时没有)——限频告警,每 10 分钟最多一条(对齐监控线程口径)
import traceback as _tb
_now = time.time()
_BACKUP_ERR_TS['n'] += 1
if _now - _BACKUP_ERR_TS['ts'] > 600:
print(f'[警告] 自动备份失败(近 10 分钟累计 {_BACKUP_ERR_TS["n"]} 次): '
f'{_tb.format_exc()[-400:]}', file=sys.stderr)
_BACKUP_ERR_TS['ts'] = _now
_BACKUP_ERR_TS['n'] = 0
time.sleep(600)
def start_auto_backup() -> None:
global _backup_thread_started
if _backup_thread_started:
return
t = threading.Thread(target=_auto_backup_loop, daemon=True)
t.start()
# F44:仅在真实启动成功后置位,失败可重试
_backup_thread_started = True
# ============ 免费体验(设备识别) ============
def device_used(device_id: str) -> bool:
if not device_id:
return False
rows = _q("SELECT id FROM users WHERE trial_device = ? AND role != 'admin' LIMIT 1", (device_id,))
return bool(rows)