mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-01 04:47:03 +08:00
Merge pull request #6509 from InfinityPacer/codex/fix/history-partial-failure-v3
This commit is contained in:
@@ -93,6 +93,7 @@ def get_transfer_history_mutation_command(
|
||||
download_repository=runtime.history.download_repository(db),
|
||||
unit_of_work=runtime.persistence.sync_transaction(db),
|
||||
file_item_factory=lambda payload: _SchemaFileItem(**payload),
|
||||
file_exists=storage_chain.exists,
|
||||
delete_media_file=storage_chain.delete_media_file,
|
||||
publish_download_file_deleted=lambda payload: eventmanager.send_event(
|
||||
EventType.DownloadFileDeleted,
|
||||
|
||||
@@ -49,6 +49,7 @@ from app.schemas.common import ProgressKeyData as _SchemaProgressKeyData
|
||||
from app.schemas.history import BatchTransferHistoryRedoRequest as _SchemaBatchTransferHistoryRedoRequest
|
||||
from app.schemas.history import DownloadHistory as _SchemaDownloadHistory
|
||||
from app.schemas.history import TransferHistory as _SchemaTransferHistory
|
||||
from app.schemas.history import TransferHistoryDeleteResult as _SchemaTransferHistoryDeleteResult
|
||||
from app.schemas.history import TransferHistoryPage as _SchemaTransferHistoryPage
|
||||
from app.schemas.response import Response as _SchemaResponse
|
||||
from app.schemas.token import TokenPayload as _SchemaTokenPayload
|
||||
@@ -399,7 +400,11 @@ async def transfer_history(
|
||||
return _SchemaResponse(success=True, data=result)
|
||||
|
||||
|
||||
@router.delete("/transfer", summary="删除整理记录", response_model=_SchemaResponse[None])
|
||||
@router.delete(
|
||||
"/transfer",
|
||||
summary="删除整理记录",
|
||||
response_model=_SchemaResponse[_SchemaTransferHistoryDeleteResult],
|
||||
)
|
||||
def delete_transfer_history(
|
||||
history_in: _SchemaTransferHistory,
|
||||
deletesrc: Optional[bool] = False,
|
||||
@@ -417,7 +422,11 @@ def delete_transfer_history(
|
||||
delete_source=bool(deletesrc),
|
||||
delete_destination=bool(deletedest),
|
||||
)
|
||||
return _SchemaResponse(success=result.success, message=result.message)
|
||||
return _SchemaResponse(
|
||||
success=result.success,
|
||||
message=result.message,
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, NoReturn, Optional, Protocol, Union
|
||||
|
||||
from app.application.configuration import TransferRetryConfig, get_transfer_retry_config
|
||||
from app.application.historymutation import (
|
||||
DownloadFileMutationRepository as DownloadFileMutationRepository,
|
||||
)
|
||||
from app.application.historymutation import (
|
||||
DownloadHistoryMutationCommand as DownloadHistoryMutationCommand,
|
||||
)
|
||||
from app.application.historymutation import (
|
||||
DownloadHistoryMutationRepository as DownloadHistoryMutationRepository,
|
||||
)
|
||||
from app.application.historymutation import (
|
||||
HistoryMutationResult as HistoryMutationResult,
|
||||
)
|
||||
from app.application.historymutation import (
|
||||
HistoryUnitOfWork as HistoryUnitOfWork,
|
||||
)
|
||||
from app.application.historymutation import (
|
||||
TransferHistoryMutationCommand as TransferHistoryMutationCommand,
|
||||
)
|
||||
from app.application.historymutation import (
|
||||
TransferHistoryMutationRepository as TransferHistoryMutationRepository,
|
||||
)
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
@@ -35,14 +55,6 @@ FAILED_RETRY_TTL = 24 * 3600
|
||||
_failed_retry_counts = TTLCache(region="transfer_failed_retry", maxsize=5000, ttl=FAILED_RETRY_TTL)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HistoryMutationResult:
|
||||
"""描述历史记录维护操作是否成功及兼容提示。"""
|
||||
|
||||
success: bool
|
||||
message: str = ""
|
||||
|
||||
|
||||
class _FrozenJsonDict(dict[str, JsonData]):
|
||||
"""保留 JSON 字典读取与序列化行为,并拒绝常规原地修改。"""
|
||||
|
||||
@@ -697,13 +709,6 @@ class HistoryQueryService:
|
||||
status: Optional[bool] = None,
|
||||
) -> TransferHistoryPage:
|
||||
"""应用历史筛选规则并返回整理历史分页 DTO。"""
|
||||
if title == "失败":
|
||||
title = None
|
||||
status = False
|
||||
elif title == "成功":
|
||||
title = None
|
||||
status = True
|
||||
|
||||
if title:
|
||||
wildcard = "*" in title or "?" in title
|
||||
if wildcard:
|
||||
@@ -764,168 +769,6 @@ class HistoryQueryService:
|
||||
return result.replace("*", "%").replace("?", "_")
|
||||
|
||||
|
||||
class DownloadHistoryMutationRepository(Protocol):
|
||||
"""下载历史删除用例需要的最小持久化端口。"""
|
||||
|
||||
def stage_delete_history(self, history_id: int) -> None:
|
||||
"""暂存下载历史删除。"""
|
||||
...
|
||||
|
||||
|
||||
class TransferHistoryMutationRepository(Protocol):
|
||||
"""整理历史删除与清理用例需要的最小持久化端口。"""
|
||||
|
||||
def get(self, history_id: int) -> Optional[TransferHistorySnapshot]:
|
||||
"""读取整理历史。"""
|
||||
...
|
||||
|
||||
def stage_delete(self, history_id: int) -> None:
|
||||
"""暂存整理历史删除。"""
|
||||
...
|
||||
|
||||
def stage_truncate(self) -> None:
|
||||
"""暂存全部整理历史删除。"""
|
||||
...
|
||||
|
||||
|
||||
class DownloadFileMutationRepository(Protocol):
|
||||
"""整理历史删除时关联下载文件状态更新端口。"""
|
||||
|
||||
def stage_delete_file_by_fullpath(self, fullpath: str) -> None:
|
||||
"""暂存下载文件删除状态。"""
|
||||
...
|
||||
|
||||
|
||||
class HistoryUnitOfWork(Protocol):
|
||||
"""同步历史维护用例使用的事务端口。"""
|
||||
|
||||
def commit(self) -> None:
|
||||
"""提交当前事务。"""
|
||||
...
|
||||
|
||||
def rollback(self) -> None:
|
||||
"""回滚当前事务。"""
|
||||
...
|
||||
|
||||
|
||||
class DownloadHistoryMutationCommand:
|
||||
"""统一提交下载历史删除,避免 API 直接持有数据库事务。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: DownloadHistoryMutationRepository,
|
||||
unit_of_work: HistoryUnitOfWork,
|
||||
) -> None:
|
||||
"""保存下载历史持久化和事务端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
|
||||
def delete(self, history_id: int) -> HistoryMutationResult:
|
||||
"""暂存并提交单条下载历史删除。"""
|
||||
self._repository.stage_delete_history(history_id)
|
||||
self._commit()
|
||||
return HistoryMutationResult(True)
|
||||
|
||||
def _commit(self) -> None:
|
||||
"""提交事务,失败时回滚。"""
|
||||
try:
|
||||
self._unit_of_work.commit()
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
class TransferHistoryMutationCommand:
|
||||
"""协调整理历史、关联文件状态和外部存储删除。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: TransferHistoryMutationRepository,
|
||||
download_repository: DownloadFileMutationRepository,
|
||||
unit_of_work: HistoryUnitOfWork,
|
||||
file_item_factory: Callable[[dict[str, JsonData]], Any],
|
||||
delete_media_file: Callable[[Any], bool],
|
||||
publish_download_file_deleted: Callable[[dict[str, Any]], None],
|
||||
clear_failures: Callable[[Optional[str], Optional[str]], None],
|
||||
) -> None:
|
||||
"""保存历史事务、存储删除、事件和失败状态清理端口。"""
|
||||
self._repository = repository
|
||||
self._download_repository = download_repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._file_item_factory = file_item_factory
|
||||
self._delete_media_file = delete_media_file
|
||||
self._publish_download_file_deleted = publish_download_file_deleted
|
||||
self._clear_failures = clear_failures
|
||||
|
||||
def delete(
|
||||
self,
|
||||
history_id: int,
|
||||
*,
|
||||
delete_source: bool = False,
|
||||
delete_destination: bool = False,
|
||||
) -> HistoryMutationResult:
|
||||
"""删除整理记录,并保持源文件失败时不提交数据库变更。"""
|
||||
history = self._repository.get(history_id)
|
||||
if not history:
|
||||
return HistoryMutationResult(False, "记录不存在")
|
||||
if getattr(history, "transfer_task_id", None):
|
||||
return HistoryMutationResult(
|
||||
False,
|
||||
"持久整理失败记录不可删除,请使用重试或人工复核入口",
|
||||
)
|
||||
|
||||
if delete_destination and history.dest_fileitem:
|
||||
destination_payload = self._file_item_payload(history.dest_fileitem)
|
||||
if destination_payload is None:
|
||||
return HistoryMutationResult(False, "目标文件历史数据无效")
|
||||
destination = self._file_item_factory(destination_payload)
|
||||
self._delete_media_file(destination)
|
||||
|
||||
source_deleted = False
|
||||
if delete_source and history.src_fileitem:
|
||||
source_payload = self._file_item_payload(history.src_fileitem)
|
||||
if source_payload is None:
|
||||
return HistoryMutationResult(False, "源文件历史数据无效")
|
||||
source = self._file_item_factory(source_payload)
|
||||
if not self._delete_media_file(source):
|
||||
return HistoryMutationResult(False, f"{source.path} 删除失败")
|
||||
self._download_repository.stage_delete_file_by_fullpath(
|
||||
Path(source.path).as_posix()
|
||||
)
|
||||
source_deleted = True
|
||||
|
||||
self._repository.stage_delete(history_id)
|
||||
self._commit()
|
||||
if source_deleted:
|
||||
self._publish_download_file_deleted({
|
||||
"src": history.src,
|
||||
"hash": history.download_hash,
|
||||
})
|
||||
self._clear_failures(history.src, history.src_storage)
|
||||
return HistoryMutationResult(True)
|
||||
|
||||
@staticmethod
|
||||
def _file_item_payload(value: JsonData) -> Optional[dict[str, JsonData]]:
|
||||
"""仅接受可安全构造文件项的历史 JSON 对象。"""
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
def truncate(self) -> HistoryMutationResult:
|
||||
"""在单一事务中清空旧历史,并保留当前失败任务记录。"""
|
||||
self._repository.stage_truncate()
|
||||
self._commit()
|
||||
return HistoryMutationResult(True, "已清空旧整理记录,失败任务记录已保留")
|
||||
|
||||
def _commit(self) -> None:
|
||||
"""提交历史事务,失败时回滚且不发布事件或清缓存。"""
|
||||
try:
|
||||
self._unit_of_work.commit()
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
class HistoryGateAction:
|
||||
"""
|
||||
整理历史查重闸的判定结果。
|
||||
|
||||
277
app/application/historymutation.py
Normal file
277
app/application/historymutation.py
Normal file
@@ -0,0 +1,277 @@
|
||||
"""历史删除命令及其事务端口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional, Protocol
|
||||
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.history import TransferHistoryDeleteResult, TransferHistoryDeleteStep
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HistoryMutationResult:
|
||||
"""描述历史记录维护操作是否成功及兼容提示。"""
|
||||
|
||||
success: bool
|
||||
message: str = ""
|
||||
|
||||
|
||||
class DownloadHistoryMutationRepository(Protocol):
|
||||
"""下载历史删除用例需要的最小持久化端口。"""
|
||||
|
||||
def stage_delete_history(self, history_id: int) -> None:
|
||||
"""暂存下载历史删除。"""
|
||||
...
|
||||
|
||||
|
||||
class TransferHistoryMutationRecord(Protocol):
|
||||
"""整理历史删除用例读取的稳定字段投影。"""
|
||||
|
||||
@property
|
||||
def transfer_task_id(self) -> Optional[str]:
|
||||
"""返回持久整理任务标识。"""
|
||||
...
|
||||
|
||||
@property
|
||||
def src(self) -> Optional[str]:
|
||||
"""返回源路径。"""
|
||||
...
|
||||
|
||||
@property
|
||||
def src_storage(self) -> Optional[str]:
|
||||
"""返回源存储标识。"""
|
||||
...
|
||||
|
||||
@property
|
||||
def src_fileitem(self) -> Optional[JsonData]:
|
||||
"""返回源文件项快照。"""
|
||||
...
|
||||
|
||||
@property
|
||||
def dest_fileitem(self) -> Optional[JsonData]:
|
||||
"""返回目标文件项快照。"""
|
||||
...
|
||||
|
||||
@property
|
||||
def download_hash(self) -> Optional[str]:
|
||||
"""返回下载任务 Hash。"""
|
||||
...
|
||||
|
||||
|
||||
class TransferHistoryMutationRepository(Protocol):
|
||||
"""整理历史删除与清理用例需要的最小持久化端口。"""
|
||||
|
||||
def get(self, history_id: int) -> Optional[TransferHistoryMutationRecord]:
|
||||
"""读取整理历史。"""
|
||||
...
|
||||
|
||||
def stage_delete(self, history_id: int) -> None:
|
||||
"""暂存整理历史删除。"""
|
||||
...
|
||||
|
||||
def stage_truncate(self) -> None:
|
||||
"""暂存全部整理历史删除。"""
|
||||
...
|
||||
|
||||
|
||||
class DownloadFileMutationRepository(Protocol):
|
||||
"""整理历史删除时关联下载文件状态更新端口。"""
|
||||
|
||||
def stage_delete_file_by_fullpath(self, fullpath: str) -> None:
|
||||
"""暂存下载文件删除状态。"""
|
||||
...
|
||||
|
||||
|
||||
class HistoryUnitOfWork(Protocol):
|
||||
"""同步历史维护用例使用的事务端口。"""
|
||||
|
||||
def commit(self) -> None:
|
||||
"""提交当前事务。"""
|
||||
...
|
||||
|
||||
def rollback(self) -> None:
|
||||
"""回滚当前事务。"""
|
||||
...
|
||||
|
||||
|
||||
class DownloadHistoryMutationCommand:
|
||||
"""统一提交下载历史删除,避免 API 直接持有数据库事务。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: DownloadHistoryMutationRepository,
|
||||
unit_of_work: HistoryUnitOfWork,
|
||||
) -> None:
|
||||
"""保存下载历史持久化和事务端口。"""
|
||||
self._repository = repository
|
||||
self._unit_of_work = unit_of_work
|
||||
|
||||
def delete(self, history_id: int) -> HistoryMutationResult:
|
||||
"""暂存并提交单条下载历史删除。"""
|
||||
self._repository.stage_delete_history(history_id)
|
||||
self._commit()
|
||||
return HistoryMutationResult(True)
|
||||
|
||||
def _commit(self) -> None:
|
||||
"""提交事务,失败时回滚。"""
|
||||
try:
|
||||
self._unit_of_work.commit()
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
|
||||
|
||||
class TransferHistoryMutationCommand:
|
||||
"""协调整理历史、关联文件状态和外部存储删除。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
repository: TransferHistoryMutationRepository,
|
||||
download_repository: DownloadFileMutationRepository,
|
||||
unit_of_work: HistoryUnitOfWork,
|
||||
file_item_factory: Callable[[dict[str, JsonData]], Any],
|
||||
delete_media_file: Callable[[Any], bool],
|
||||
publish_download_file_deleted: Callable[[dict[str, Any]], None],
|
||||
clear_failures: Callable[[Optional[str], Optional[str]], None],
|
||||
file_exists: Optional[Callable[[Any], Optional[bool]]] = None,
|
||||
) -> None:
|
||||
"""保存历史事务、存储删除、事件和失败状态清理端口。"""
|
||||
self._repository = repository
|
||||
self._download_repository = download_repository
|
||||
self._unit_of_work = unit_of_work
|
||||
self._file_item_factory = file_item_factory
|
||||
self._file_exists = file_exists or (lambda _fileitem: True)
|
||||
self._delete_media_file = delete_media_file
|
||||
self._publish_download_file_deleted = publish_download_file_deleted
|
||||
self._clear_failures = clear_failures
|
||||
|
||||
def delete(
|
||||
self,
|
||||
history_id: int,
|
||||
*,
|
||||
delete_source: bool = False,
|
||||
delete_destination: bool = False,
|
||||
) -> TransferHistoryDeleteResult:
|
||||
"""删除整理记录并返回源、目标文件及历史记录的分项结果。"""
|
||||
history = self._repository.get(history_id)
|
||||
if not history:
|
||||
return TransferHistoryDeleteResult(
|
||||
source=TransferHistoryDeleteStep(status="not_requested"),
|
||||
destination=TransferHistoryDeleteStep(status="not_requested"),
|
||||
history="not_found",
|
||||
message="记录不存在",
|
||||
)
|
||||
if history.transfer_task_id:
|
||||
return TransferHistoryDeleteResult(
|
||||
source=TransferHistoryDeleteStep(status="not_requested"),
|
||||
destination=TransferHistoryDeleteStep(status="not_requested"),
|
||||
history="retained",
|
||||
message="持久整理失败记录不可删除,请使用重试或人工复核入口",
|
||||
)
|
||||
|
||||
destination_result = self._delete_file(
|
||||
history.dest_fileitem if delete_destination else None,
|
||||
requested=delete_destination,
|
||||
label="目标文件",
|
||||
)
|
||||
source_result = self._delete_file(
|
||||
history.src_fileitem if delete_source else None,
|
||||
requested=delete_source,
|
||||
label="源文件",
|
||||
)
|
||||
source_completed = source_result.status in {"deleted", "already_missing"}
|
||||
all_requested_completed = all(
|
||||
step.status in {"not_requested", "deleted", "already_missing"}
|
||||
for step in (source_result, destination_result)
|
||||
)
|
||||
|
||||
if source_completed:
|
||||
source_payload = self._file_item_payload(history.src_fileitem)
|
||||
if source_payload is not None:
|
||||
source = self._file_item_factory(source_payload)
|
||||
self._download_repository.stage_delete_file_by_fullpath(
|
||||
Path(source.path).as_posix()
|
||||
)
|
||||
|
||||
if all_requested_completed:
|
||||
self._repository.stage_delete(history_id)
|
||||
|
||||
if source_completed or all_requested_completed:
|
||||
self._commit()
|
||||
if source_completed:
|
||||
self._publish_download_file_deleted({
|
||||
"src": history.src,
|
||||
"hash": history.download_hash,
|
||||
})
|
||||
if all_requested_completed:
|
||||
self._clear_failures(history.src, history.src_storage)
|
||||
|
||||
return TransferHistoryDeleteResult(
|
||||
source=source_result,
|
||||
destination=destination_result,
|
||||
history="deleted" if all_requested_completed else "retained",
|
||||
message=(
|
||||
"已删除整理记录"
|
||||
if all_requested_completed
|
||||
else next(
|
||||
(
|
||||
step.message
|
||||
for step in (source_result, destination_result)
|
||||
if step.status == "failed" and step.message
|
||||
),
|
||||
"整理记录已保留,部分文件处理失败",
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def _delete_file(
|
||||
self,
|
||||
value: Optional[JsonData],
|
||||
*,
|
||||
requested: bool,
|
||||
label: str,
|
||||
) -> TransferHistoryDeleteStep:
|
||||
"""删除一个文件目标;原目标已不存在时按完成处理,失败不抛出以便继续汇总其他步骤。"""
|
||||
if not requested:
|
||||
return TransferHistoryDeleteStep(status="not_requested")
|
||||
payload = self._file_item_payload(value)
|
||||
if payload is None:
|
||||
return TransferHistoryDeleteStep(
|
||||
status="failed",
|
||||
message=f"{label}历史数据无效",
|
||||
)
|
||||
try:
|
||||
fileitem = self._file_item_factory(payload)
|
||||
if self._file_exists(fileitem) is False:
|
||||
return TransferHistoryDeleteStep(status="already_missing")
|
||||
if not self._delete_media_file(fileitem):
|
||||
return TransferHistoryDeleteStep(
|
||||
status="failed",
|
||||
message=f"{fileitem.path} 删除失败",
|
||||
)
|
||||
except Exception:
|
||||
return TransferHistoryDeleteStep(status="failed", message=f"{label}删除失败")
|
||||
return TransferHistoryDeleteStep(status="deleted")
|
||||
|
||||
@staticmethod
|
||||
def _file_item_payload(value: JsonData) -> Optional[dict[str, JsonData]]:
|
||||
"""仅接受可安全构造文件项的历史 JSON 对象。"""
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
def truncate(self) -> HistoryMutationResult:
|
||||
"""在单一事务中清空旧历史,并保留当前失败任务记录。"""
|
||||
self._repository.stage_truncate()
|
||||
self._commit()
|
||||
return HistoryMutationResult(True, "已清空旧整理记录,失败任务记录已保留")
|
||||
|
||||
def _commit(self) -> None:
|
||||
"""提交历史事务,失败时回滚且不发布事件或清缓存。"""
|
||||
try:
|
||||
self._unit_of_work.commit()
|
||||
except Exception:
|
||||
self._unit_of_work.rollback()
|
||||
raise
|
||||
@@ -413,6 +413,8 @@ SCHEMA_EXPORTS = {
|
||||
'TorrentReidentifyData': ('app.schemas.cache', 'TorrentReidentifyData'),
|
||||
'TransferDirectoryConf': ('app.schemas.system', 'TransferDirectoryConf'),
|
||||
'TransferHistory': ('app.schemas.history', 'TransferHistory'),
|
||||
'TransferHistoryDeleteResult': ('app.schemas.history', 'TransferHistoryDeleteResult'),
|
||||
'TransferHistoryDeleteStep': ('app.schemas.history', 'TransferHistoryDeleteStep'),
|
||||
'TransferHistoryPage': ('app.schemas.history', 'TransferHistoryPage'),
|
||||
'TransferInfo': ('app.schemas.transfer', 'TransferInfo'),
|
||||
'TransferInterceptEventData': ('app.schemas.event', 'TransferInterceptEventData'),
|
||||
@@ -483,7 +485,7 @@ SCHEMA_CONFLICTS = {
|
||||
'FilterRuleGroup': ['app.schemas.rule', 'app.schemas.system'],
|
||||
'JsonData': ['app.schemas.agent', 'app.schemas.common', 'app.schemas.context', 'app.schemas.dashboard', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.mfa', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.openai', 'app.schemas.servcookie', 'app.schemas.site', 'app.schemas.transfer', 'app.schemas.user', 'app.schemas.workflow', 'app.schemas.mcp'],
|
||||
'List': ['app.schemas.agent', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.message', 'app.schemas.monitoring', 'app.schemas.plugin', 'app.schemas.openai', 'app.schemas.subscribe', 'app.schemas.transfer', 'app.schemas.workflow'],
|
||||
'Literal': ['app.schemas.agent', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.mcp'],
|
||||
'Literal': ['app.schemas.agent', 'app.schemas.history', 'app.schemas.music', 'app.schemas.plugin', 'app.schemas.search', 'app.schemas.servcookie', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.mcp'],
|
||||
'LocaleHelper': ['app.schemas.dashboard', 'app.schemas.response'],
|
||||
'MediaInfo': ['app.schemas.context', 'app.schemas.system', 'app.schemas.transfer', 'app.schemas.workflow'],
|
||||
'MediaSource': ['app.schemas.cache', 'app.schemas.context', 'app.schemas.event', 'app.schemas.history', 'app.schemas.mediaserver', 'app.schemas.music', 'app.schemas.subscribe', 'app.schemas.transfer'],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import List, Optional
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -144,3 +144,32 @@ class TransferHistoryPage(BaseModel):
|
||||
|
||||
list: List[TransferHistory] = Field(default_factory=list, description="整理历史列表")
|
||||
total: int = Field(default=0, description="记录总数")
|
||||
|
||||
|
||||
class TransferHistoryDeleteStep(BaseModel): # type: ignore[misc]
|
||||
"""整理历史删除流程中单个文件目标的执行结果。"""
|
||||
|
||||
# not_requested 未请求;deleted 已删除;already_missing 原目标已不存在;failed 删除或校验失败
|
||||
status: Literal["not_requested", "deleted", "already_missing", "failed"]
|
||||
# 仅用于无法继续处理时给出可读原因,正常状态为空
|
||||
message: str = ""
|
||||
|
||||
|
||||
class TransferHistoryDeleteResult(BaseModel): # type: ignore[misc]
|
||||
"""整理历史删除及源/目标文件清理的分项结果。"""
|
||||
|
||||
# 源文件步骤
|
||||
source: TransferHistoryDeleteStep
|
||||
# 目标文件步骤
|
||||
destination: TransferHistoryDeleteStep
|
||||
# 历史记录步骤:deleted 已删除、retained 保留待重试、not_found 不存在
|
||||
history: Literal["deleted", "retained", "not_found"]
|
||||
# 面向请求方的概要消息;详细状态由三个步骤字段表达
|
||||
message: str = ""
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""兼容应用层命令调用方,以历史记录是否删除作为整体成功条件。"""
|
||||
return self.history == "deleted"
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -755,7 +755,7 @@ flowchart LR
|
||||
| 指标 | 当前值 |
|
||||
|---|---:|
|
||||
| Python 模块 | 973 |
|
||||
| 内部导入边 | 8,422 |
|
||||
| 内部导入边 | 8,426 |
|
||||
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
|
||||
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
|
||||
| Direct egress | 53(债务已清零,53 条精确 containment) |
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
| RES-002 | `DELIVERED`(R2) | 复杂度门禁漏扫私有方法、类/文件和 Scheduler;原生并发清单不完整 | `f7ca7e517`、`5cd5780d3`:完整宿主 AST、canonical owner/count、低水位与零增长门禁已落地 |
|
||||
| RES-003 | `DELIVERED`(R3) | Outbox after_commit 失败只有内存 pending 标记,重启不可恢复 | `9d06f91bb`:持久 intent、唯一 handler、claim/fencing、重启回放、幂等和失败观测完整闭环 |
|
||||
| RES-004 | `DELIVERED`(R4) | Startup initializer 与插件市场存在多套 Transport/Adapter/Manager 构造 | `7f5b8b469` 至 `046b0b305`:构造回收到 composition,兼容门面消费同一 owner,canonical 无重复正式实现 |
|
||||
| RES-005 | `DELIVERED`(R5) | 审计声明、机器门禁、CI 与远端交付状态需要重新校准 | 当前文档、生成 fixture 与规则一致;锁定全量 `7684 passed, 9 skipped`,Application/Domain 覆盖率为 `81.79%` / `81.03%`,Pylint `10.00/10`、架构/兼容、真实启动和最终 exact-head GitHub CI 闭环,远端 `0/0` |
|
||||
| RES-005 | `DELIVERED`(R5) | 审计声明、机器门禁、CI 与远端交付状态需要重新校准 | 当前文档、生成 fixture 与规则一致;锁定全量 `7684 passed, 9 skipped`,Application/Domain 覆盖率为 `81.80%` / `81.03%`,Pylint `10.00/10`、架构/兼容、真实启动和最终 exact-head GitHub CI 闭环,远端 `0/0` |
|
||||
R2 门禁现已完整覆盖私有、dunder、任意控制流嵌套方法、类、文件与
|
||||
`app/scheduler/`;`concurrency.py` 扫描完整宿主源码,按 canonical import/alias、
|
||||
TaskGroup、可证明的 loop/executor 来源和词法 owner 聚合数量。新增 owner、数量增长以及
|
||||
@@ -93,7 +93,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
|
||||
| 指标 | 当前值 | 解释 |
|
||||
|---|---:|---|
|
||||
| 宿主 Python 模块 / 内部依赖边 | 981 / 8,422 | `dependency-baseline.json` 当前快照 |
|
||||
| 宿主 Python 模块 / 内部依赖边 | 982 / 8,426 | `dependency-baseline.json` 当前快照 |
|
||||
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
|
||||
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
|
||||
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
|
||||
@@ -103,7 +103,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
|
||||
| 全量 mypy 历史债务 | 9,982 / 591 文件 | canonical `SearchChain` Facade 已补齐显式类型转发;strict frontier 当前覆盖 41 个文件,低水位只允许继续下降 |
|
||||
| Ruff 历史诊断 | 631 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率低水位 | Application 81.79%,Domain 81.03% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
| 覆盖率低水位 | Application 81.80%,Domain 81.03% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
### 3.3 热点文件
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"application": {
|
||||
"covered_lines": 12935,
|
||||
"percent": 81.79,
|
||||
"statements": 15814
|
||||
"covered_lines": 12966,
|
||||
"percent": 81.8,
|
||||
"statements": 15851
|
||||
},
|
||||
"domain": {
|
||||
"covered_lines": 3686,
|
||||
|
||||
@@ -1089,8 +1089,8 @@
|
||||
"runtime_only": true
|
||||
}
|
||||
},
|
||||
"edge_count": 8422,
|
||||
"edge_sha256": "3971beb13490710d6054b8cedbc66da9c0920bb30107332440f8c788d27feebd",
|
||||
"edge_count": 8426,
|
||||
"edge_sha256": "b100c70544e268aba59153e0725133903487e4dcd2c6b28ca9130ea8ef6a982c",
|
||||
"edges": [
|
||||
"app -> app.foundation",
|
||||
"app -> app.foundation.environment",
|
||||
@@ -3796,6 +3796,7 @@
|
||||
"app.application.formatting -> app.schemas.workflow",
|
||||
"app.application.history -> app.application",
|
||||
"app.application.history -> app.application.configuration",
|
||||
"app.application.history -> app.application.historymutation",
|
||||
"app.application.history -> app.domain",
|
||||
"app.application.history -> app.domain.context",
|
||||
"app.application.history -> app.domain.meta",
|
||||
@@ -3813,6 +3814,9 @@
|
||||
"app.application.history -> app.schemas.media",
|
||||
"app.application.history -> app.schemas.transfer",
|
||||
"app.application.history -> app.schemas.types",
|
||||
"app.application.historymutation -> app.schemas",
|
||||
"app.application.historymutation -> app.schemas.common",
|
||||
"app.application.historymutation -> app.schemas.history",
|
||||
"app.application.image -> app.application",
|
||||
"app.application.image -> app.application.configuration",
|
||||
"app.application.image -> app.application.security",
|
||||
@@ -9515,7 +9519,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 981,
|
||||
"module_count": 982,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -9794,6 +9798,7 @@
|
||||
"app.application.downloader",
|
||||
"app.application.formatting",
|
||||
"app.application.history",
|
||||
"app.application.historymutation",
|
||||
"app.application.image",
|
||||
"app.application.maintenance",
|
||||
"app.application.mediaserver",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"repeat": 3,
|
||||
"targets": {
|
||||
"app.startup.lifecycle": {
|
||||
"loaded_app_module_count": 498,
|
||||
"loaded_app_module_count": 499,
|
||||
"max_ms": 1138.629,
|
||||
"median_ms": 1108.684,
|
||||
"min_ms": 1097.855,
|
||||
@@ -17,7 +17,7 @@
|
||||
]
|
||||
},
|
||||
"app.factory": {
|
||||
"loaded_app_module_count": 510,
|
||||
"loaded_app_module_count": 511,
|
||||
"max_ms": 1118.665,
|
||||
"median_ms": 1117.279,
|
||||
"min_ms": 1111.113,
|
||||
@@ -28,7 +28,7 @@
|
||||
]
|
||||
},
|
||||
"app.main": {
|
||||
"loaded_app_module_count": 512,
|
||||
"loaded_app_module_count": 513,
|
||||
"max_ms": 1172.187,
|
||||
"median_ms": 1161.652,
|
||||
"min_ms": 1155.282,
|
||||
|
||||
@@ -69,6 +69,8 @@ def test_transfer_source_delete_failure_keeps_database_unchanged():
|
||||
|
||||
assert result.success is False
|
||||
assert result.message == "/downloads/demo.mkv 删除失败"
|
||||
assert result.source.status == "failed"
|
||||
assert result.destination.status == "not_requested"
|
||||
dependencies["repository"].stage_delete.assert_not_called()
|
||||
dependencies["download_repository"].stage_delete_file_by_fullpath.assert_not_called()
|
||||
dependencies["unit_of_work"].commit.assert_not_called()
|
||||
@@ -91,6 +93,8 @@ def test_transfer_delete_commits_before_event_and_retry_cleanup():
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.source.status == "deleted"
|
||||
assert result.destination.status == "deleted"
|
||||
dependencies["download_repository"].stage_delete_file_by_fullpath.assert_called_once_with(
|
||||
"/downloads/demo.mkv"
|
||||
)
|
||||
@@ -135,6 +139,79 @@ def test_transfer_delete_rejects_durable_receipt_before_file_side_effects():
|
||||
|
||||
assert result.success is False
|
||||
assert result.message == "持久整理失败记录不可删除,请使用重试或人工复核入口"
|
||||
assert result.history == "retained"
|
||||
dependencies["delete_media_file"].assert_not_called()
|
||||
dependencies["repository"].stage_delete.assert_not_called()
|
||||
dependencies["unit_of_work"].commit.assert_not_called()
|
||||
|
||||
|
||||
def test_transfer_destination_delete_failure_keeps_history_and_reports_step():
|
||||
"""目标文件删除失败时必须保留历史,并向调用方报告失败步骤。"""
|
||||
command, dependencies = _transfer_command(
|
||||
history=_history(),
|
||||
delete_result=False,
|
||||
)
|
||||
|
||||
result = command.delete(7, delete_destination=True)
|
||||
|
||||
assert result.success is False
|
||||
assert result.destination.status == "failed"
|
||||
assert result.history == "retained"
|
||||
assert result.source.status == "not_requested"
|
||||
dependencies["repository"].stage_delete.assert_not_called()
|
||||
dependencies["unit_of_work"].commit.assert_not_called()
|
||||
|
||||
|
||||
def test_transfer_delete_commits_completed_source_when_destination_fails():
|
||||
"""一项文件已完成而另一项失败时,已完成源文件状态仍应提交并保留历史。"""
|
||||
repository = Mock()
|
||||
repository.get.return_value = _history()
|
||||
dependencies = {
|
||||
"repository": repository,
|
||||
"download_repository": Mock(),
|
||||
"unit_of_work": Mock(),
|
||||
"file_item_factory": lambda payload: SimpleNamespace(**payload),
|
||||
"file_exists": Mock(return_value=True),
|
||||
"delete_media_file": Mock(side_effect=[False, True]),
|
||||
"publish_download_file_deleted": Mock(),
|
||||
"clear_failures": Mock(),
|
||||
}
|
||||
command = TransferHistoryMutationCommand(**dependencies)
|
||||
|
||||
result = command.delete(7, delete_source=True, delete_destination=True)
|
||||
|
||||
assert result.success is False
|
||||
assert result.destination.status == "failed"
|
||||
assert result.source.status == "deleted"
|
||||
assert result.history == "retained"
|
||||
repository.stage_delete.assert_not_called()
|
||||
dependencies["download_repository"].stage_delete_file_by_fullpath.assert_called_once_with(
|
||||
"/downloads/demo.mkv"
|
||||
)
|
||||
dependencies["unit_of_work"].commit.assert_called_once_with()
|
||||
dependencies["publish_download_file_deleted"].assert_called_once()
|
||||
dependencies["clear_failures"].assert_not_called()
|
||||
|
||||
|
||||
def test_transfer_delete_treats_missing_requested_file_as_completed():
|
||||
"""重试时已被前一次尝试删除的文件应跳过外部删除并允许清理历史。"""
|
||||
command, dependencies = _transfer_command(history=_history())
|
||||
dependencies["delete_media_file"].reset_mock()
|
||||
dependencies["file_exists"] = Mock(return_value=False)
|
||||
command = TransferHistoryMutationCommand(
|
||||
repository=dependencies["repository"],
|
||||
download_repository=dependencies["download_repository"],
|
||||
unit_of_work=dependencies["unit_of_work"],
|
||||
file_item_factory=dependencies["file_item_factory"],
|
||||
file_exists=dependencies["file_exists"],
|
||||
delete_media_file=dependencies["delete_media_file"],
|
||||
publish_download_file_deleted=dependencies["publish_download_file_deleted"],
|
||||
clear_failures=dependencies["clear_failures"],
|
||||
)
|
||||
|
||||
result = command.delete(7, delete_source=True, delete_destination=True)
|
||||
|
||||
assert result.success is True
|
||||
assert result.source.status == "already_missing"
|
||||
assert result.destination.status == "already_missing"
|
||||
dependencies["delete_media_file"].assert_not_called()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""历史查询应用服务的分页、筛选和 DTO 边界测试。"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import ANY, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -42,24 +42,26 @@ async def test_list_download_returns_schema_dtos() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_transfer_maps_failed_alias_to_status_filter() -> None:
|
||||
"""“失败”快捷条件应继续映射为无标题的失败状态查询。"""
|
||||
async def test_list_transfer_uses_explicit_status_without_reinterpreting_title() -> None:
|
||||
"""状态筛选必须使用显式参数,中文标题仍保持标题查询语义。"""
|
||||
service, _, transfer_repository = _make_service()
|
||||
transfer_repository.async_list_by_page.return_value = [
|
||||
transfer_repository.async_list_by_title.return_value = [
|
||||
SimpleNamespace(id=3, status=False)
|
||||
]
|
||||
transfer_repository.async_count.return_value = 1
|
||||
transfer_repository.async_count_by_title.return_value = 1
|
||||
|
||||
page = await service.list_transfer(title="失败", page=3, count=5)
|
||||
page = await service.list_transfer(title="失败", page=3, count=5, status=False)
|
||||
|
||||
assert page.list == [TransferHistory(id=3, status=False)]
|
||||
assert page.total == 1
|
||||
transfer_repository.async_list_by_page.assert_awaited_once_with(
|
||||
transfer_repository.async_count_by_title.assert_awaited_once()
|
||||
transfer_repository.async_list_by_title.assert_awaited_once_with(
|
||||
ANY,
|
||||
page=3,
|
||||
count=5,
|
||||
status=False,
|
||||
wildcard=False,
|
||||
)
|
||||
transfer_repository.async_count.assert_awaited_once_with(status=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -105,10 +105,11 @@ def test_delete_transfer_history_endpoint_clears_retry_count(monkeypatch):
|
||||
id=101,
|
||||
src=src_path,
|
||||
src_storage=storage,
|
||||
dest_fileitem=None,
|
||||
src_fileitem=None,
|
||||
download_hash=None,
|
||||
)
|
||||
dest_fileitem=None,
|
||||
src_fileitem=None,
|
||||
download_hash=None,
|
||||
transfer_task_id=None,
|
||||
)
|
||||
repository = Mock()
|
||||
repository.get.return_value = history
|
||||
command = TransferHistoryMutationCommand(
|
||||
@@ -136,6 +137,10 @@ def test_delete_transfer_history_endpoint_clears_retry_count(monkeypatch):
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
assert response.data is not None
|
||||
assert response.data.history == "deleted"
|
||||
assert response.data.source.status == "not_requested"
|
||||
assert response.data.destination.status == "not_requested"
|
||||
assert failed_retry_count(src_path, storage) == 0
|
||||
finally:
|
||||
_reset_failed_retries(src_path, storage)
|
||||
|
||||
Reference in New Issue
Block a user