diff --git a/web/app.py b/web/app.py index 7f31be8..6a782df 100644 --- a/web/app.py +++ b/web/app.py @@ -25,6 +25,7 @@ from web.errors import install_error_handlers from web.middleware import ResponseHygieneMiddleware from web.routers import account as account_router from web.routers import admin as admin_router +from web.routers import admin_site as admin_site_router from web.routers import auth as auth_router from web.routers import auth_status as auth_status_router from web.routers import delivery as delivery_router @@ -97,6 +98,7 @@ def create_app(serve_flask_fallback: bool = False) -> FastAPI: app.include_router(user_router.router, prefix='/api/v1', tags=['user']) app.include_router(admin_router.router, prefix='/api/v1', tags=['admin']) app.include_router(register_router.router, prefix='/api/v1', tags=['auth']) + app.include_router(admin_site_router.router, prefix='/api/v1', tags=['admin']) # 首页与静态资源(迁移完成形态;fallback 模式下同路径也能由 Flask 提供) app.include_router(index_router.router, tags=['index']) diff --git a/web/routers/admin_site.py b/web/routers/admin_site.py new file mode 100644 index 0000000..401fcc5 --- /dev/null +++ b/web/routers/admin_site.py @@ -0,0 +1,547 @@ +# -*- coding: utf-8 -*- +"""管理后台(/admin/site/*)端点(FastAPI 栈)——与 Flask 侧逐字段对齐。 + +鉴权:与 auth_system.admin_required 同口径(403 + error_code='forbidden')。 + +说明:这些端点多为对 auth_system 同步函数(SQLite / SMTP / 文件)的薄封装, +统一经 asyncio.to_thread 执行以不阻塞事件循环;操作者名取当前管理员用户名 +(与 Flask _actor 同口径)。 +""" +import asyncio +import math +import re +import secrets +import time +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import FileResponse + +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,}$') + + +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 asyncio.to_thread(auth.add_log, 'success', admin.username, '-', action, detail) + + +# ============ 用户管理 ============ + +@router.post('/admin/site/users') +async def users(_admin: AdminUser): + rows = await asyncio.to_thread(auth.list_users) + 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 asyncio.to_thread(auth.get_user_by_id, uid) + ok = await asyncio.to_thread(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 asyncio.to_thread(auth.get_user_by_id, uid) + if not target: + return error('用户不存在') + if target['role'] == 'admin': + return error('管理员不限次数') + await asyncio.to_thread(auth.set_user_credits, uid, count) + await _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 asyncio.to_thread(auth.get_user_by_name, username): + return error('用户名已存在') + if email and await asyncio.to_thread(auth.get_user_by_email, email): + return error('该邮箱已被注册,请更换邮箱') + await asyncio.to_thread(auth.create_user, username, password, email=email, + credits=credits, 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 asyncio.to_thread(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 asyncio.to_thread(auth.reset_password, uid, newpwd) + await _log(admin, '修改自己的密码', '管理员') + return success(message='密码已修改,下次登录请使用新密码(其他设备已全部下线)') + await asyncio.to_thread(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 asyncio.to_thread(auth.get_user_by_id, uid) + if not target: + return error('用户不存在') + if target['role'] == 'admin': + return error('不能对管理员账号执行该操作') + cur = target['status'] if 'status' in target.keys() else 1 + new_status = 0 if cur else 1 + await asyncio.to_thread(auth.set_user_status, uid, new_status) + await _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 asyncio.to_thread(auth.get_user_by_id, uid) + if not row: + return error('用户不存在') + if row['role'] == 'admin': + return error('不能对管理员执行该操作') + if action == 'ban': + if not await asyncio.to_thread(auth.ban_user, uid, reason or '管理员手动封禁'): + return error('封禁失败') + await _log(admin, '封禁用户', f'{row["username"]}: {reason or "手动封禁"}') + return success(message=f'已封禁用户「{row["username"]}」,该账号已强制下线') + if not await asyncio.to_thread(auth.unban_user, uid): + 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 asyncio.to_thread(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 asyncio.to_thread(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 asyncio.to_thread(auth.get_smtp_config) + password = cfg.get('password', '') + await asyncio.to_thread(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 asyncio.to_thread(auth.send_email, to, 'SMTP 测试邮件', + '这是一封来自云村小助手的测试邮件,收到即说明 SMTP 配置成功。') + await _log(admin, 'SMTP测试', to) + return success(message='测试邮件已发送,请查收') + except Exception as e: + await asyncio.to_thread(auth.add_log, 'fail', admin.username, '-', 'SMTP测试', str(e)) + 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 asyncio.to_thread(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 asyncio.to_thread(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 asyncio.to_thread( + 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 asyncio.to_thread(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 asyncio.to_thread(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 asyncio.to_thread(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 asyncio.to_thread(auth.get_announcement)) + + +@router.post('/admin/site/ann/save') +async def ann_save(admin: AdminUser, request: Request): + data = await _request_data(request) + try: + await asyncio.to_thread(auth.save_announcement, str(data.get('title') or ''), + 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 asyncio.to_thread(auth.get_setting, 'playlist_play_key', '') + if not key: + key = secrets.token_urlsafe(16) + await asyncio.to_thread(auth.set_setting, 'playlist_play_key', key) + return success({'key': key}) + + +@router.post('/admin/site/play_key/rotate') +async def play_key_rotate(admin: AdminUser): + key = secrets.token_urlsafe(16) + await asyncio.to_thread(auth.set_setting, 'playlist_play_key', key) + await _log(admin, '刷新播放API密钥', '旧密钥已失效') + return success({'key': key}, '密钥已刷新,旧密钥已全部失效') + + +# ============ 数据库备份 ============ + +@router.post('/admin/site/backup/create') +async def backup_create(admin: AdminUser): + try: + name = await asyncio.to_thread(auth.create_backup) + await asyncio.to_thread(auth.set_setting, 'last_backup_ts', str(time.time())) + 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 asyncio.to_thread(auth.list_backups) + interval = await asyncio.to_thread(auth.get_setting, 'backup_interval_hours', '0') + + def _size() -> int: + return auth.DB_PATH.stat().st_size if auth.DB_PATH.exists() else 0 + + return success({'backups': backups, 'interval_hours': interval, + 'db_size': await asyncio.to_thread(_size)}) + + +@router.get('/admin/site/backup/download') +async def backup_download(_admin: AdminUser, request: Request): + name = request.query_params.get('name') or '' + p = await asyncio.to_thread(auth.backup_path, name) + 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') + if not f or not getattr(f, 'filename', None): + return error('请选择数据库文件') + if not f.filename.endswith('.db'): + return error('仅支持 .db 文件') + auth.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.BACKUP_DIR / name).exists(): + name = f'{base}-{k}.db' + k += 1 + dest = auth.BACKUP_DIR / name + content = await f.read() # type: ignore[union-attr] - form 校验后必为 UploadFile + await asyncio.to_thread(dest.write_bytes, content) + with dest.open('rb') as hf: + header = hf.read(16) + if not header.startswith(b'SQLite format 3'): + dest.unlink() + return error('不是有效的 SQLite 数据库文件') + await _log(admin, '上传数据库', dest.name) + try: + await asyncio.to_thread(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 asyncio.to_thread(auth.restore_backup, name) + await _log(admin, '恢复数据库', name) + return success(message='数据库已恢复,请刷新页面') + except ValueError as e: + return error(f'恢复失败: {e!s}', 400) + except Exception as e: + await asyncio.to_thread(auth.add_log, 'fail', admin.username, '-', '恢复数据库', str(e)) + 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 asyncio.to_thread(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 asyncio.to_thread(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)