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/ 指纹三项一致
111 lines
4.5 KiB
Python
111 lines
4.5 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""任务生命周期原语(启动 / 终态 / 文件收尾 / 结算)。
|
||
"""
|
||
|
||
import threading
|
||
import time
|
||
import traceback
|
||
from typing import Any
|
||
|
||
import auth_system
|
||
from core.state import (
|
||
DOWNLOAD_TASKS,
|
||
DOWNLOAD_TASKS_LOCK,
|
||
)
|
||
|
||
from .api_service import (
|
||
api_service,
|
||
)
|
||
|
||
|
||
def _start_task_thread(task_id: str, target, args=()) -> str | None:
|
||
"""启动任务 worker(启动失败时收敛任务终态并按账本退费,不留僵尸 queued 记录)。
|
||
退款只走账本(本处 settle 后装饰器看到账已关,不会重复退)。
|
||
线程句柄存入任务字典——监控循环对 4h 超时任务若发现 worker 线程已死
|
||
(无法自行走到终态结算),可代为按账本结算退款(settle 幂等,无双退)。"""
|
||
try:
|
||
th = threading.Thread(target=target, args=args, daemon=True)
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.get(task_id)
|
||
if t is not None:
|
||
t['worker_thread'] = th
|
||
th.start()
|
||
return None
|
||
except Exception as e:
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.pop(task_id, None)
|
||
if t:
|
||
try:
|
||
auth_system.ledger_settle(task_id, 0, 'submit_failed')
|
||
except Exception:
|
||
pass
|
||
api_service.logger.error(f"任务线程启动失败: {task_id} {e}")
|
||
return f'任务启动失败:{e}'
|
||
|
||
|
||
def _mark_task_terminal(task_id: str, status: str, msg: str = '') -> None:
|
||
"""任务进入终态的统一入口(记录 completed_at,保留期从完成时刻起算,
|
||
而不是从提交时刻起算)。
|
||
/F-E:置 error/canceled 终态时若 worker 线程已死(无人再走正常结算),
|
||
代为按账本结算退款——ledger_settle 幂等,worker 已自行结算过则此处为 0。
|
||
终态先到先得——已终态(done/error/canceled)的任务
|
||
不再被覆盖(堵住 canceled→error 的非法迁移边:取消后解析阶段的自身失败
|
||
不得把"已取消"改写成"失败",completed_at 也不再刷新)。"""
|
||
need_settle = False
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.get(task_id)
|
||
if t and t.get('status') in ('done', 'error', 'canceled'):
|
||
return
|
||
if t:
|
||
t['status'] = status
|
||
t['msg'] = msg or t.get('msg', '')
|
||
t['completed_at'] = time.time()
|
||
if status in ('canceled', 'error'):
|
||
th = t.get('worker_thread')
|
||
if th is not None and not th.is_alive():
|
||
need_settle = True
|
||
# settle 移到任务锁外——ledger_settle 内部要抢 _DB_LOCK 写库
|
||
# (busy 最长 15s),持任务锁调用会让全站进度/提交/取消/领取随库停摆;
|
||
# settle 幂等,锁外调用无双退
|
||
if need_settle:
|
||
try:
|
||
auth_system.ledger_settle(task_id, 0, status)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _task_files(task: dict[str, Any]) -> list[str]:
|
||
"""任务当前关联的可清理文件路径(交付 ZIP/交付副本;缓存文件不在此列)"""
|
||
result = task.get('result') or {}
|
||
files = []
|
||
for f in (result.get('zip_path'), result.get('file_path')):
|
||
if f:
|
||
files.append(f)
|
||
return files
|
||
|
||
|
||
def _settle_batch_billing(task_id: str, ok_count: int, final_status: str = 'settled') -> int:
|
||
"""批量任务结算(走持久账本,按实际预扣-实际交付幂等退款;
|
||
重复调用/重启后调用都只会退一次)"""
|
||
try:
|
||
refund = auth_system.ledger_settle(task_id, ok_count, final_status)
|
||
except Exception:
|
||
# 本函数多在异常处理器内被调用——结算再抛错(库忙超时)
|
||
# 会杀死 worker 收尾路径,跳过终态标记;失败留周期对账兜底(幂等)
|
||
api_service.logger.error(
|
||
f"批量结算异常(留对账兜底): {task_id}\n{traceback.format_exc()}")
|
||
return 0
|
||
if refund:
|
||
try:
|
||
with DOWNLOAD_TASKS_LOCK:
|
||
t = DOWNLOAD_TASKS.get(task_id) or {}
|
||
uid = int(t.get('billing_user_id') or 0)
|
||
u = auth_system.get_user_by_id(uid) if uid else None
|
||
uname = u['username'] if u else 'system'
|
||
remaining = u['duration_seconds'] if u else '?'
|
||
auth_system.add_log('success', uname, 'system', '批量退费',
|
||
f'失败部分 {refund} 次已退回,剩余 {remaining} 次')
|
||
except Exception:
|
||
pass
|
||
return refund
|