Files

478 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
订阅业务活动事实、限量轮次明细和持久化端口。
"""
from __future__ import annotations
import copy
import hashlib
from collections.abc import Iterable, Iterator, Mapping
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from threading import RLock
from typing import Protocol, cast
from uuid import uuid4
from app.application.subscription.contract import SubscriptionHistorySnapshot, SubscriptionSnapshot
from app.application.subscription.sitebudget import SubscriptionSearchCancelled, SubscriptionSearchDeferred
from app.runtime.log import logger
from app.schemas.common import JsonData
RULE_FIELDS = (
"include", "exclude", "quality", "resolution", "effect", "sites", "filter_groups",
"best_version", "best_version_full", "start_episode", "total_episode", "custom_words",
"audio_quality", "audio_format", "min_bitrate", "min_bit_depth", "min_sample_rate",
)
EDIT_FIELDS = RULE_FIELDS + ("keyword", "search_interval", "state", "downloader", "save_path", "episode_group")
DETAIL_LIMIT = 100
DECISION_RANKS = {"related": 0, "rejected": 1, "qualified": 2, "failed": 3, "submitted": 4}
def _decision_rank(decision: str) -> int:
"""
按业务重要性排列资源摘要,普通相关和被拒绝项优先让出有界存储。
"""
return DECISION_RANKS.get(decision, 0)
def _detail_objects(details: Mapping[str, JsonData], field_name: str) -> list[dict[str, JsonData]]:
"""
恢复由当前服务写入的 JSON 对象列表,忽略旧记录中的不兼容值。
"""
values = details.get(field_name)
return [value for value in values if isinstance(value, dict)] if isinstance(values, list) else []
def activity_time() -> str:
"""
返回可排序的 UTC 活动时间。
"""
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
@dataclass(frozen=True, slots=True)
class SubscriptionActivity:
"""
脱离数据库会话的单订阅业务事件快照。
:param activity_key: 贯穿活动订阅与历史订阅的业务活动归属键
:param event_id: 同一业务轮次的稳定事件标识
:param kind: 业务活动类型,例如搜索、匹配或订阅变更
:param source: 触发本轮执行的入口,例如手工搜索或自动调度
:param status: 当前轮次的运行或结束状态
:param started_at: 活动开始的 UTC 时间文本
:param finished_at: 完成执行的时间文本;尚未完成时为 None
:param counts: 按业务分类聚合的数量
:param details: 供用户查看的有限量业务明细
:param tracking: 供恢复轮次使用的内部去重标识列表,按跟踪类型分组
"""
activity_key: str
event_id: str
kind: str
source: str
status: str
started_at: str
finished_at: str | None = None
counts: dict[str, int] = field(default_factory=dict)
details: dict[str, JsonData] = field(default_factory=dict)
tracking: dict[str, list[str]] = field(default_factory=dict)
class SubscriptionActivityRepository(Protocol):
"""
后台轮次写入与带订阅归属校验的分页读取端口。
"""
def save(self, event: SubscriptionActivity) -> None:
"""
在独立短事务中新增或更新同一轮次。
:param event: 要写入的单订阅业务活动快照
"""
...
def get(self, activity_key: str, event_id: str) -> SubscriptionActivity | None:
"""
读取同订阅同任务的已保存进度,恢复时继续累计同一轮次。
:param activity_key: 活动订阅与历史订阅共用的活动归属键
:param event_id: 同一业务轮次的稳定事件标识
:return: 同一活动归属键和事件标识下的已保存快照;未保存时返回 None
"""
...
async def list_accessible(
self, subscription_id: int, username: str, superuser: bool, *,
history: bool, kind: str | None, since: str | None, before: str | None, limit: int,
) -> tuple[list[SubscriptionActivity], str | None] | None:
"""
先确认活动或历史订阅归属,再返回稳定游标分页。
:param subscription_id: 目标订阅或订阅历史的数据库主键
:param username: 发起读取的本地用户名,用于校验订阅归属
:param superuser: 当前用户是否具有跨用户访问权限
:param history: 是否按订阅历史记录校验归属
:param kind: 要筛选的活动类型;None 表示不限类型
:param since: 活动开始时间下界;None 表示不限制
:param before: 上一页返回的稳定游标;None 表示读取第一页
:param limit: 最多返回的记录数量
:return: 活动列表与下一页游标;无后续页时游标为 None,订阅不存在或无权访问时返回 None
"""
...
async def get_accessible(
self, subscription_id: int, event_id: str, username: str, superuser: bool, *, history: bool,
) -> SubscriptionActivity | None:
"""
按订阅归属和事件身份读取单条完整详情。
:param subscription_id: 目标订阅或订阅历史的数据库主键
:param event_id: 同一业务轮次的稳定事件标识
:param username: 发起读取的本地用户名,用于校验订阅归属
:param superuser: 当前用户是否具有跨用户访问权限
:param history: 是否按订阅历史记录校验归属
:return: 完整活动详情;订阅或活动不存在,或无权访问时返回 None
"""
...
def lifecycle_activity(
snapshot: SubscriptionSnapshot | SubscriptionHistorySnapshot, kind: str, *, old: Mapping[str, JsonData] | None = None,
) -> SubscriptionActivity | None:
"""
构造创建、修改或完成事实;无可见字段变更不制造活动。
"""
if not snapshot.activity_key:
return None
changes: list[JsonData] = []
current = snapshot.to_dict()
if old is not None:
changes = [
{"field": key, "before": old.get(key), "after": current.get(key)}
for key in EDIT_FIELDS if old.get(key) != current.get(key)
]
if not changes:
return None
kind = "status" if all(isinstance(item, dict) and item["field"] == "state" for item in changes) else "updated"
now = activity_time()
return SubscriptionActivity(
activity_key=snapshot.activity_key, event_id=uuid4().hex, kind=kind,
source="user" if kind in {"created", "updated", "status"} else "system",
status="completed", started_at=now, finished_at=now,
details={"changes": changes, "rules": {key: current.get(key) for key in RULE_FIELDS}},
)
def queued_activity(subscribe: SubscriptionSnapshot, task_id: str, source: str,
retry_at: str | None) -> SubscriptionActivity | None:
"""
在搜索请求入队时记录真实请求时间,与恢复执行共用任务事件身份。
"""
if not subscribe.activity_key:
return None
run = SubscriptionActivityRun(subscribe, "search", source, task_id)
run.status = "waiting"
run.details["phase"] = "queued"
run.details["reason"] = "搜索请求已安排,系统会按顺序处理"
if retry_at:
run.details["retry_at"] = retry_at
return run.snapshot()
def settled_activity(event: SubscriptionActivity, state: str, finished_at: str | None) -> SubscriptionActivity | None:
"""
以队列终态收口尚未进入处理的事件,不把取消和暂停跳过遗留为等待。
"""
if event.status not in {"running", "waiting"} or state not in {"completed", "failed", "cancelled", "skipped"}:
return None
status = "cancelled" if state in {"cancelled", "skipped"} else state
details = dict(event.details)
details.pop("retry_at", None)
details["reason"] = "本次搜索已停止" if status == "cancelled" else "本次搜索处理失败" if status == "failed" else "本次搜索已处理结束"
return replace(event, status=status, finished_at=finished_at or activity_time(), details=details)
class SubscriptionActivityRun:
"""
线程安全地累计一轮业务结果,只持久化安全且有上限的资源摘要。
"""
def __init__(self, subscribe: SubscriptionSnapshot, kind: str, source: str, run_id: str | None = None) -> None:
"""
冻结本轮订阅条件并初始化各处理阶段的独立计数。
"""
self.subscribe = subscribe
self.event_id = run_id or uuid4().hex
self.kind = kind
self.source = source
self.started_at = activity_time()
self.status = "running"
self.finished_at: str | None = None
self.counts: dict[str, int] = {"sites": 0, "requested_sites": 0, "discovered": 0, "related": 0, "qualified": 0, "submitted": 0,
"initial_submitted": 0, "upgrade_submitted": 0, "full_upgrade_submitted": 0}
self.details: dict[str, JsonData] = {
"rules": {key: subscribe.to_dict().get(key) for key in RULE_FIELDS},
"resources": [], "sites": [],
}
self._resources: dict[str, dict[str, JsonData]] = {}
self._related: set[str] = set()
self._qualified: set[str] = set()
self._submitted: set[str] = set()
self._failed: set[str] = set()
self._sites: dict[int, dict[str, JsonData]] = {}
self._requested_sites: set[int] = set()
self._planned_sites: set[int] = set()
self._lock = RLock()
def resource(self, context: object, decision: str, reason: str | None = None) -> None:
"""
按站点和资源稳定键去重,保留标题、阶段和用户可理解的原因。
"""
torrent = getattr(context, "torrent_info", context)
site_id = getattr(torrent, "site", None)
title = str(getattr(torrent, "title", ""))[:500]
key = hashlib.sha256(f"{site_id}:{getattr(torrent, 'tid', None) or title}".encode()).hexdigest()[:32]
with self._lock:
previous = self._resources.get(key)
if key not in self._related:
self._related.add(key)
self.counts["related"] += 1
elif previous is not None and previous["decision"] == decision and previous.get("reason") == reason:
return
if decision in {"qualified", "submitted"} and key not in self._qualified:
self._qualified.add(key)
self.counts["qualified"] += 1
if decision == "submitted" and key not in self._submitted:
self._submitted.add(key)
self.counts["submitted"] += 1
submission = getattr(context, "subscription_download", None) or {}
submission_type = submission.get("submission_type")
if submission_type in {"initial", "upgrade"}:
self.counts[f"{submission_type}_submitted"] += 1
if submission_type == "upgrade" and submission.get("best_version_full"):
self.counts["full_upgrade_submitted"] += 1
self._failed.discard(key)
if decision == "failed" and key not in self._submitted:
self._failed.add(key)
self.counts["download_failed"] = len(self._failed)
if key in self._submitted:
decision, reason = "submitted", None
# 跟踪去重键不保留下载地址、passkey、磁力链接或站点原始响应。
self._make_detail_room(key, decision)
if key in self._resources or len(self._resources) < DETAIL_LIMIT:
self._resources[key] = {
"resource_id": key, "title": title, "site": str(getattr(torrent, "site_name", "") or ""),
"decision": decision, "reason": reason,
"subscription_download": copy.deepcopy(getattr(context, "subscription_download", None)
or (previous or {}).get("subscription_download"))
if decision == "submitted" else None,
}
resources: list[dict[str, JsonData]] = sorted(
self._resources.values(),
key=lambda item: -_decision_rank(str(item["decision"])),
)
self.details["resources"] = cast(JsonData, resources)
self.details["resources_omitted"] = max(0, len(self._related) - DETAIL_LIMIT)
def _make_detail_room(self, key: str, decision: str) -> None:
"""
详情达到上限时优先保留提交、失败和符合条件资源,不能让拒绝项挤掉下载回执。
"""
if key in self._resources or len(self._resources) < DETAIL_LIMIT:
return
lowest = min(self._resources, key=lambda item: _decision_rank(str(self._resources[item]["decision"])))
if _decision_rank(decision) > _decision_rank(str(self._resources[lowest]["decision"])):
self._resources.pop(lowest)
def plan_sites(self, site_ids: Iterable[int]) -> None:
"""
累计同轮实际计划过的站点,恢复只补查一个站点时保留原计划分母。
"""
with self._lock:
self._planned_sites.update(site_ids)
self.counts["sites"] = max(self.counts["sites"], len(self._planned_sites))
def discover(self, candidates: int) -> None:
"""
累计插件等额外来源返回的候选项,与并发站点观察共用同一锁。
"""
with self._lock:
self.counts["discovered"] += candidates
def site(self, site_id: int, *, name: str = "", candidates: int = 0, status: str = "completed", attempted: bool | None = None) -> None:
"""
累计站点请求结果,与本订阅相关资源计数分别保存。
"""
with self._lock:
self._planned_sites.add(site_id)
self.counts["sites"] = max(self.counts["sites"], len(self._planned_sites))
previous = self._sites.get(site_id)
total = candidates + (int(cast(int, previous["candidates"])) if previous else 0)
did_request = attempted if attempted is not None else status != "waiting"
self._sites[site_id] = {"id": site_id, "name": name or (previous or {}).get("name") or str(site_id), "candidates": total, "status": status, "attempted": did_request}
if did_request:
self._requested_sites.add(site_id)
self.counts["requested_sites"] = len(self._requested_sites)
self.counts["discovered"] += candidates
self.details["sites"] = list(self._sites.values())
def snapshot(self) -> SubscriptionActivity:
"""
复制当前计数和明细,避免持久化过程与工作线程共享可变容器。
"""
with self._lock:
self.counts["failed_sites"] = sum(site["status"] in {"failed", "error"} for site in self._sites.values())
self.counts["waiting_sites"] = sum(site["status"] in {"waiting", "deferred"} for site in self._sites.values())
return SubscriptionActivity(
self.subscribe.activity_key or "", self.event_id, self.kind, self.source,
self.status, self.started_at, self.finished_at, dict(self.counts), copy.deepcopy(self.details),
{"related": sorted(self._related), "qualified": sorted(self._qualified), "submitted": sorted(self._submitted), "failed": sorted(self._failed),
"requested_sites": [str(site_id) for site_id in sorted(self._requested_sites)],
"planned_sites": [str(site_id) for site_id in sorted(self._planned_sites)]},
)
def restore(self, event: SubscriptionActivity) -> None:
"""
恢复累计事实和有界资源摘要,重试不会重新计算已统计的候选。
"""
self.started_at = event.started_at
self.counts.update(event.counts)
self.details.update(copy.deepcopy(event.details))
if event.details.get("phase") == "queued":
self.details["rules"] = {key: self.subscribe.to_dict().get(key) for key in RULE_FIELDS}
self._related.update(event.tracking.get("related", []))
self._qualified.update(event.tracking.get("qualified", []))
self._submitted.update(event.tracking.get("submitted", []))
self._failed.update(event.tracking.get("failed", []))
self._requested_sites.update(int(site_id) for site_id in event.tracking.get("requested_sites", []))
self._planned_sites.update(int(site_id) for site_id in event.tracking.get("planned_sites", []))
for resource in _detail_objects(self.details, "resources"):
self._resources[str(resource["resource_id"])] = resource
for site in _detail_objects(self.details, "sites"):
self._sites[int(cast(int, site["id"]))] = site
if site.get("attempted", site["status"] != "waiting") is True:
self._requested_sites.add(int(cast(int, site["id"])))
self.details.pop("retry_at", None)
self.details.pop("reason", None)
self.details.pop("pending_site_ids", None)
self.details.pop("phase", None)
_current_run: ContextVar[SubscriptionActivityRun | None] = ContextVar("subscription_activity_run", default=None)
def current_activity_run() -> SubscriptionActivityRun | None:
"""
读取当前订阅作用域,普通搜索和下载不会生成订阅事件。
"""
return _current_run.get()
class SubscriptionActivityService:
"""
在业务执行边界记录轮次,不把运行日志文本解释为用户事件。
"""
def __init__(self, repository: SubscriptionActivityRepository) -> None:
"""
注入结构化业务活动持久化端口。
"""
self.repository = repository
def save(self, run: SubscriptionActivityRun) -> None:
"""
尽力保存后台观测事实,观测故障不重复已提交的下载副作用。
"""
if not run.subscribe.activity_key:
return
try:
self.repository.save(run.snapshot())
except Exception:
logger.error("订阅活动保存失败", exc_info=True)
@staticmethod
def _finish_run(run: SubscriptionActivityRun) -> None:
"""
按下载回执与站点事实收口结果;部分失败不会被隐去或冒充全站点成功。
"""
if run.status != "running":
return
failures = run.snapshot().counts["failed_sites"]
if run.counts["submitted"]:
run.status = "completed"
elif run.counts.get("download_failed"):
run.status = "failed"
run.details["reason"] = "本轮下载提交未成功,可在资源详情中查看处理结果"
elif failures and failures == run.counts["sites"]:
run.status = "failed"
run.details["reason"] = "本轮站点请求均未成功,可在站点详情和运行日志中查看"
else:
run.status = "not_submitted" if run.counts["qualified"] else "empty"
if failures and "reason" not in run.details:
run.details["reason"] = f"本轮有 {failures} 个站点请求未成功,其余处理结果已保留"
@contextmanager
def scope(self, subscribe: SubscriptionSnapshot, kind: str, source: str, run_id: str | None = None) -> Iterator[SubscriptionActivityRun]:
"""
保存开始与结束事实,等待和失败由具体业务结果明确设置。
"""
run = SubscriptionActivityRun(subscribe, kind, source, run_id)
if run_id and subscribe.activity_key:
try:
previous = self.repository.get(subscribe.activity_key, run_id)
if previous is not None:
run.restore(previous)
except Exception:
logger.error("订阅活动进度读取失败", exc_info=True)
attempt_started = activity_time()
before = dict(run.counts)
token = _current_run.set(run)
self.save(run)
try:
yield run
self._finish_run(run)
except SubscriptionSearchDeferred as deferred:
run.status = "waiting"
run.details["retry_at"] = deferred.retry_at
run.details["reason"] = "站点暂时忙,系统会自动继续搜索"
run.details["pending_site_ids"] = list(deferred.site_ids)
raise
except SubscriptionSearchCancelled:
run.status = "cancelled"
run.details["reason"] = "本轮处理已停止,已提交的下载保持有效"
raise
except Exception:
run.status = "failed"
run.details["reason"] = "本轮处理出现异常,请查看运行日志了解原因"
raise
finally:
run.finished_at = activity_time()
attempts: list[JsonData] = list(_detail_objects(run.details, "attempts"))
attempts.append({"started_at": attempt_started, "finished_at": run.finished_at,
"status": run.status, "counts": {key: value - before.get(key, 0) for key, value in run.counts.items()},
"rules": {key: subscribe.to_dict().get(key) for key in RULE_FIELDS}})
run.details["attempts"] = attempts[-DETAIL_LIMIT:]
self.save(run)
_current_run.reset(token)
@contextmanager
def subscription_activity_scope(service: SubscriptionActivityService | None, subscribe: SubscriptionSnapshot,
kind: str, source: str, run_id: str | None = None) -> Iterator[SubscriptionActivityRun]:
"""
兼容显式注入的旧测试宿主;生产组合根始终提供持久化服务。
"""
if service is not None:
with service.scope(subscribe, kind, source, run_id) as run:
yield run
else:
run = SubscriptionActivityRun(subscribe, kind, source, run_id)
token = _current_run.set(run)
try:
yield run
finally:
_current_run.reset(token)