Archived
为什么:上一次分包重构把 1900 行巨石按职责拆成 9 个子模块后,留了一个 runtime.py 作为「原样再导出」的兼容门面,好让调用点零改动。但迁移期结束后 没有一个调用点迁走——门面成了永久中间层:每次读代码都要先跳一层才知道 符号真正住在哪,pyright 也无法从门面追溯定义位置。 改法:保持「函数内延迟导入」的形式不变,只把导入目标换成真实子模块。 **刻意不提到模块顶部**——services/__init__.py 里有依赖自检 (缺 auth_system/podcast_api/music_api/music_downloader/qr_login 就 sys.exit(1)), 提到顶层会让「import web.*」这一步就触发该自检,改变启动语义,超出本次范围。 7 处调用点(AST 精确扫描确认全集): - web/routers/download.py ×4:download / download_batch_zip / podcast_download / podcast_batch_zip,分别直连 services.downloads / batch / podcast / tasks 外加 _shared._build_download_base 与 api_service.api_service - web/routers/tasks.py ×2:_semaphores 取 _shared.BATCH_ZIP_SEM + api_service, topen 取 _shared.ENDPOINT_NAMES - web/tasks_registry.py ×1:start_task 直连 _taskcore._start_task_thread 子模块短名统一加 `services_` 前缀别名(services_tasks 等),避免与函数内 既有局部名相撞。 顺带清理:9 个子模块 docstring 里「(自 services/runtime.py 拆出……)」的 历史说明在门面删除后已成悬空引用,一并去掉;包的模块划分说明改为 「不再有兼容门面层」。 验证: - 符号可达:11 个被引符号在真实子模块上全部 hasattr 通过; services.runtime 已 ModuleNotFoundError;api_service 仍是同一实例对象 - 三个调用方模块均可正常 import - 门禁:ruff 0 · pyright 0 errors 0 warnings · pytest 175 passed - 真实 uvicorn 冒烟 8/8(含 POST /auth/login 400),临时数据目录独立落库 9 张表,生产 data/ 指纹三项一致
80 lines
3.2 KiB
Python
80 lines
3.2 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""下载/播客任务登记。
|
|
|
|
与原实现的唯一差异:不依赖请求上下文(flask.g),请求级账本键由 Billing 显式携带。
|
|
其余(归属、容量闸、task_id 白名单与碰撞换新、账本承接与撞名重试)逐条对齐。
|
|
"""
|
|
import logging
|
|
import re
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from typing import Any
|
|
|
|
from core.config import MAX_ACTIVE_TASKS_PER_USER, MAX_TERMINAL_TASKS, MAX_TOTAL_TASKS
|
|
from core.state import DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK
|
|
from web.billing import Billing
|
|
|
|
logger = logging.getLogger('web.tasks')
|
|
|
|
_TASK_ID_RE = re.compile(r'^[A-Za-z0-9_-]{1,32}$')
|
|
|
|
|
|
async def register_download_task(billing: Billing, task: dict[str, Any],
|
|
task_id_hint: str = '') -> str | None:
|
|
"""任务登记:失败返回错误描述,成功返回 None。task['task_id'] 为最终 id。"""
|
|
user_id = billing.user.id
|
|
task['user_id'] = user_id
|
|
task['billing_reserved'] = billing.need
|
|
|
|
with DOWNLOAD_TASKS_LOCK:
|
|
tid = (task_id_hint or '').strip() or uuid.uuid4().hex[:12]
|
|
if not _TASK_ID_RE.match(tid):
|
|
tid = uuid.uuid4().hex[:12]
|
|
while tid in DOWNLOAD_TASKS:
|
|
tid = uuid.uuid4().hex[:12]
|
|
active = sum(1 for t in DOWNLOAD_TASKS.values()
|
|
if t.get('user_id') == user_id
|
|
and t.get('status') in ('queued', 'running', 'paused'))
|
|
if active >= MAX_ACTIVE_TASKS_PER_USER:
|
|
return f'您有 {active} 个任务正在进行,请等待完成或取消后再提交'
|
|
active_total = sum(1 for t in DOWNLOAD_TASKS.values()
|
|
if t.get('status') in ('queued', 'running', 'paused'))
|
|
if active_total >= MAX_TOTAL_TASKS:
|
|
return '当前任务队列已满,请稍后再试'
|
|
if len(DOWNLOAD_TASKS) - active_total >= MAX_TERMINAL_TASKS:
|
|
return '当前任务队列已满,请稍后再试'
|
|
task['task_id'] = tid
|
|
DOWNLOAD_TASKS[tid] = task
|
|
|
|
# 账本承接(撞名重试)
|
|
try:
|
|
renamed = await billing.rename_to(tid)
|
|
retries = 0
|
|
while not renamed and retries < 5:
|
|
retries += 1
|
|
with DOWNLOAD_TASKS_LOCK:
|
|
DOWNLOAD_TASKS.pop(tid, None)
|
|
tid = uuid.uuid4().hex[:12]
|
|
while tid in DOWNLOAD_TASKS:
|
|
tid = uuid.uuid4().hex[:12]
|
|
task['task_id'] = tid
|
|
DOWNLOAD_TASKS[tid] = task
|
|
renamed = await billing.rename_to(tid)
|
|
if not renamed:
|
|
raise RuntimeError('任务账本键撞名,请重新提交')
|
|
billing.ledger_key = tid
|
|
except Exception:
|
|
with DOWNLOAD_TASKS_LOCK:
|
|
if DOWNLOAD_TASKS.get(tid) is task:
|
|
DOWNLOAD_TASKS.pop(tid, None)
|
|
raise
|
|
return None
|
|
|
|
|
|
async def start_task(task_id: str, worker: Callable, args: tuple = ()) -> str | None:
|
|
"""启动 worker 线程(复用 services 的 _start_task_thread,失败收敛终态并退费)。"""
|
|
import asyncio
|
|
|
|
from services._taskcore import _start_task_thread
|
|
return await asyncio.to_thread(_start_task_thread, task_id, worker, args)
|