Archived
refactor: 拆分 4 个巨石模块为包,清理过时注释并删除作废文档
为什么:单文件 1100~1900 行的巨石模块让「改一处要通读全文」;同时注释里残留 大量已删台账的编号,以及「Flask/FastAPI 双栈迁移期」这类已失实的架构描述, 会误导后来者按不复存在的架构去改代码。 拆分(对外导入路径不变,行为零变更): - music_downloader.py -> music_downloader/(models/downloader/cover/_http/_log/_concurrency) - auth_system.py -> auth_system/(db/security/users/credits/ledger/mail/cards/backup...) - music_api.py -> music_api/(api/client/crypto/qr/cookies/compat/constants/errors) - services/runtime.py -> services/(api_service/_shared/_taskcore/downloads/batch/podcast/ streaming/tasks);services/runtime.py 保留为兼容门面 兼容性做法:各包用 __init__.py 以 __all__ 原样再导出全部符号,既有调用点 (auth_system.xxx / services.xxx / from music_api import Y)零改动;路径常量改为经包属性 动态读取(auth_system.DB_PATH),否则测试的 monkeypatch 会静默失效并写到生产库。 修复 9 处代码损伤(上一轮注释清理把 `()` 当「空括号残留」删掉所致;用「与拆分前逐字 等价」校验逐一定位,静态检查只会报出其中 4 处): - core/config.py ).strip() or '*' - core/state.py threading.Lock() x2 - qr_login.py datetime.now() / .strip() - music_downloader/downloader.py .lower() - services/podcast.py set() / time.time() x2 清理与删除: - 删除 部署文档.md / 功能介绍.md / README_私有包警告.txt - 清理指向已删台账与文档的过时编号注释;把「迁移期 / 两栈 / 双框架 / 已迁移」等 失实描述改写为现状描述(保留 `<-> Flask /path` 映射表等仍有价值的契约来源说明) - _mkpkg.py:公开包剔除清单去掉已不存在的文档名;私有包警告改为自包含 - pyproject.toml:补 pythonpath = ["."],使裸跑 pytest 不再 import core 失败 验证:ruff 全通过;pyright 0 errors / 0 warnings(102 文件);pytest 144 passed; 真实 uvicorn 冒烟 6 条路由符合预期且生产 data/ 指纹未变;忽略注释的逐字等价性校验通过。
This commit is contained in:
1 parent
838d635bc5
commit
6288a98ecc
89 files changed
+5806
-5112
No files matched your search
+5
-7
@@ -1,11 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI 应用层(Flask → FastAPI 迁移目标)。
|
||||
"""FastAPI 应用层——本项目的唯一服务端。
|
||||
|
||||
迁移策略:strangler(绞杀者)——新路由在 FastAPI 侧实现,未迁移路由由
|
||||
遗留 Flask 应用兜底(见 web/app.py 的可选 WSGI 挂载)。两侧共享 core/ 的
|
||||
配置、运行时状态与工具,保证迁移期状态一致、行为可比。
|
||||
Flask 巨石已删除,全部 HTTP 端点、鉴权、静态资源与 SSE 均在本包内实现;
|
||||
应用工厂见 web/app.py,路由按业务域拆分在 web/routers/ 下。
|
||||
|
||||
本包只依赖 core/ 与上游服务模块(music_api 等),**不得**从 main.py 导入路由。
|
||||
当前为脚手架:仅迁移 /health、/api/info 两个无鉴权、无状态的系统端点,
|
||||
作为验证「双框架响应契约一致」的样板。
|
||||
本包只依赖 core/ 与上游服务模块(music_api、auth_system、services 等),
|
||||
**不得**反向从入口脚本导入任何路由定义。
|
||||
"""
|
||||
+6
-9
@@ -1,11 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI 应用工厂。
|
||||
|
||||
设计(strangler 迁移):
|
||||
- 已迁移的路由挂在 `/api/v1` 前缀下,与遗留 Flask 的同名路由(根路径)并存,
|
||||
便于逐端点比对响应契约(.zcode/refactor/parity.py)。
|
||||
迁移已完成:Flask 巨石(main.py/auth_routes.py)已删除,本应用即唯一服务端。
|
||||
`api_prefix` 为空即直接服务前端使用的根路径;非空则挂在给定前缀下(用于契约比对)。
|
||||
本应用即唯一服务端:全部端点在 web/routers/ 下按业务域注册。
|
||||
`api_prefix` 用于把整套路由挂到指定前缀下(便于契约测试与反向代理)。
|
||||
"""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -50,7 +47,7 @@ async def _lifespan(app: FastAPI):
|
||||
|
||||
|
||||
def _init_audio_secret() -> None:
|
||||
"""注入音频签名密钥(与 Flask 同源,令两栈 token 互认)。"""
|
||||
"""注入音频签名密钥(与 auth_system 同源,令直链 token 与服务端一致)。"""
|
||||
try:
|
||||
import auth_system
|
||||
|
||||
@@ -64,7 +61,7 @@ def create_app(api_prefix: str = '') -> FastAPI:
|
||||
|
||||
api_prefix:路由挂载前缀。
|
||||
- ''(默认):直接服务前端使用的根路径(生产形态)
|
||||
- '/api/v1':挂在子前缀下(契约比对脚本 .zcode/refactor/parity.py 用)
|
||||
- '/api/v1':挂在子前缀下(便于契约测试与反向代理)
|
||||
"""
|
||||
_init_audio_secret()
|
||||
|
||||
@@ -85,7 +82,7 @@ def create_app(api_prefix: str = '') -> FastAPI:
|
||||
if _static_dir.is_dir():
|
||||
app.mount('/static', StaticFiles(directory=str(_static_dir)), name='static')
|
||||
|
||||
# 已迁移路由:统一挂 /api/v1(迁移期与 Flask 根路径并存,便于对比)
|
||||
# 业务路由:统一挂到 api_prefix(默认空前缀,即前端使用的根路径)
|
||||
app.include_router(system_router.router, prefix=api_prefix, tags=['system'])
|
||||
app.include_router(site_router.router, prefix=api_prefix, tags=['site'])
|
||||
app.include_router(music_router.router, prefix=api_prefix, tags=['music'])
|
||||
@@ -101,7 +98,7 @@ def create_app(api_prefix: str = '') -> FastAPI:
|
||||
app.include_router(register_router.router, prefix=api_prefix, tags=['auth'])
|
||||
app.include_router(admin_site_router.router, prefix=api_prefix, tags=['admin'])
|
||||
app.include_router(download_router.router, prefix=api_prefix, tags=['download'])
|
||||
# 首页与静态资源(迁移完成形态;fallback 模式下同路径也能由 Flask 提供)
|
||||
# 首页(根路径,不挂前缀)
|
||||
app.include_router(index_router.router, tags=['index'])
|
||||
|
||||
install_error_handlers(app)
|
||||
|
||||
+8
-8
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""认证的内存态与校验辅助(原 auth_routes 的模块级状态,框架无关)。
|
||||
"""认证的内存态与校验辅助(模块级状态,框架无关)。
|
||||
|
||||
包含:登录失败锁定、管理员 2FA 票据、发码 IP 限流、用户名/保留名校验。
|
||||
这些状态**进程内共享**——登录/注册/2FA 各端点都读写同一份,避免「换个端点绕过锁定」。
|
||||
@@ -38,7 +38,7 @@ _ADMIN_2FA_MAX_ATTEMPTS = 5 # 邮箱比对 + 验证码合计尝试上限
|
||||
_ADMIN_2FA_MAX_TICKETS = 200 # 全局活票上限(防内存膨胀,挤掉最旧)
|
||||
|
||||
|
||||
_ADMIN2FA_SEND_GAP = 20 # 同账号两次发码最小间隔(秒)——第 87 条初版 60s 会卡住合法的背靠背重登(含矩阵双设备全流程),20s 已足以阻断紧密轰炸循环
|
||||
_ADMIN2FA_SEND_GAP = 20 # 同账号两次发码最小间隔(秒)——初版 60s 会卡住合法的背靠背重登(含矩阵双设备全流程),20s 已足以阻断紧密轰炸循环
|
||||
|
||||
|
||||
def _admin2fa_purge() -> None:
|
||||
@@ -58,7 +58,7 @@ def _admin2fa_mask(email: str) -> str:
|
||||
|
||||
def _admin2fa_issue(user) -> str:
|
||||
_admin2fa_purge()
|
||||
# 第 87 条:每管理员仅 1 张活票——新票签发即销毁同账号旧票
|
||||
# 每管理员仅 1 张活票——新票签发即销毁同账号旧票
|
||||
for t in [t for t, v in list(_ADMIN_2FA.items()) if v['username'] == user['username']]:
|
||||
_ADMIN_2FA.pop(t, None)
|
||||
if len(_ADMIN_2FA) >= _ADMIN_2FA_MAX_TICKETS:
|
||||
@@ -111,7 +111,7 @@ def _login_fail_clear(username: str) -> None:
|
||||
|
||||
|
||||
def _login_fail_locked_log_once(username: str) -> bool:
|
||||
"""R5-4:锁定周期内的重复被封尝试只落一条 fail 日志,防多 IP 刷审计表;
|
||||
"""锁定周期内的重复被封尝试只落一条 fail 日志,防多 IP 刷审计表;
|
||||
解锁(窗口过期或成功登录)后进入新周期,才允许再记一条。"""
|
||||
if username in _LOGIN_FAIL_LOGGED:
|
||||
return False
|
||||
@@ -121,7 +121,7 @@ def _login_fail_locked_log_once(username: str) -> bool:
|
||||
|
||||
def _safe_register_credits() -> int:
|
||||
"""注册赠送次数(settings.register_credits,默认 3)。
|
||||
R5 防呆:损坏回退默认;<0 一律按 0——设为 -1 会经 create_user 造出"不限次"新号。"""
|
||||
防呆:损坏回退默认;<0 一律按 0——设为 -1 会经 create_user 造出"不限次"新号。"""
|
||||
try:
|
||||
credits = int(auth.get_setting('register_credits', str(auth.DEFAULT_REGISTER_CREDITS)) or 0)
|
||||
except (TypeError, ValueError):
|
||||
@@ -139,7 +139,7 @@ _SEND_CODE_IP_WINDOW = 3600
|
||||
|
||||
|
||||
def _send_code_ip_check(ip: str) -> bool:
|
||||
"""返回 True 表示允许发送;超限返回 False(S4:表超限时顺手清理过期项,防无界增长)"""
|
||||
"""返回 True 表示允许发送;超限返回 False(表超限时顺手清理过期项,防无界增长)"""
|
||||
now = time.time()
|
||||
if len(_SEND_CODE_IP) > 10000:
|
||||
stale = [k for k, (c, w) in _SEND_CODE_IP.items() if now - w > _SEND_CODE_IP_WINDOW]
|
||||
@@ -159,8 +159,8 @@ _RESERVED_USERNAMES = {'admin', 'administrator', 'qfbsh', 'root', 'system', 'mod
|
||||
|
||||
def re_username_ok(username: str) -> bool:
|
||||
import re as _re
|
||||
# P3-10:禁纯下划线/下划线开头结尾("____"、"___admin" 有钓鱼观感风险)
|
||||
# R5-P3:白名单收紧为中文/字母/数字/下划线——`\w` 会放行西里尔等同形字符
|
||||
# 禁纯下划线/下划线开头结尾("____"、"___admin" 有钓鱼观感风险)
|
||||
# 白名单收紧为中文/字母/数字/下划线——`\w` 会放行西里尔等同形字符
|
||||
#("аdmin" 冒充钓鱼);保留字(admin/qfbsh 等)一并拒绝,防冒充管理入口
|
||||
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('_') \
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI 计费依赖(账本预扣/退款),与 Flask login_required 同口径。
|
||||
"""计费依赖(账本预扣/退款),口径与登录闸一致。
|
||||
|
||||
Flask 版 login_required 用 flask.g 暂存请求级账本键;FastAPI 无 g,改用
|
||||
历史实现用请求上下文(flask.g)暂存请求级账本键;本实现无 g,改用
|
||||
「依赖返回值携带 ledger_key + 路由内显式退款」的方式,语义完全一致:
|
||||
- 预扣与开账同事务:auth_system.reserve_and_open(A2/B8)
|
||||
- 预扣与开账同事务:auth_system.reserve_and_open
|
||||
- 失败退款:只退「仍 open 的账本行」(与装饰器 _refund_open 同口径,杜绝双退)
|
||||
- 任务接手:auth_system.ledger_rename 把请求级行改挂 task_id(worker 结算唯一入口)
|
||||
|
||||
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI 依赖:会话解析、当前用户、登录校验。
|
||||
|
||||
与 Flask 侧 auth_system 的语义对齐(迁移期两栈共用同一 Cookie、同一站点库):
|
||||
与 auth_system 的语义对齐(共用同一 Cookie、同一站点库):
|
||||
- 会话 Cookie 经 core.session 解码(Flask 兼容签名),密钥同源
|
||||
- 封禁账号(status 0/2):按未登录处理
|
||||
- 顶号校验:会话内的 `site_uid_seq` 必须与库中 `login_seq` 一致,否则视为失效
|
||||
- 剩余次数/账本预扣属付费流程,待付费路由迁移时再补(此处先覆盖免费端点)
|
||||
- 剩余次数/账本预扣属付费流程,由 web.billing 承担(此处只做身份解析)
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any
|
||||
@@ -20,7 +20,7 @@ _SESSION_COOKIE = 'session'
|
||||
|
||||
|
||||
def secret_key() -> str:
|
||||
"""会话签名密钥(与 Flask 同源:data/secret.key)。"""
|
||||
"""会话签名密钥(密钥来源:data/secret.key)。"""
|
||||
import auth_system
|
||||
return auth_system.get_secret_key()
|
||||
|
||||
@@ -75,7 +75,7 @@ async def current_user(
|
||||
async def require_login(
|
||||
user: Annotated[SiteUser | None, Depends(current_user)],
|
||||
) -> SiteUser:
|
||||
"""登录闸:未登录抛 401(与 Flask login_required 同形包体),已登录返回用户。"""
|
||||
"""登录闸:未登录抛 401,已登录返回用户。"""
|
||||
if user is None:
|
||||
raise AppError('请先登录后再使用解析功能', 401, error_code='unauthorized')
|
||||
return user
|
||||
@@ -84,7 +84,7 @@ async def require_login(
|
||||
async def require_admin(
|
||||
user: Annotated[SiteUser | None, Depends(current_user)],
|
||||
) -> SiteUser:
|
||||
"""管理员闸:非管理员抛 403(与 Flask admin_required 同形包体)。"""
|
||||
"""管理员闸:非管理员抛 403。"""
|
||||
if user is None or not user.is_admin:
|
||||
raise AppError('需要管理员权限', 403, error_code='forbidden')
|
||||
return user
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"""统一应用异常与错误响应。
|
||||
|
||||
FastAPI 依赖里无法直接「返回响应短路」,因此用抛出 AppError + 全局异常处理器
|
||||
的方式,产出与 Flask 侧同形的 JSON 包体({status, success, message, error_code})。
|
||||
的方式,产出统一的 JSON 包体({status, success, message, error_code})。
|
||||
"""
|
||||
from typing import Any
|
||||
|
||||
@@ -30,7 +30,7 @@ def install_error_handlers(app) -> None:
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def _http_error(_request: Request, exc: StarletteHTTPException) -> Any:
|
||||
# 404/405 等也统一为本站包体(Flask 侧 404 返回 {'success': False, ...})
|
||||
# 404/405 等也统一为本站包体(404 也返回 {'success': False, ...})
|
||||
msg = {404: '请求的资源不存在', 405: '请求方法不允许'}.get(exc.status_code,
|
||||
str(exc.detail or '请求错误'))
|
||||
return error(msg, exc.status_code)
|
||||
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FastAPI 中间件:镜像 Flask after_request 的响应侧行为。
|
||||
"""FastAPI 中间件:响应侧统一处理(安全头 / 直链签名 / 指纹抹除)。
|
||||
|
||||
与 main.py `after_request` 对齐的关键点:
|
||||
处理要点:
|
||||
1. 音频直链保护——JSON 响应中的网易云音频地址替换为 HMAC 签名代理地址
|
||||
(`/audio_stream?token=...`),使直链不可被外站直接盗用;密钥与 Flask 同源,
|
||||
故两栈签发的 token 互相可校验。
|
||||
(`/audio_stream?token=...`),使直链不可被外站直接盗用;密钥与 core.security 同源,
|
||||
故校验口径一致。
|
||||
2. 零成本安全响应头(nosniff / DENY / Referrer-Policy / CSP 基线)。
|
||||
3. 抹去服务器版本指纹(Server 头统一为 NeteaseUrl)。
|
||||
4. 429 统一带 Retry-After,供客户端退避。
|
||||
|
||||
+8
-8
@@ -1,19 +1,19 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""公网限流(FastAPI 栈)——按 IP 滑动窗口,与 Flask 侧 _rate_ok 同一实现口径。
|
||||
"""公网限流——按 IP 滑动窗口。
|
||||
|
||||
复用 core.state.RATE_BUCKETS/_RATE_LOCK,使两栈在迁移期共享同一批限流桶
|
||||
(同一 IP 无论打到哪个栈都计入同一窗口),避免出现「绕到另一个框架即绕过限流」。
|
||||
限流桶集中在 core.state.RATE_BUCKETS/_RATE_LOCK,使所有端点共享同一批桶
|
||||
(同一 IP 一律计入同一窗口),避免出现绕过限流的口子。
|
||||
"""
|
||||
import collections
|
||||
import time
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from core.config import RESULT_RETENTION_SECONDS # noqa: F401 - 保持与 Flask 侧常量同源
|
||||
from core.config import RESULT_RETENTION_SECONDS # noqa: F401 - 再导出,与 core.config 口径同源
|
||||
from core.state import RATE_BUCKETS, RATE_LOCK
|
||||
from web.errors import AppError
|
||||
|
||||
# 免于限流的端点(试听流/进度轮询,与 Flask _QUIET/_rate_limit_guard 同口径)
|
||||
# 免于限流的端点(试听流/进度轮询)
|
||||
_EXEMPT_PREFIXES = ('/audio_stream',)
|
||||
_EXEMPT_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat'}
|
||||
|
||||
@@ -21,7 +21,7 @@ _EXEMPT_PATHS = {'/api/queue', '/api/batch_progress', '/api/task/heartbeat'}
|
||||
def client_ip(request: Request) -> str:
|
||||
"""客户端 IP:仅当直连方是回环(可信反代)时采信 XFF/Real-IP。
|
||||
|
||||
规则与 Flask 时代完全一致(core.net 与原实现同源):XFF 最右段优先,
|
||||
规则见 core.net(与原实现同源):XFF 最右段优先,
|
||||
最右段为回环时回落 X-Real-IP,全部无效回落直连地址。
|
||||
"""
|
||||
from core.net import is_loopback_ip, trusted_proxy_ip
|
||||
@@ -41,7 +41,7 @@ def client_ip(request: Request) -> str:
|
||||
|
||||
|
||||
def rate_ok(key: str, limit: int, window: int) -> bool:
|
||||
"""滑动窗口限流:True=放行。与 Flask main._rate_ok 同算法、同桶容器。"""
|
||||
"""滑动窗口限流:True=放行。"""
|
||||
now = time.time()
|
||||
with RATE_LOCK:
|
||||
dq = RATE_BUCKETS.get(key)
|
||||
@@ -60,7 +60,7 @@ def rate_ok(key: str, limit: int, window: int) -> bool:
|
||||
|
||||
|
||||
async def guard(request: Request, *, bucket: str, limit: int, window: int) -> None:
|
||||
"""限流闸:超限抛 429(与 Flask 包体同形)。豁免端点直接放行。"""
|
||||
"""限流闸:超限抛 429。豁免端点直接放行。"""
|
||||
path = request.url.path
|
||||
if path in _EXEMPT_PATHS or path.startswith(_EXEMPT_PREFIXES):
|
||||
return
|
||||
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""响应工具:与 Flask 侧 APIResponse 的 JSON 契约逐字段对齐。
|
||||
"""响应工具:统一 JSON 包体契约。
|
||||
|
||||
Flask 的 APIResponse.success/error 返回 (dict, status) 元组,由 Flask 序列化。
|
||||
沿用历史 APIResponse 的包体结构(逐字段一致):
|
||||
FastAPI 侧改为直接返回 JSONResponse,但**包体结构完全一致**:
|
||||
success: {"status": sc, "success": true, "message": msg, ["data": ...]}
|
||||
error: {"status": sc, "success": false, "message": msg, ["error_code": ...]}
|
||||
这样前端无需任何改动即可同时对接两个框架。
|
||||
这样前端无需任何改动即可无缝对接。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
@@ -14,7 +14,7 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
def success(data: Any = None, message: str = 'success', status_code: int = 200) -> JSONResponse:
|
||||
"""成功响应(对应 Flask APIResponse.success)"""
|
||||
"""成功响应。"""
|
||||
body = {
|
||||
'status': status_code,
|
||||
'success': True,
|
||||
@@ -26,7 +26,7 @@ def success(data: Any = None, message: str = 'success', status_code: int = 200)
|
||||
|
||||
|
||||
def error(message: str, status_code: int = 400, error_code: str | None = None) -> JSONResponse:
|
||||
"""错误响应(对应 Flask APIResponse.error)"""
|
||||
"""错误响应。"""
|
||||
body = {
|
||||
'status': status_code,
|
||||
'success': False,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""账号与全局歌单端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
|
||||
"""账号与全局歌单端点。
|
||||
|
||||
已迁移:
|
||||
端点:
|
||||
- POST/GET /api/v1/account/vip ↔ Flask /account/vip(admin/user 双路径 + 缓存)
|
||||
- POST/GET /api/v1/global/playlists ↔ Flask /global/playlists(管理员)
|
||||
- POST/GET /api/v1/global/playlist ↔ Flask /global/playlist(管理员)
|
||||
@@ -31,7 +31,7 @@ _VIP_NAMES = {
|
||||
0: '普通用户', 1: '音乐包', 11: '黑胶VIP', 100: '黑胶VIP', 10: '黑胶SVIP', 110: '黑胶SVIP',
|
||||
}
|
||||
|
||||
# 探测单飞:与 Flask _VIP_PROBE_LOCK 同源(跨栈共享,避免两栈各自串行 8 级探测)
|
||||
# 探测单飞:与 core.state 中同一把锁共享,避免并发请求各自串行 8 级探测
|
||||
_VIP_PROCESS_LOCK: Any = VIP_PROBE_LOCK
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ def _cookies() -> dict[str, str]:
|
||||
|
||||
|
||||
async def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]:
|
||||
"""检测账号会员类型与可解析音质(与 Flask _detect_vip 同口径)。
|
||||
"""检测账号会员类型与可解析音质。
|
||||
|
||||
单飞锁为 threading.Lock:异步上下文里用 to_thread 有界等待 0.5s,
|
||||
拿不到锁的请求直接用账号接口给降级结果(不等 8 级探测)。
|
||||
@@ -100,7 +100,7 @@ async def account_vip(
|
||||
'vip_name': _vip_name(vip_type), 'detected_by': method, 'levels_ok': levels_ok,
|
||||
'nickname': (info or {}).get('nickname', ''),
|
||||
}
|
||||
# 降级结果(levels_ok 空)短缓存 30s,正常结果 300s(与 Flask 同口径)
|
||||
# 降级结果(levels_ok 空)短缓存 30s,正常结果 300s
|
||||
ttl = 30 if not levels_ok else 300
|
||||
if len(VIP_USER_CACHE) > 500:
|
||||
VIP_USER_CACHE.clear()
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""管理员扫码登录 / Cookie 管理 / 操作记录(FastAPI 栈)。
|
||||
"""管理员扫码登录 / Cookie 管理 / 操作记录。
|
||||
|
||||
已迁移:
|
||||
端点:
|
||||
- POST /api/v1/admin/qr/create ↔ Flask /admin/qr/create
|
||||
- POST /api/v1/admin/qr/check ↔ Flask /admin/qr/check
|
||||
- POST /api/v1/admin/cookie/status ↔ Flask /admin/cookie/status
|
||||
- POST /api/v1/admin/cookie/delete ↔ Flask /admin/cookie/delete
|
||||
- POST /api/v1/admin/logs ↔ Flask /admin/logs
|
||||
|
||||
鉴权:Flask 这些端点用 _verify_admin_password(文案"需要管理员权限,请使用管理员账号登录"),
|
||||
鉴权:管理员密码闸(文案"需要管理员权限,请使用管理员账号登录"),
|
||||
与 auth_system.admin_required 的文案不同,故本模块用本地 admin 闸保持逐字对齐。
|
||||
|
||||
说明:QRLoginManager 已异步化(直接 await);QRLoginClient 的 save_cookie/logout/
|
||||
@@ -34,7 +34,7 @@ logger = logging.getLogger('web.admin')
|
||||
|
||||
|
||||
async def _require_admin(user: Annotated[SiteUser | None, Depends(current_user)]) -> SiteUser:
|
||||
"""管理员闸(文案与 Flask _verify_admin_password 逐字对齐)。"""
|
||||
"""管理员闸(文案与历史实现逐字对齐)。"""
|
||||
if user is None or not user.is_admin:
|
||||
raise AppError('需要管理员权限,请使用管理员账号登录', 403)
|
||||
return user
|
||||
@@ -52,7 +52,7 @@ def _admin_client():
|
||||
|
||||
|
||||
def _reset_caches() -> None:
|
||||
"""Cookie 变更后清 VIP 探测缓存与全局 MUSIC_U 缓存(与 Flask 同口径)。"""
|
||||
"""Cookie 变更后清 VIP 探测缓存与全局 MUSIC_U 缓存。"""
|
||||
VIP_CACHE['data'] = None
|
||||
VIP_CACHE['ts'] = 0.0
|
||||
GLOBAL_MUSIC_U_CACHE['v'] = None
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""管理后台(/admin/site/*)端点(FastAPI 栈)——与 Flask 侧逐字段对齐。
|
||||
"""管理后台(/admin/site/*)端点。
|
||||
|
||||
鉴权:与 auth_system.admin_required 同口径(403 + error_code='forbidden')。
|
||||
|
||||
说明:这些端点多为对 auth_system 同步函数(SQLite / SMTP / 文件)的薄封装,
|
||||
统一经 asyncio.to_thread 执行以不阻塞事件循环;操作者名取当前管理员用户名
|
||||
(与 Flask _actor 同口径)。
|
||||
统一经 asyncio.to_thread 执行以不阻塞事件循环;操作者名取当前管理员用户名。
|
||||
"""
|
||||
import asyncio
|
||||
import math
|
||||
|
||||
+6
-6
@@ -1,18 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""认证操作端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
|
||||
"""认证操作端点。
|
||||
|
||||
已迁移:
|
||||
端点:
|
||||
- POST /api/v1/auth/logout ↔ Flask /auth/logout(顶号:递增 login_seq + 删 Cookie)
|
||||
- POST /api/v1/auth/heartbeat ↔ Flask /auth/heartbeat
|
||||
- POST /api/v1/auth/ann_seen ↔ Flask /auth/ann_seen
|
||||
- POST /api/v1/auth/login ↔ Flask /auth/login(密码校验 + 失败锁定 + 管理员 2FA 转票据)
|
||||
|
||||
设计:
|
||||
- 登录失败锁定与管理员 2FA 票据表**复用 web.auth_state 的模块级状态**,两栈共享同一份
|
||||
- 登录失败锁定与管理员 2FA 票据表**复用 web.auth_state 的模块级状态**,全进程共享同一份
|
||||
计数/票据,避免「换个框架打就绕过锁定」。
|
||||
- 密码校验(PBKDF2,10 万次迭代,CPU 密集)与库操作经 asyncio.to_thread 执行,
|
||||
不阻塞事件循环。
|
||||
- 会话 Cookie 由 core.session 签发(与 Flask 同格式同密钥),删 Cookie 亦同口径。
|
||||
- 会话 Cookie 由 core.session 签发(沿用同一格式与密钥,存量会话继续有效),删 Cookie 亦同口径。
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
@@ -52,7 +52,7 @@ def _cookie_kwargs() -> dict:
|
||||
|
||||
|
||||
def _login_response(payload: dict, message: str, uid: int, seq: int) -> JSONResponse:
|
||||
"""构造带会话 Cookie 的成功响应(Cookie 格式与 Flask 一致)。"""
|
||||
"""构造带会话 Cookie 的成功响应(沿用同一 Cookie 格式)。"""
|
||||
resp = JSONResponse(content={'status': 200, 'success': True, 'message': message, 'data': payload})
|
||||
cookie = cs.dumps(deps.secret_key(), {
|
||||
'site_uid': uid, 'site_uid_seq': seq, 'site_uid_ts': int(time.time()),
|
||||
@@ -78,7 +78,7 @@ async def logout(user: Annotated[SiteUser | None, Depends(current_user)]):
|
||||
|
||||
@router.post('/auth/heartbeat')
|
||||
async def heartbeat(user: Annotated[SiteUser | None, Depends(current_user)]):
|
||||
"""会话保活:返回最新次数;超限/失效给 401(expired 语义与 Flask 同)。"""
|
||||
"""会话保活:返回最新次数;超限/失效给 401(expired 语义不变)。"""
|
||||
if user is None:
|
||||
return error('登录已失效', 401, error_code='unauthorized')
|
||||
row = await repo.get_user_by_id(user.id)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""认证状态端点(FastAPI 栈)——与 Flask /auth/me 响应契约逐字段对齐。
|
||||
"""认证状态端点。
|
||||
|
||||
- GET/POST /api/v1/auth/me ↔ Flask /auth/me
|
||||
· user:站点用户公开字段(未登录为 None)
|
||||
@@ -28,7 +28,7 @@ async def auth_me(
|
||||
user: Annotated[SiteUser | None, Depends(current_user)],
|
||||
request: Request,
|
||||
):
|
||||
"""当前会话与站点公开配置(未登录也返回,与 Flask 同口径)。"""
|
||||
"""当前会话与站点公开配置(未登录也返回)。"""
|
||||
await ratelimit.guard(request, bucket='free', limit=30, window=60)
|
||||
try:
|
||||
ann = await repo.get_announcement()
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""结果交付端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
|
||||
"""结果交付端点。
|
||||
|
||||
已迁移:
|
||||
端点:
|
||||
- POST/GET /api/v1/download/result ↔ Flask /download/result(单曲)
|
||||
- POST/GET /api/v1/download/batch_zip/result ↔ Flask /download/batch_zip/result(批量 ZIP)
|
||||
|
||||
语义对齐要点:
|
||||
- 任务归属:仅任务本人的站点账号可领取(非本人 403)
|
||||
- 状态语义:进行中 202 / 暂停 200+提示 / 取消 200+原因 / 失败 500+原因 /
|
||||
文件缺失 404(与 Flask 分支逐一对应)
|
||||
文件缺失 404(与历史分支逐一对应)
|
||||
- 交付物:FileResponse 流式发送;Cache-Control: no-store;
|
||||
X-Download-Filename(URL 编码,供前端取回原始文件名)
|
||||
- 文件清理仍由监控线程按 35 分钟保留期统一回收(交付请求不删文件)
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""下载提交端点(FastAPI 栈)——与 Flask 侧逐字段对齐。
|
||||
"""下载提交端点。
|
||||
|
||||
已迁移:
|
||||
端点:
|
||||
- POST /api/v1/download ↔ Flask /download(计费 1 次;file/json 两模式)
|
||||
- POST /api/v1/download/batch_zip ↔ Flask /download/batch_zip(按去重歌曲数计费)
|
||||
- POST /api/v1/podcast/download ↔ Flask /podcast/download(计费 1 次)
|
||||
- POST /api/v1/podcast/download/batch_zip ↔ Flask /podcast/download/batch_zip
|
||||
|
||||
计费:经 web.billing.require_billing(预扣+开账同事务 / 失败只退 open 行),
|
||||
任务登记与启动复用 web.tasks_registry(与 main._register_download_task 同口径);
|
||||
worker 仍复用 main 的既有实现(无 flask 依赖),故账本与交付语义不变。
|
||||
任务登记与启动由 web.tasks_registry 承担;
|
||||
worker 复用 services 层既有实现,账本与交付语义不变。
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
@@ -283,7 +283,7 @@ async def download_batch_zip(user: Annotated[SiteUser | None, Depends(current_us
|
||||
# ============ 播客单集下载 ============
|
||||
|
||||
def _podcast_billing_count(data: dict[str, Any]) -> int:
|
||||
"""播客打包计费(与 Flask _podcast_billing_count 同口径)。"""
|
||||
"""播客打包计费。"""
|
||||
if data.get('radio_id'):
|
||||
return 1
|
||||
limit = _safe_int(data.get('limit', 200) or 200, 200)
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""首页与静态资源(FastAPI 栈)。
|
||||
"""首页与静态资源。
|
||||
|
||||
- GET / 首页:渲染同一份 templates/index.html,禁止缓存(与 Flask 一致)
|
||||
- GET / 首页:渲染 templates/index.html,禁止缓存
|
||||
- 静态资源由 Starlette StaticFiles 挂载 /static(见 web/app.py)
|
||||
|
||||
说明:首页在 Flask fallback 模式下由 Flask 兜底提供;本端点用于「关闭 Flask 后」
|
||||
FastAPI 独立提供首页(迁移完成的形态)。两者渲染的是同一模板,故内容一致。
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
@@ -21,8 +18,8 @@ _TEMPLATE = Path(__file__).resolve().parent.parent.parent / 'templates' / 'index
|
||||
async def index(request: Request) -> HTMLResponse:
|
||||
"""首页(禁止缓存,确保前端更新即时生效)。
|
||||
|
||||
用 Jinja2 以 Flask 相同的默认口径渲染(keep_trailing_newline=False),
|
||||
使输出与 Flask render_template 逐字节一致。
|
||||
用 Jinja2 以与历史模板渲染一致的默认口径(keep_trailing_newline=False),
|
||||
使输出逐字节不变。
|
||||
"""
|
||||
import jinja2
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""音乐端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
|
||||
"""音乐端点。
|
||||
|
||||
已迁移:
|
||||
端点:
|
||||
- 免费解析类(需登录,不扣次数):
|
||||
POST/GET /api/v1/toplist ↔ Flask /toplist
|
||||
POST/GET /api/v1/artist_songs ↔ Flask /artist_songs
|
||||
@@ -11,7 +11,7 @@
|
||||
POST/GET /api/v1/playlist ↔ Flask /playlist
|
||||
POST/GET /api/v1/song ↔ Flask /song(四种 type:url/name/lyric/json)
|
||||
|
||||
参数获取沿用 Flask 口径:GET query / POST json / POST form 三路兼容。
|
||||
参数获取口径:GET query / POST json / POST form 三路兼容。
|
||||
上游调用走 music_api 的**异步**方法(原生并发,无同步桥)。
|
||||
"""
|
||||
import asyncio
|
||||
@@ -62,7 +62,7 @@ def _nick_lock() -> asyncio.Lock:
|
||||
|
||||
|
||||
async def mask_server_nickname(nick: str, user: SiteUser) -> str:
|
||||
"""非管理员响应中把服务器账号昵称替换为站点名(与 Flask _mask_server_nickname 同口径)。
|
||||
"""非管理员响应中把服务器账号昵称替换为站点名。
|
||||
|
||||
- 管理员本人可见真实昵称;
|
||||
- 缓存 10 分钟内复用;上游拿不到时回落站点库里的最后已知昵称继续遮蔽。
|
||||
@@ -175,7 +175,7 @@ async def search(user: LoginUser, request: Request):
|
||||
except APIException:
|
||||
return error('服务暂时不可用,请稍后再试', 500)
|
||||
|
||||
# 向后兼容字段 + 服务器昵称遮蔽(与 Flask 侧同口径)
|
||||
# 向后兼容字段 + 服务器昵称遮蔽
|
||||
if result:
|
||||
for song in result:
|
||||
if song.get('type', 'song') == 'song' and 'artists' in song:
|
||||
@@ -191,7 +191,7 @@ async def search(user: LoginUser, request: Request):
|
||||
# ============ 个人/全局账号 Cookie ============
|
||||
|
||||
def _global_music_u() -> str:
|
||||
"""全局账号 MUSIC_U(5 分钟缓存,与 Flask main._global_music_u 同源同缓存)。"""
|
||||
"""全局账号 MUSIC_U(5 分钟缓存)。"""
|
||||
now = time.time()
|
||||
if GLOBAL_MUSIC_U_CACHE['v'] is None or now - GLOBAL_MUSIC_U_CACHE['ts'] > 300:
|
||||
try:
|
||||
@@ -209,7 +209,7 @@ async def _resolve_user_cookies(
|
||||
"""解析个人账号 Cookie(浏览器传入的 user_cookie,不落服务器)。
|
||||
|
||||
全局账号(cookie.txt)零个人身份权限:浏览器提交的 MUSIC_U 若与全局账号一致
|
||||
(全局 Cookie 泄露被当个人 Cookie 冒用),一律拒绝并记审计(与 Flask 同口径)。
|
||||
(全局 Cookie 泄露被当个人 Cookie 冒用),一律拒绝并记审计。
|
||||
"""
|
||||
ck = media.parse_cookie_str(data.get('user_cookie'))
|
||||
mu = (ck.get('MUSIC_U') or '').strip()
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""播客端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
|
||||
"""播客端点。
|
||||
|
||||
已迁移(免费解析类,需登录,不扣次数):
|
||||
端点(免费解析类,需登录,不扣次数):
|
||||
- POST/GET /api/v1/podcast/parse ↔ Flask /podcast/parse
|
||||
- POST/GET /api/v1/podcast/programs ↔ Flask /podcast/programs
|
||||
- POST/GET /api/v1/podcast/search ↔ Flask /podcast/search
|
||||
- POST/GET /api/v1/podcast/hot ↔ Flask /podcast/hot
|
||||
|
||||
说明:podcast_api 仍是同步模块(requests),此处以 asyncio.to_thread 包装,
|
||||
让阻塞的上游调用不占用事件循环——行为与 Flask 侧一致,且不阻塞其它请求。
|
||||
让阻塞的上游调用不占用事件循环——行为不变,且不阻塞其它请求。
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
@@ -164,6 +164,6 @@ async def podcast_hot(_user: LoginUser, request: Request):
|
||||
categories = await asyncio.to_thread(podcast_api.get_categories)
|
||||
except APIException:
|
||||
categories = []
|
||||
# 满页视为可能还有更多(与 Flask 同口径)
|
||||
# 满页视为可能还有更多
|
||||
return success({'radios': radios, 'categories': categories,
|
||||
'has_more': len(radios) >= limit}, '获取热门播客成功')
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""注册 / 找回密码 / 卡密 / 验证码(FastAPI 栈)——与 Flask 侧响应契约逐字段对齐。
|
||||
"""注册 / 找回密码 / 卡密 / 验证码。
|
||||
|
||||
已迁移:
|
||||
端点:
|
||||
- POST /api/v1/auth/send_code ↔ Flask /auth/send_code
|
||||
- POST /api/v1/auth/register ↔ Flask /auth/register
|
||||
- POST /api/v1/auth/trial ↔ Flask /auth/trial
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
说明:
|
||||
- 同步的 auth_system 调用(SQLite 事务、邮件 SMTP、PBKDF2)经 asyncio.to_thread 执行,
|
||||
不阻塞事件循环;行为与 Flask 完全一致。
|
||||
不阻塞事件循环;行为与历史实现完全一致。
|
||||
- 发码 IP 限流复用 web.auth_state 的模块级状态 `_SEND_CODE_IP`。
|
||||
- 失败锁定/验证码消费等安全口径逐字保留。
|
||||
"""
|
||||
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""站点数据端点(FastAPI 栈):走 core.db 异步 SQLite(读并发/写串行/WAL)。
|
||||
"""站点数据端点:走 core.db 异步 SQLite(读并发/写串行/WAL)。
|
||||
|
||||
这些是**新增**的 `/api/v1` 只读端点,用于验证异步读写路径与共享同一份站点库;
|
||||
不替代、不影响 Flask 侧既有端点。鉴权端点(需登录/管理员)待会话互通层就绪后再迁移。
|
||||
这些是 `/api/v1` 下的只读端点,走异步读写路径并共享同一份站点库;
|
||||
鉴权端点(需登录/管理员)由 web.deps 统一把关。
|
||||
|
||||
注意:本模块只做读(与 announcements/settings 这些低风险资源对应),
|
||||
写操作仍归 auth_system 的事务口径,避免迁移期两套写路径互相踩踏。
|
||||
写操作统一归 auth_system 的事务口径,避免两套写路径互相踩踏。
|
||||
"""
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""音频流代理(FastAPI 栈)——与 Flask /audio_stream 行为逐项对齐。
|
||||
"""音频流代理。
|
||||
|
||||
要点(对齐 Flask 实现):
|
||||
- 签名校验:token 由 core.security 校验(密钥同源,两栈 token 互认);失效 403。
|
||||
要点:
|
||||
- 签名校验:token 由 core.security 校验(密钥同源);失效 403。
|
||||
- HEAD 无正文:直接 200 + Accept-Ranges,不请求上游。
|
||||
- Range 透传:客户端 Range 原样转发上游,206/Content-Range 原样回传(拖动进度可用)。
|
||||
- 编码透明:上游若带 Content-Encoding(aiohttp 会解压),不得沿用压缩前的
|
||||
Content-Length/Content-Range(否则头体不一致,同 Flask F92)。
|
||||
Content-Length/Content-Range(否则头体不一致)。
|
||||
- 流式转发:16KB 分块,不整段入内存;下游中断时 finally 关闭上游响应。
|
||||
- Cache-Control: no-store。
|
||||
"""
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""系统端点(迁移样板):/health、/api/info。
|
||||
"""系统端点:/health、/api/info。
|
||||
|
||||
/flask 侧的这两个端点无鉴权、无状态、无任务依赖,是最安全的迁移起点。
|
||||
此处实现与 main.py 的 health_check / api_info **响应契约逐字段对齐**,
|
||||
用于验证双框架一致性(见 .zcode/refactor/parity.py)。
|
||||
两个端点无鉴权、无状态、无任务依赖。
|
||||
响应契约与历史快照逐字段对齐,
|
||||
由 tests/golden/responses.json 兜住回归。
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -16,7 +16,7 @@ from web.response import error, success
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 与 main.py api_info 的 endpoints 表保持一致(迁移期以 Flask 侧为准,逐字对齐)
|
||||
# 与 api_info 的 endpoints 表保持一致(逐字对齐历史契约)
|
||||
_ENDPOINTS = {
|
||||
'/health': 'GET - 健康检查',
|
||||
'/song': 'GET/POST - 获取歌曲信息',
|
||||
@@ -51,7 +51,7 @@ def health_check():
|
||||
'timestamp': int(time.time()) if 'time' in sys.modules else None,
|
||||
'cookie_status': cookie_status,
|
||||
'cookie_count': len(cookies),
|
||||
'downloads_dir': 'configured', # R5-P3:不向匿名访客回显服务器绝对路径
|
||||
'downloads_dir': 'configured', # 不向匿名访客回显服务器绝对路径
|
||||
'version': APP_VERSION,
|
||||
}
|
||||
return success(health_info, "API服务运行正常")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""任务状态端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
|
||||
"""任务状态端点。
|
||||
|
||||
已迁移:
|
||||
端点:
|
||||
- POST/GET /api/v1/topen ↔ Flask /api/topen(请求统计,无鉴权)
|
||||
- POST/GET /api/v1/queue ↔ Flask /api/queue(排队状态,无鉴权)
|
||||
- POST/GET /api/v1/batch_progress ↔ Flask /api/batch_progress(需登录,仅本人任务)
|
||||
@@ -9,8 +9,8 @@
|
||||
- POST/GET /api/v1/task/heartbeat ↔ Flask /api/task/heartbeat(需登录)
|
||||
- POST/GET /api/v1/task/list ↔ Flask /api/task/list(需登录)
|
||||
|
||||
这些端点读写 core.state 的共享任务表(与 Flask 同一批对象),因此两栈在迁移期
|
||||
看到的是同一份任务状态——这是「单进程双框架」能成立的关键。
|
||||
这些端点读写 core.state 的共享任务表(与任务执行侧同一批对象),因此
|
||||
看到的是同一份任务状态。
|
||||
"""
|
||||
import time
|
||||
from typing import Annotated, Any
|
||||
@@ -41,7 +41,7 @@ LoginUser = Annotated[SiteUser, Depends(require_login)]
|
||||
|
||||
|
||||
def _touch_device(device_id: str) -> None:
|
||||
"""记录设备心跳(与 Flask _touch_device 同口径,共享 DEVICE_LAST_SEEN)。"""
|
||||
"""记录设备心跳(与 core.state 的 DEVICE_LAST_SEEN 共享)。"""
|
||||
device_id = str(device_id or '')[:64]
|
||||
if not device_id:
|
||||
return
|
||||
@@ -57,7 +57,7 @@ def _owned_by(task: dict[str, Any], user: SiteUser) -> bool:
|
||||
|
||||
|
||||
def _semaphores():
|
||||
"""批量打包/下载信号量(Flask 侧对象,懒取以共享同一计数)。"""
|
||||
"""批量打包/下载信号量(懒取,与 core.state 中的同一对象共享计数)。"""
|
||||
import services.runtime as services
|
||||
return services.BATCH_ZIP_SEM, services.api_service.downloader._download_semaphore
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""个人账号与公开播放端点(FastAPI 栈)——与 Flask 侧响应契约逐字段对齐。
|
||||
"""个人账号与公开播放端点。
|
||||
|
||||
已迁移:
|
||||
端点:
|
||||
- POST /api/v1/user/qr/create ↔ Flask /user/qr/create(扫码登录二维码 SVG)
|
||||
- POST /api/v1/user/qr/check ↔ Flask /user/qr/check(轮询登录状态)
|
||||
- POST /api/v1/user/status ↔ Flask /user/status
|
||||
|
||||
+2
-2
@@ -5,13 +5,13 @@ from typing import Any
|
||||
|
||||
from core.state import REQ_LOG
|
||||
|
||||
# 站点用户公开字段(与 Flask auth_system._user_public 的非 full 口径一致)
|
||||
# 站点用户公开字段
|
||||
_PUBLIC_FIELDS = ('id', 'username', 'email', 'role', 'duration_seconds',
|
||||
'created_at', 'last_login', 'ann_seen')
|
||||
|
||||
|
||||
def format_credits(count: int) -> str:
|
||||
"""次数文案(与 Flask auth_system.format_credits 逐字一致)。"""
|
||||
"""次数文案。"""
|
||||
if count < 0:
|
||||
return '不限次数'
|
||||
if count <= 0:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""下载/播客任务登记(FastAPI 栈)——镜像 main._register_download_task。
|
||||
"""下载/播客任务登记。
|
||||
|
||||
与 Flask 版的唯一差异:不依赖 flask.g,请求级账本键由 Billing 显式携带。
|
||||
与原实现的唯一差异:不依赖请求上下文(flask.g),请求级账本键由 Billing 显式携带。
|
||||
其余(归属、容量闸、task_id 白名单与碰撞换新、账本承接与撞名重试)逐条对齐。
|
||||
"""
|
||||
import logging
|
||||
@@ -46,7 +46,7 @@ async def register_download_task(billing: Billing, task: dict[str, Any],
|
||||
task['task_id'] = tid
|
||||
DOWNLOAD_TASKS[tid] = task
|
||||
|
||||
# 账本承接(撞名重试,与 Flask 同口径)
|
||||
# 账本承接(撞名重试)
|
||||
try:
|
||||
renamed = await billing.rename_to(tid)
|
||||
retries = 0
|
||||
|
||||
Reference in new issue
Block a user