Archived
为什么:文件 IO 这条线上 to_thread 用得过宽。凡是「单文件 exists / unlink / stat」 这类微秒级元数据操作,线程调度开销(几十微秒起)比操作本身还大,套 to_thread 只是 给同步调用加了层异步外壳,不产生任何并发收益;真正需要卸载的是整库复制、ZIP 整段 拷贝、SQLite 探测这类活。本批按这个口径把两端都摆正,并顺手修掉一处真实缺陷。 改动: - web/routers/admin_site.py 备份上传:原 `content = await f.read()` 会把整库一次性 读进内存(端点对外无大小上限,等于给了上传方一个内存放大器),落盘后还要把文件 重新打开读一次头。改为 _save_upload_stream 按 1 MiB 逐块搬运——内存占用恒为一块, 文件头在写第一块时就地截获(省掉那趟回读)。仍属阻塞 IO,故保留在 to_thread 里。 - web/routers/admin_site.py backup_list:撤掉仅为一次 stat 而拆出的嵌套函数 + to_thread,直接在事件循环里做完。 - web/routers/admin.py:save_cookie / logout / _get_cookie_info 四处 to_thread 改 直接调用——它们读写 1KB 级的 cookie.txt,全程不碰网络。 - music_api/qr_client.py:logout() 的 print 改 logger。该方法既被 CLI 也被 admin 端点调用,在 web 语境里 print 会把内容直接喷到服务端 stdout,绕过 logging 配置、 无法被收集,也污染容器日志。 - auth_system/backup.py:三处单文件 unlink(占位文件回滚、删备份、裁剪 pre_restore_) 改直接同步;把只为配合 to_thread 而拆出的 _backup_path_sync 并回 backup_path。 保留在线程里的:整库 copy2 / os.replace、glob + N 次 stat、SQLite 探测、以及持 跨线程锁的 _reserve_backup_path(内联会让事件循环等一把别的线程可能持有的锁)。 - web/routers/delivery.py:两处 os.path.exists 改直接同步(带 ASYNC240 行内豁免, 与 core/db.py、auth_system/db.py 同一写法),顺带删掉随之未使用的 asyncio 导入。 - pyproject.toml / uv.lock:移除 aiofiles。它全仓零引用,且底层同样是 to_thread—— 对 1KB 的 cookie 文件不会带来任何真异步收益。 验证: - uv run ruff check . → All checks passed! - uv run pyright → 0 errors, 0 warnings, 0 informations - uv run pytest → 392 项,0 失败 / 0 error / 0 skipped (含 test_admin_backup_upload 四条:接受真实 SQLite / 缺字段拒 / 后缀拒 / 后缀对但内容非库必须拒且不留半成品) - data/site.db sha256 前后一致(a19f5dbf…6487),无 -wal/-shm 残留
573 lines
23 KiB
Python
573 lines
23 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""管理后台(/admin/site/*)端点。
|
||
|
||
鉴权:与 auth_system.admin_required 同口径(403 + error_code='forbidden')。
|
||
|
||
说明:这些端点多为对 auth_system 的薄封装。auth_system 已迁移为**原生异步**,
|
||
故直接 await。PBKDF2 校验属 CPU 密集且 CPython 在迭代里**释放 GIL**,是这里唯一
|
||
真正值得占用线程的活(唯有 to_thread 下它才是真并行)。本地文件操作的归位口径是
|
||
「重活留线程、微秒级直接同步」:备份上传是整库体量,按块流式落盘、仍走 to_thread;
|
||
stat / unlink 这类元数据操作,线程调度开销比操作本身还大,直接在事件循环里做完。
|
||
操作者名取当前管理员用户名。
|
||
"""
|
||
import asyncio
|
||
import math
|
||
import re
|
||
import secrets
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Annotated, Any, BinaryIO
|
||
|
||
from fastapi import APIRouter, Depends, Request
|
||
from fastapi.responses import FileResponse
|
||
from starlette.datastructures import UploadFile
|
||
|
||
import auth_system as auth
|
||
from web.deps import SiteUser, current_user
|
||
from web.errors import AppError
|
||
from web.response import error, success
|
||
|
||
router = APIRouter()
|
||
|
||
_RESERVED_USERNAMES = {'admin', 'administrator', 'qfbsh', 'root', 'system', 'moderator', '管理员', '客服'}
|
||
_EMAIL_RE = re.compile(r'^[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}$')
|
||
|
||
# 备份上传的搬运块大小。上传的是**整库备份**,原实现 `content = await f.read()`
|
||
# 会把整库一次性读进内存(本端点对外没有大小上限),等于给上传方一个内存放大器;
|
||
# 改成逐块搬运后,内存占用恒为一块。
|
||
_UPLOAD_CHUNK = 1 << 20
|
||
|
||
_SQLITE_MAGIC = b'SQLite format 3'
|
||
|
||
|
||
def _save_upload_stream(src: BinaryIO, dest: Path) -> bytes:
|
||
"""把上传流按块写进 dest,并返回文件头(前 16 字节)。
|
||
|
||
**阻塞文件 IO**:只应由 asyncio.to_thread 调用。文件头在写第一块时就地截获,
|
||
省掉「落盘后再把文件重新打开读一次」的那趟回读。
|
||
"""
|
||
src.seek(0)
|
||
header = b''
|
||
with dest.open('wb') as out:
|
||
while chunk := src.read(_UPLOAD_CHUNK):
|
||
if len(header) < 16:
|
||
header += chunk[:16 - len(header)]
|
||
out.write(chunk)
|
||
return header
|
||
|
||
|
||
async def _require_admin(user: Annotated[SiteUser | None, Depends(current_user)]) -> SiteUser:
|
||
if user is None or not user.is_admin:
|
||
raise AppError('需要管理员权限', 403, error_code='forbidden')
|
||
return user
|
||
|
||
|
||
AdminUser = Annotated[SiteUser, Depends(_require_admin)]
|
||
|
||
|
||
def _username_ok(username: str) -> bool:
|
||
return bool(re.fullmatch(r'[A-Za-z0-9_\u4e00-\u9fa5]{3,20}', username)) and \
|
||
not re.match(r'^_+$', username) and not username.startswith('_') and \
|
||
not username.endswith('_') and username.lower() not in _RESERVED_USERNAMES
|
||
|
||
|
||
async def _request_data(request: Request) -> dict[str, Any]:
|
||
data: dict[str, Any] = {}
|
||
if request.method == 'GET':
|
||
data.update(dict(request.query_params))
|
||
return data
|
||
ctype = (request.headers.get('content-type') or '').lower()
|
||
if 'multipart/form-data' in ctype:
|
||
return data # 文件上传单独处理 Form
|
||
try:
|
||
body = await request.json()
|
||
if isinstance(body, dict):
|
||
data.update(body)
|
||
except Exception:
|
||
pass
|
||
if not data:
|
||
try:
|
||
form = await request.form()
|
||
data.update(dict(form.items()))
|
||
except Exception:
|
||
pass
|
||
return data
|
||
|
||
|
||
def _int_or(value: Any, default: int = 0) -> int:
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
async def _log(admin: SiteUser, action: str, detail: str = '') -> None:
|
||
await auth.add_log('success', admin.username, '-', action, detail)
|
||
|
||
|
||
# ============ 用户管理 ============
|
||
|
||
@router.post('/admin/site/users')
|
||
async def users(_admin: AdminUser):
|
||
rows = await auth.list_users()
|
||
return success({'users': rows})
|
||
|
||
|
||
@router.post('/admin/site/user/delete')
|
||
async def user_delete(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
uid = _int_or(data.get('id'))
|
||
try:
|
||
target = await auth.get_user_by_id(uid)
|
||
ok = await auth.delete_user(uid)
|
||
if not ok:
|
||
return error('用户不存在或为管理员')
|
||
await _log(admin, '删除用户', target['username'] if target else str(uid))
|
||
return success(message='用户已删除')
|
||
except Exception as e:
|
||
return error(f'删除失败: {e!s}', 500)
|
||
|
||
|
||
@router.post('/admin/site/user/duration')
|
||
async def user_duration(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
uid = _int_or(data.get('id'))
|
||
raw_count = data.get('count')
|
||
if raw_count is None:
|
||
raw_count = data.get('minutes') or 0
|
||
try:
|
||
count = int(float(raw_count))
|
||
except (TypeError, ValueError, OverflowError):
|
||
return error('次数必须为数字')
|
||
if count < -1:
|
||
return error('次数不能小于 -1(-1 表示不限次数)')
|
||
try:
|
||
target = await auth.get_user_by_id(uid)
|
||
if not target:
|
||
return error('用户不存在')
|
||
if target['role'] == 'admin':
|
||
return error('管理员不限次数')
|
||
await auth.set_user_credits(uid, count)
|
||
await _log(admin, '修改用户次数', f'{target["username"]} → {count} 次')
|
||
return success(message=f'已修改为 {count} 次')
|
||
except Exception as e:
|
||
return error(f'修改失败: {e!s}', 500)
|
||
|
||
|
||
@router.post('/admin/site/user/create')
|
||
async def user_create(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
username = str(data.get('username') or '').strip()
|
||
password = str(data.get('password') or '')
|
||
email = auth._normalize_email(str(data.get('email') or ''))
|
||
try:
|
||
credits = int(float(data.get('count') or 0))
|
||
except (TypeError, ValueError, OverflowError):
|
||
return error('次数必须为数字')
|
||
if not math.isfinite(credits):
|
||
return error('次数必须为有限数字')
|
||
if credits < -1:
|
||
return error('次数不能小于 -1(-1 表示不限次数)')
|
||
if not _username_ok(username):
|
||
if username.lower() in _RESERVED_USERNAMES:
|
||
return error('该用户名为系统保留名,不可使用')
|
||
return error('用户名需 3-20 位,仅支持中文、字母、数字、下划线,且不能纯下划线')
|
||
if len(password) < 8 or len(password) > 128:
|
||
return error('密码长度需在 8-128 位之间')
|
||
try:
|
||
if await auth.get_user_by_name(username):
|
||
return error('用户名已存在')
|
||
if email and await auth.get_user_by_email(email):
|
||
return error('该邮箱已被注册,请更换邮箱')
|
||
await auth.create_user(username, password, email=email, credits=credits,
|
||
guard=auth.RegistrationGuard(ip='-', ua='admin创建'))
|
||
await _log(admin, '添加用户', f'{username} · {credits} 次')
|
||
return success(message=f'用户 {username} 创建成功({credits} 次)')
|
||
except Exception as e:
|
||
return error(f'创建失败: {e!s}', 500)
|
||
|
||
|
||
@router.post('/admin/site/user/reset_password')
|
||
async def user_reset_password(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
uid = _int_or(data.get('id'))
|
||
newpwd = str(data.get('password') or '')
|
||
if len(newpwd) < 8 or len(newpwd) > 128:
|
||
return error('新密码长度需在 8-128 位之间')
|
||
try:
|
||
target = await auth.get_user_by_id(uid)
|
||
if not target:
|
||
return error('用户不存在')
|
||
if target['role'] == 'admin':
|
||
if admin.id != int(target['id']):
|
||
return error('不能对其他管理员账号执行该操作')
|
||
oldpwd = str(data.get('old_password') or '')
|
||
ok, _ = await asyncio.to_thread(auth._verify_password,
|
||
target['password_hash'], oldpwd, target['salt'])
|
||
if not ok:
|
||
return error('原密码错误')
|
||
await auth.reset_password(uid, newpwd)
|
||
await _log(admin, '修改自己的密码', '管理员')
|
||
return success(message='密码已修改,下次登录请使用新密码(其他设备已全部下线)')
|
||
await auth.reset_password(uid, newpwd)
|
||
await _log(admin, '重置密码', target['username'])
|
||
return success(message=f'已重置 {target["username"]} 的密码')
|
||
except Exception as e:
|
||
return error(f'重置失败: {e!s}', 500)
|
||
|
||
|
||
@router.post('/admin/site/user/toggle')
|
||
async def user_toggle(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
uid = _int_or(data.get('id'))
|
||
try:
|
||
target = await auth.get_user_by_id(uid)
|
||
if not target:
|
||
return error('用户不存在')
|
||
if target['role'] == 'admin':
|
||
return error('不能对管理员账号执行该操作')
|
||
cur = target['status'] if 'status' in target.keys() else 1
|
||
new_status = 0 if cur else 1
|
||
await auth.set_user_status(uid, new_status)
|
||
await _log(admin, '禁用/启用',
|
||
f'{target["username"]} → {"启用" if new_status else "禁用"}')
|
||
return success({'status': new_status},
|
||
message=f'已{"启用" if new_status else "禁用"} {target["username"]}')
|
||
except Exception as e:
|
||
return error(f'操作失败: {e!s}', 500)
|
||
|
||
|
||
@router.post('/admin/site/user/ban')
|
||
async def user_ban(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
uid = _int_or(data.get('id'))
|
||
action = str(data.get('action') or 'ban')
|
||
reason = str(data.get('reason') or '')
|
||
try:
|
||
row = await auth.get_user_by_id(uid)
|
||
if not row:
|
||
return error('用户不存在')
|
||
if row['role'] == 'admin':
|
||
return error('不能对管理员执行该操作')
|
||
if action == 'ban':
|
||
if not await auth.ban_user(uid, reason or '管理员手动封禁'):
|
||
return error('封禁失败')
|
||
await _log(admin, '封禁用户', f'{row["username"]}: {reason or "手动封禁"}')
|
||
return success(message=f'已封禁用户「{row["username"]}」,该账号已强制下线')
|
||
if not await auth.unban_user(uid):
|
||
return error('解封失败')
|
||
await _log(admin, '解封用户', row['username'])
|
||
return success(message=f'已解封用户「{row["username"]}」')
|
||
except Exception as e:
|
||
return error(f'操作失败: {e!s}', 500)
|
||
|
||
|
||
@router.post('/admin/site/user/cookie')
|
||
async def user_cookie(_admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
username = str(data.get('username') or '')
|
||
user = await auth.get_user_by_name(username)
|
||
if not user:
|
||
return error('用户不存在', 404)
|
||
return success({'username': username, 'cookie': user['netease_cookie'] or ''})
|
||
|
||
|
||
# ============ SMTP ============
|
||
|
||
@router.post('/admin/site/smtp/get')
|
||
async def smtp_get(_admin: AdminUser):
|
||
cfg = await auth.get_smtp_config()
|
||
configured = bool(cfg.get('host') and cfg.get('sender') and cfg.get('password'))
|
||
return success({'host': cfg.get('host', ''), 'port': cfg.get('port', ''),
|
||
'sender': cfg.get('sender', ''), 'configured': configured})
|
||
|
||
|
||
@router.post('/admin/site/smtp/save')
|
||
async def smtp_save(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
host = str(data.get('host') or '').strip()
|
||
try:
|
||
port = int(data.get('port') or 587)
|
||
except (TypeError, ValueError):
|
||
return error('端口必须为数字')
|
||
sender = str(data.get('sender') or '').strip()
|
||
password = str(data.get('password') or '')
|
||
if not host or not sender:
|
||
return error('SMTP 服务器与发件邮箱不能为空')
|
||
try:
|
||
if not password:
|
||
cfg = await auth.get_smtp_config()
|
||
password = cfg.get('password', '')
|
||
await auth.save_smtp_config(host, port, sender, password)
|
||
await _log(admin, '保存SMTP配置', f'{host}:{port}')
|
||
return success(message='SMTP 配置已保存')
|
||
except Exception as e:
|
||
return error(f'保存失败: {e!s}', 500)
|
||
|
||
|
||
@router.post('/admin/site/smtp/test')
|
||
async def smtp_test(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
to = str(data.get('to') or '').strip()
|
||
if not to:
|
||
return error('请填写测试收件邮箱')
|
||
if not _EMAIL_RE.match(to):
|
||
return error('收件邮箱格式不正确')
|
||
try:
|
||
await auth.send_email(to, 'SMTP 测试邮件',
|
||
'这是一封来自云村小助手的测试邮件,收到即说明 SMTP 配置成功。')
|
||
await _log(admin, 'SMTP测试', to)
|
||
return success(message='测试邮件已发送,请查收')
|
||
except Exception as e:
|
||
await auth.add_log('fail', admin.username, '-', 'SMTP测试', str(e))
|
||
return error(f'发送失败: {e!s}', 500)
|
||
|
||
|
||
# ============ 日志 ============
|
||
|
||
@router.post('/admin/site/logs')
|
||
async def logs(_admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
log_type = 'fail' if str(data.get('type')) == 'fail' else 'success'
|
||
page = max(1, _int_or(data.get('page'), 1))
|
||
rows, total = await auth.query_logs(log_type, 10, (page - 1) * 10)
|
||
return success({'logs': rows, 'type': log_type, 'page': page,
|
||
'total': total, 'pages': max(1, (total + 9) // 10)})
|
||
|
||
|
||
@router.post('/admin/site/logs/clear')
|
||
async def logs_clear(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
log_type = 'fail' if str(data.get('type')) == 'fail' else 'success'
|
||
deleted = await auth.clear_logs(log_type)
|
||
await _log(admin, '清除日志', f'{log_type} · {deleted} 条')
|
||
return success(message=f'已清除 {"失败" if log_type == "fail" else "成功"}日志 {deleted} 条')
|
||
|
||
|
||
# ============ 卡密 ============
|
||
|
||
@router.post('/admin/site/cards/generate')
|
||
async def cards_generate(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
try:
|
||
count = int(data.get('count') or 1)
|
||
valid_days = int(data.get('valid_days') or 0)
|
||
except (TypeError, ValueError):
|
||
return error('张数 / 次数 / 有效天数必须为数字')
|
||
raw_credits = data.get('credits', data.get('minutes'))
|
||
try:
|
||
credits = int(raw_credits) if raw_credits is not None else 5
|
||
except (TypeError, ValueError):
|
||
return error('张数 / 次数 / 有效天数必须为数字')
|
||
if credits <= 0:
|
||
return error('卡密次数必须大于 0')
|
||
try:
|
||
codes = await auth.generate_cards(count, credits, created_by=admin.username,
|
||
prefix=str(data.get('prefix') or 'VIP'), note=str(data.get('note') or ''),
|
||
valid_days=valid_days)
|
||
await _log(admin, '生成卡密', f'{count} 张 · {credits} 次/张 · 有效{valid_days or "永久"}天')
|
||
return success({'codes': codes, 'credits': credits, 'count': len(codes)},
|
||
f'已生成 {len(codes)} 张卡密')
|
||
except Exception as e:
|
||
return error(f'生成失败: {e!s}', 500)
|
||
|
||
|
||
@router.post('/admin/site/cards/list')
|
||
async def cards_list(_admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
limit = _int_or(data.get('limit'), 100) or 100
|
||
offset = _int_or(data.get('offset'), 0)
|
||
cards, total = await auth.list_cards(limit=limit, offset=offset)
|
||
return success({'cards': cards, 'total': total})
|
||
|
||
|
||
@router.post('/admin/site/cards/disable')
|
||
async def cards_disable(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
code = str(data.get('code') or '').strip()
|
||
if not code:
|
||
return error('缺少卡密')
|
||
disable = str(data.get('disabled') or '').strip().lower() in ('1', 'true')
|
||
try:
|
||
await auth.set_card_disabled(code, disable)
|
||
await _log(admin, '禁用卡密' if disable else '恢复卡密', code[:4] + '****')
|
||
return success(message=('已禁用 ' if disable else '已恢复 ') + code[:4] + '****')
|
||
except ValueError as e:
|
||
return error(str(e))
|
||
except Exception:
|
||
return error('操作失败,请稍后重试', 500)
|
||
|
||
|
||
@router.post('/admin/site/cards/delete')
|
||
async def cards_delete(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
code = str(data.get('code') or '').strip()
|
||
if not code:
|
||
return error('缺少卡密')
|
||
try:
|
||
await auth.delete_card(code)
|
||
await _log(admin, '删除卡密', code[:4] + '****')
|
||
return success(message='已删除 ' + code[:4] + '****')
|
||
except ValueError as e:
|
||
return error(str(e))
|
||
except Exception:
|
||
return error('操作失败,请稍后重试', 500)
|
||
|
||
|
||
# ============ 公告 ============
|
||
|
||
@router.post('/admin/site/ann/get')
|
||
async def ann_get(_admin: AdminUser):
|
||
return success(await auth.get_announcement())
|
||
|
||
|
||
@router.post('/admin/site/ann/save')
|
||
async def ann_save(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
try:
|
||
await auth.save_announcement(str(data.get('title') or ''),
|
||
str(data.get('content') or ''), str(data.get('popup_mode') or 'off'))
|
||
await _log(admin, '保存公告', str(data.get('title') or ''))
|
||
return success(message='公告已保存')
|
||
except Exception as e:
|
||
return error(f'保存失败: {e!s}', 500)
|
||
|
||
|
||
# ============ 播放 API 密钥 ============
|
||
|
||
@router.post('/admin/site/play_key/get')
|
||
async def play_key_get(_admin: AdminUser):
|
||
key = await auth.get_setting('playlist_play_key', '')
|
||
if not key:
|
||
key = secrets.token_urlsafe(16)
|
||
await auth.set_setting('playlist_play_key', key)
|
||
return success({'key': key})
|
||
|
||
|
||
@router.post('/admin/site/play_key/rotate')
|
||
async def play_key_rotate(admin: AdminUser):
|
||
key = secrets.token_urlsafe(16)
|
||
await auth.set_setting('playlist_play_key', key)
|
||
await _log(admin, '刷新播放API密钥', '旧密钥已失效')
|
||
return success({'key': key}, '密钥已刷新,旧密钥已全部失效')
|
||
|
||
|
||
# ============ 数据库备份 ============
|
||
|
||
@router.post('/admin/site/backup/create')
|
||
async def backup_create(admin: AdminUser):
|
||
try:
|
||
name = await auth.create_backup()
|
||
await auth.set_setting('last_backup_ts', str(time.time()))
|
||
await _log(admin, '数据库备份', name)
|
||
return success({'name': name}, f'备份完成:{name}')
|
||
except Exception as e:
|
||
return error(f'备份失败: {e!s}', 500)
|
||
|
||
|
||
@router.post('/admin/site/backup/list')
|
||
async def backup_list(_admin: AdminUser):
|
||
backups = await auth.list_backups()
|
||
interval = await auth.get_setting('backup_interval_hours', '0')
|
||
# 主库文件的 stat:微秒级元数据操作,线程调度开销比它本身还大,直接同步做
|
||
db_size = auth.db.DB_PATH.stat().st_size if auth.db.DB_PATH.exists() else 0
|
||
return success({'backups': backups, 'interval_hours': interval, 'db_size': db_size})
|
||
|
||
|
||
@router.get('/admin/site/backup/download')
|
||
async def backup_download(_admin: AdminUser, request: Request):
|
||
name = request.query_params.get('name') or ''
|
||
p = await auth.backup_path(name)
|
||
if not p:
|
||
return error('备份文件不存在', 404)
|
||
resp = FileResponse(str(p), filename=name)
|
||
resp.headers['Cache-Control'] = 'no-store'
|
||
return resp
|
||
|
||
|
||
@router.post('/admin/site/backup/upload')
|
||
async def backup_upload(admin: AdminUser, request: Request):
|
||
dest = None
|
||
try:
|
||
form = await request.form()
|
||
f = form.get('file')
|
||
# 必须用 starlette 的 UploadFile 做 isinstance:fastapi.UploadFile 是它的**子类**,
|
||
# 实测 isinstance(request.form() 取到的对象, fastapi.UploadFile) 为 False,
|
||
# 换成 fastapi 那个会把所有真实上传判成「未选择文件」(顺带让类型检查器收窄 f)。
|
||
if not isinstance(f, UploadFile) or not f.filename:
|
||
return error('请选择数据库文件')
|
||
if not f.filename.endswith('.db'):
|
||
return error('仅支持 .db 文件')
|
||
auth.db.BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||
base = 'upload_' + time.strftime('%Y%m%d_%H%M%S')
|
||
name = base + '.db'
|
||
k = 1
|
||
while (auth.db.BACKUP_DIR / name).exists():
|
||
name = f'{base}-{k}.db'
|
||
k += 1
|
||
dest = auth.db.BACKUP_DIR / name
|
||
# 流式落盘:整库体量逐块搬运,文件头随第一块一起截获(不再回读一遍)
|
||
header = await asyncio.to_thread(_save_upload_stream, f.file, dest)
|
||
if not header.startswith(_SQLITE_MAGIC):
|
||
dest.unlink()
|
||
return error('不是有效的 SQLite 数据库文件')
|
||
await _log(admin, '上传数据库', dest.name)
|
||
try:
|
||
await auth.prune_backups(20, 5)
|
||
except Exception:
|
||
pass
|
||
return success({'name': dest.name}, '上传成功,可在下方选择该备份进行恢复')
|
||
except Exception as e:
|
||
try:
|
||
if dest and dest.exists():
|
||
dest.unlink()
|
||
except Exception:
|
||
pass
|
||
return error(f'上传失败: {e!s}', 500)
|
||
|
||
|
||
@router.post('/admin/site/backup/restore')
|
||
async def backup_restore(admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
name = str(data.get('name') or '')
|
||
try:
|
||
await auth.restore_backup(name)
|
||
await _log(admin, '恢复数据库', name)
|
||
return success(message='数据库已恢复,请刷新页面')
|
||
except ValueError as e:
|
||
return error(f'恢复失败: {e!s}', 400)
|
||
except Exception as e:
|
||
await auth.add_log('fail', admin.username, '-', '恢复数据库', str(e))
|
||
return error(f'恢复失败: {e!s}', 500)
|
||
|
||
|
||
@router.post('/admin/site/backup/delete')
|
||
async def backup_delete(_admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
name = str(data.get('name') or '')
|
||
try:
|
||
if await auth.delete_backup(name):
|
||
return success(message='备份已删除')
|
||
return error('备份文件不存在', 404)
|
||
except Exception as e:
|
||
return error(f'删除失败: {e!s}', 500)
|
||
|
||
|
||
@router.post('/admin/site/backup/auto')
|
||
async def backup_auto(_admin: AdminUser, request: Request):
|
||
data = await _request_data(request)
|
||
try:
|
||
hours = float(data.get('hours') or 0)
|
||
except (TypeError, ValueError):
|
||
return error('间隔必须为数字')
|
||
if not math.isfinite(hours):
|
||
return error('间隔必须为有限数字')
|
||
if hours < 0:
|
||
return error('间隔不能为负')
|
||
try:
|
||
await auth.set_setting('backup_interval_hours', str(hours))
|
||
return success(message=f'定时备份已开启,每 {hours} 小时自动备份' if hours > 0
|
||
else '定时备份已关闭')
|
||
except Exception as e:
|
||
return error(f'设置失败: {e!s}', 500)
|