mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
fix(api): humanize user-facing error messages
This commit is contained in:
@@ -319,7 +319,7 @@ UNSUPPORTED_IMAGE_INPUT_MESSAGE = (
|
||||
"当前模型不支持图片输入,请更换支持图片输入的模型,或在系统设置中关闭图片输入支持后重试。"
|
||||
)
|
||||
AGENT_EXECUTION_ERROR_PREFIX = "智能助手执行失败"
|
||||
AGENT_EXECUTION_ERROR_MESSAGE = "智能助手执行失败,请稍后重试。"
|
||||
AGENT_EXECUTION_ERROR_MESSAGE = "智能助手执行失败,请稍后重试"
|
||||
AGENT_DISPLAY_HISTORY_SKIP_CHANNELS = {NotificationChannel.WebAgent.value}
|
||||
AGENT_CHAT_TITLE_PROMPT = (
|
||||
"你是 MoviePilot 智能助手的内部会话标题生成器。你的唯一任务是根据提供的用户消息生成一个简洁中文标题。"
|
||||
@@ -1441,7 +1441,7 @@ class MoviePilotAgent:
|
||||
message = cls._primary_exception_message(error)
|
||||
if not message:
|
||||
return AGENT_EXECUTION_ERROR_MESSAGE
|
||||
return f"{AGENT_EXECUTION_ERROR_PREFIX}: {message}"
|
||||
return AGENT_EXECUTION_ERROR_MESSAGE
|
||||
|
||||
async def _dispatch_execution_notice(self, message: str) -> None:
|
||||
"""
|
||||
@@ -2105,8 +2105,8 @@ class MoviePilotAgent:
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"处理消息时发生错误: {str(e)}"
|
||||
logger.error(error_message)
|
||||
error_message = AGENT_EXECUTION_ERROR_MESSAGE
|
||||
logger.error(f"处理消息时发生错误: {e}", exc_info=True)
|
||||
if not user_display_saved:
|
||||
await self._save_display_history_messages([self.build_display_message(role="user", content=message)])
|
||||
if not self.should_dispatch_reply:
|
||||
|
||||
@@ -151,13 +151,13 @@ async def test_agent_mcp_server(
|
||||
data=result.model_dump(),
|
||||
)
|
||||
except Exception as err:
|
||||
logger.warning(f"测试 Agent MCP 服务器失败: {err}")
|
||||
logger.warning(f"测试 Agent MCP 服务器失败: {err}", exc_info=True)
|
||||
return _SchemaResponse(
|
||||
success=False,
|
||||
message=f"测试MCP服务器失败: {str(err)}",
|
||||
message="MCP 服务测试失败,请检查服务配置后重试",
|
||||
data={
|
||||
"success": False,
|
||||
"message": str(err),
|
||||
"message": "MCP 服务测试失败,请检查服务配置后重试",
|
||||
"tools": [],
|
||||
"tool_count": 0,
|
||||
},
|
||||
|
||||
@@ -44,6 +44,7 @@ from app.application.transfer.execution import (
|
||||
TransferExecutionRepository,
|
||||
TransferRetryRequestResult,
|
||||
)
|
||||
from app.runtime.errors import public_error_message
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.loop import main_loop_registry
|
||||
from app.runtime.progress import AsyncProgressHelper
|
||||
@@ -88,9 +89,9 @@ def _request_durable_transfer_retry(
|
||||
def _format_retry_rejections(
|
||||
rejections: list[tuple[int, TransferRetryRequestResult]],
|
||||
) -> str:
|
||||
"""把批量 durable 重试拒绝原因格式化为可审计的接口提示。"""
|
||||
"""把批量整理重试拒绝原因格式化为前端可直接理解的提示。"""
|
||||
return ";".join(
|
||||
f"#{history_id} [{result.state.value}]: {result.message}"
|
||||
f"第 {history_id} 条:{public_error_message(result.message, context='transfer')}"
|
||||
for history_id, result in rejections
|
||||
)
|
||||
|
||||
@@ -135,9 +136,9 @@ def _durable_retry_messages(
|
||||
"""构造 durable 批量登记结果消息,供纯 durable 和混合请求复用。"""
|
||||
messages: list[str] = []
|
||||
if accepted_count:
|
||||
messages.append(f"已登记 {accepted_count} 个持久整理任务重试")
|
||||
messages.append(f"已提交 {accepted_count} 个整理任务,后台将自动处理")
|
||||
if rejections:
|
||||
messages.append("以下任务未登记重试:" + _format_retry_rejections(rejections))
|
||||
messages.append("以下整理记录未能提交重试:" + _format_retry_rejections(rejections))
|
||||
return messages
|
||||
|
||||
|
||||
@@ -242,13 +243,14 @@ def _start_ai_redo_task(
|
||||
data={"history_id": history_id, "success": True, "completed": True},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"智能助手后台整理失败:{e}", exc_info=True)
|
||||
await progress.update(
|
||||
text=f"智能助手整理失败:{str(e)}",
|
||||
text="智能助手整理失败,请稍后重试",
|
||||
data={
|
||||
"history_id": history_id,
|
||||
"success": False,
|
||||
"completed": True,
|
||||
"error": str(e),
|
||||
"error": "智能助手整理失败,请稍后重试",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
@@ -299,13 +301,14 @@ def _start_batch_ai_redo_task(
|
||||
data={"history_ids": history_ids, "success": True, "completed": True},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"智能助手后台批量整理失败:{e}", exc_info=True)
|
||||
await progress.update(
|
||||
text=f"智能助手批量整理失败:{str(e)}",
|
||||
text="智能助手批量整理失败,请稍后重试",
|
||||
data={
|
||||
"history_ids": history_ids,
|
||||
"success": False,
|
||||
"completed": True,
|
||||
"error": str(e),
|
||||
"error": "智能助手批量整理失败,请稍后重试",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
@@ -472,16 +475,18 @@ async def ai_redo_transfer_history(
|
||||
repository=execution_repository,
|
||||
)
|
||||
if not retry.accepted:
|
||||
return _SchemaResponse(success=False, message=retry.message)
|
||||
retry_message = public_error_message(retry.message, context="transfer")
|
||||
return _SchemaResponse(success=False, message=retry_message)
|
||||
retry_message = public_error_message(retry.message, context="transfer")
|
||||
progress_key = f"transfer_retry_{history_id}_{int(time.time() * 1000)}"
|
||||
await _complete_durable_retry_progress(
|
||||
progress_key=progress_key,
|
||||
text=retry.message,
|
||||
text=retry_message,
|
||||
history_ids=[history.id],
|
||||
)
|
||||
return _SchemaResponse(
|
||||
success=True,
|
||||
message=retry.message,
|
||||
message=retry_message,
|
||||
data={"progress_key": progress_key},
|
||||
)
|
||||
|
||||
|
||||
@@ -294,8 +294,8 @@ def wechat_verify(
|
||||
return PlainTextResponse(sEchoStr)
|
||||
return "微信验证失败"
|
||||
except Exception as err:
|
||||
logger.error(f"微信请求验证失败: {str(err)}")
|
||||
return str(err)
|
||||
logger.error(f"微信请求验证失败: {str(err)}", exc_info=True)
|
||||
return "消息验证失败,请稍后重试"
|
||||
|
||||
|
||||
def vocechat_verify() -> Any:
|
||||
@@ -374,7 +374,13 @@ def send_notification(
|
||||
"""
|
||||
from pywebpush import WebPushException, webpush
|
||||
|
||||
for sub in webpush_registry.list():
|
||||
subscriptions = webpush_registry.list()
|
||||
if not subscriptions:
|
||||
return _SchemaResponse(success=True, message="没有可发送的浏览器通知")
|
||||
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
for sub in subscriptions:
|
||||
try:
|
||||
webpush(
|
||||
subscription_info=sub,
|
||||
@@ -383,9 +389,30 @@ def send_notification(
|
||||
vapid_claims={"sub": get_api_runtime_config_snapshot().vapid_subject},
|
||||
**webpush_options_for_endpoint(sub.get("endpoint")),
|
||||
)
|
||||
success_count += 1
|
||||
except WebPushException as err:
|
||||
logger.error(f"WebPush发送失败: {str(err)}")
|
||||
if is_webpush_subscription_gone(err) and webpush_registry.remove(sub):
|
||||
logger.info(f"已移除失效WebPush订阅: {sub.get('endpoint')}")
|
||||
continue
|
||||
return _SchemaResponse(success=True)
|
||||
failure_count += 1
|
||||
except Exception as err:
|
||||
logger.error(f"WebPush发送失败: {str(err)}", exc_info=True)
|
||||
failure_count += 1
|
||||
|
||||
if not failure_count:
|
||||
return _SchemaResponse(
|
||||
success=True,
|
||||
message=f"消息已发送到 {success_count} 个设备",
|
||||
)
|
||||
if success_count:
|
||||
return _SchemaResponse(
|
||||
success=True,
|
||||
message=(
|
||||
f"消息已发送到 {success_count} 个设备,"
|
||||
f"{failure_count} 个设备发送失败"
|
||||
),
|
||||
)
|
||||
return _SchemaResponse(
|
||||
success=False,
|
||||
message="消息发送失败,请检查浏览器通知权限后重试",
|
||||
)
|
||||
|
||||
@@ -327,7 +327,11 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
|
||||
termination_reason = "error"
|
||||
logger.error(f"渐进式搜索出错:{err}", exc_info=True)
|
||||
payload = _sse_event(
|
||||
{"type": "error", "success": False, "message": str(err)},
|
||||
{
|
||||
"type": "error",
|
||||
"success": False,
|
||||
"message": "搜索失败,请稍后重试",
|
||||
},
|
||||
locale=locale,
|
||||
)
|
||||
event_count += 1
|
||||
|
||||
@@ -61,7 +61,9 @@ from app.application.subscription.status import SubscriptionExecutionStatusServi
|
||||
from app.chain.subscribe.facade import SubscribeChain
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.runtime.errors import public_error_message
|
||||
from app.runtime.execution import run_in_threadpool
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.tasks import TaskRegistry
|
||||
from app.schemas.common import IdData as _SchemaIdData
|
||||
from app.schemas.media import normalize_media_source, resolve_media_identity
|
||||
@@ -287,7 +289,15 @@ async def create_subscribe(
|
||||
owner_scope=not current_user.is_superuser,
|
||||
**subscribe_dict,
|
||||
)
|
||||
return _SchemaResponse(success=bool(sid), message=message, data={"id": sid})
|
||||
return _SchemaResponse(
|
||||
success=bool(sid),
|
||||
message=(
|
||||
public_error_message(message, context="subscription")
|
||||
if message
|
||||
else ""
|
||||
),
|
||||
data={"id": sid},
|
||||
)
|
||||
|
||||
|
||||
@router.put("/", summary="更新订阅", response_model=_SchemaResponse[None])
|
||||
@@ -357,9 +367,10 @@ async def update_subscribe(
|
||||
existing=subscribe,
|
||||
)
|
||||
except ValueError as error:
|
||||
logger.error(f"订阅分类设置无效:{error}", exc_info=True)
|
||||
return _SchemaResponse(
|
||||
success=False,
|
||||
message=f"订阅分类无效:{error}",
|
||||
message="订阅分类设置无效,请重新选择分类后重试",
|
||||
)
|
||||
if not change:
|
||||
return _SchemaResponse(success=False, message="订阅不存在")
|
||||
@@ -792,7 +803,14 @@ async def subscribe_share(
|
||||
share_comment=sub.share_comment,
|
||||
share_user=sub.share_user,
|
||||
)
|
||||
return _SchemaResponse(success=state, message=errmsg)
|
||||
return _SchemaResponse(
|
||||
success=state,
|
||||
message=(
|
||||
public_error_message(errmsg, context="subscription")
|
||||
if errmsg
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/share/{share_id}", summary="删除分享", response_model=_SchemaResponse[None])
|
||||
@@ -803,7 +821,14 @@ async def subscribe_share_delete(
|
||||
删除分享
|
||||
"""
|
||||
state, errmsg = await MoviePilotServerHelper.async_share_delete(share_id=share_id)
|
||||
return _SchemaResponse(success=state, message=errmsg)
|
||||
return _SchemaResponse(
|
||||
success=state,
|
||||
message=(
|
||||
public_error_message(errmsg, context="subscription")
|
||||
if errmsg
|
||||
else ""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/fork", summary="复用订阅", response_model=_SchemaResponse[None])
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.application.transfer.execution import (
|
||||
)
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.transfer.facade import TransferChain
|
||||
from app.runtime.errors import public_error_message
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.stop import runtime_stop_state
|
||||
from app.schemas.common import NameData as _SchemaNameData
|
||||
@@ -50,6 +51,32 @@ from app.schemas.workflow import FileItem as _SchemaFileItem
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
|
||||
def _public_transfer_message(message: Optional[object]) -> Optional[str]:
|
||||
"""把整理链返回的错误转换为前端可直接展示的文案。"""
|
||||
if message is None or not str(message).strip():
|
||||
return None
|
||||
return public_error_message(message, context="transfer")
|
||||
|
||||
|
||||
def _public_transfer_result(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""裁剪整理结果中的错误字段,保留预览数据的原有结构。"""
|
||||
result = dict(data)
|
||||
if result.get("message"):
|
||||
result["message"] = _public_transfer_message(result["message"])
|
||||
items = result.get("items")
|
||||
if isinstance(items, list):
|
||||
result["items"] = [
|
||||
{
|
||||
**item,
|
||||
"message": _public_transfer_message(item.get("message")),
|
||||
}
|
||||
if isinstance(item, dict)
|
||||
else item
|
||||
for item in items
|
||||
]
|
||||
return result
|
||||
|
||||
|
||||
def _manual_review_actor(current_user: object) -> str:
|
||||
"""按名称、用户名和用户 ID 的稳定顺序提取人工复核操作者。"""
|
||||
for attribute in ("name", "username", "id"):
|
||||
@@ -80,7 +107,7 @@ def _manual_review_task_data(
|
||||
"kind": task.step.kind,
|
||||
"intent": task.step.intent,
|
||||
"evidence": task.step.evidence,
|
||||
"error": task.step.error,
|
||||
"error": _public_transfer_message(task.step.error),
|
||||
},
|
||||
"review_revision": task.review_revision,
|
||||
}),
|
||||
@@ -174,9 +201,10 @@ def resolve_transfer_manual_review(
|
||||
result=result,
|
||||
)
|
||||
except TransferExecutionConflictError as error:
|
||||
logger.warning(f"整理人工复核请求冲突:{error}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(error),
|
||||
detail="整理任务状态已变化,请刷新后重试",
|
||||
) from error
|
||||
return _SchemaResponse(
|
||||
success=True,
|
||||
@@ -609,7 +637,7 @@ def _execute_manual_transfer(
|
||||
)
|
||||
explicit_selected_files = bool(transer_item.fileitems)
|
||||
|
||||
def _build_failure_preview_item(file_item: FileItem, message: str) -> dict:
|
||||
def _build_failure_preview_item(file_item: FileItem, message: str | None) -> dict:
|
||||
"""
|
||||
构造手动整理预览失败项。
|
||||
"""
|
||||
@@ -618,7 +646,7 @@ def _execute_manual_transfer(
|
||||
"target": None,
|
||||
"target_dir": None,
|
||||
"success": False,
|
||||
"message": message,
|
||||
"message": _public_transfer_message(message),
|
||||
"type": None,
|
||||
"title": None,
|
||||
"season": None,
|
||||
@@ -633,9 +661,15 @@ def _execute_manual_transfer(
|
||||
|
||||
def _merge_messages(messages: List[str]) -> str:
|
||||
"""
|
||||
合并手动整理批量预览提示信息。
|
||||
合并手动整理批量预览提示信息,并统一转换错误文案。
|
||||
"""
|
||||
valid_messages = [msg for msg in messages if msg]
|
||||
valid_messages = [
|
||||
public_message
|
||||
for msg in messages
|
||||
if msg
|
||||
for public_message in [_public_transfer_message(msg)]
|
||||
if public_message
|
||||
]
|
||||
if not valid_messages:
|
||||
return ""
|
||||
return "、".join(valid_messages[:2]) + (
|
||||
@@ -675,16 +709,23 @@ def _execute_manual_transfer(
|
||||
)
|
||||
if transer_item.preview:
|
||||
if isinstance(errormsg, dict):
|
||||
preview_items.extend(errormsg.get("items") or [])
|
||||
preview_items.extend(
|
||||
_public_transfer_result(errormsg).get("items") or []
|
||||
)
|
||||
if errormsg.get("message"):
|
||||
error_messages.append(errormsg.get("message"))
|
||||
if not state:
|
||||
all_success = False
|
||||
else:
|
||||
if errormsg:
|
||||
error_messages.append(str(errormsg))
|
||||
public_message = _public_transfer_message(errormsg)
|
||||
if public_message:
|
||||
error_messages.append(public_message)
|
||||
preview_items.append(
|
||||
_build_failure_preview_item(src_fileitem, str(errormsg))
|
||||
_build_failure_preview_item(
|
||||
src_fileitem,
|
||||
_public_transfer_message(errormsg),
|
||||
)
|
||||
)
|
||||
all_success = False
|
||||
elif not state:
|
||||
@@ -702,7 +743,11 @@ def _execute_manual_transfer(
|
||||
if source in seen_sources:
|
||||
continue
|
||||
seen_sources.add(source)
|
||||
merged_preview_items.append(preview_item)
|
||||
merged_preview_items.append(
|
||||
_public_transfer_result(preview_item)
|
||||
if isinstance(preview_item, dict)
|
||||
else preview_item
|
||||
)
|
||||
merged_message = _merge_messages(error_messages)
|
||||
preview_data = {
|
||||
"summary": {
|
||||
@@ -762,12 +807,16 @@ def _execute_manual_transfer(
|
||||
if isinstance(errormsg, list):
|
||||
errormsg = f"整理完成,{len(errormsg)} 个文件转移失败!"
|
||||
if isinstance(errormsg, dict):
|
||||
public_result = _public_transfer_result(errormsg)
|
||||
return _SchemaResponse(
|
||||
success=True,
|
||||
message=errormsg.get("message"),
|
||||
data=errormsg,
|
||||
message=public_result.get("message"),
|
||||
data=public_result,
|
||||
)
|
||||
return _SchemaResponse(
|
||||
success=False,
|
||||
message=_public_transfer_message(errormsg),
|
||||
)
|
||||
return _SchemaResponse(success=False, message=errormsg)
|
||||
# 成功
|
||||
if transer_item.preview:
|
||||
return _SchemaResponse(success=True, data=errormsg or {})
|
||||
@@ -796,7 +845,10 @@ def recommend_episode_format(
|
||||
)
|
||||
if not state:
|
||||
logger.warn(f"推荐集数定位模板失败:{target_path} - {errmsg}")
|
||||
return _SchemaResponse(success=False, message=errmsg)
|
||||
return _SchemaResponse(
|
||||
success=False,
|
||||
message=_public_transfer_message(errmsg),
|
||||
)
|
||||
logger.info(
|
||||
f"推荐集数定位模板成功:{target_path} - 规则 {data.get('rule_name') if data else None}"
|
||||
)
|
||||
|
||||
+18
-3
@@ -18,6 +18,7 @@ from app.chain.subscribe.facade import SubscribeChain
|
||||
from app.chain.tvdb import TvdbChain
|
||||
from app.domain.context import MediaInfo
|
||||
from app.domain.metainfo import MetaInfo
|
||||
from app.runtime.errors import public_error_message
|
||||
from app.runtime.version import get_app_version
|
||||
from app.schemas.response import Response as _SchemaResponse
|
||||
from app.schemas.servarr import RadarrMovie, SonarrSeries
|
||||
@@ -34,6 +35,14 @@ from app.schemas.types import MediaSource, MediaType
|
||||
arr_router = APIRouter(tags=["servarr"], responses=ERROR_RESPONSES)
|
||||
|
||||
|
||||
def _subscribe_error_message(error: Optional[object]) -> str:
|
||||
"""将 Servarr 订阅失败转换为调用方能理解的提示。"""
|
||||
return public_error_message(
|
||||
error or "订阅操作失败",
|
||||
context="subscription",
|
||||
)
|
||||
|
||||
|
||||
def _subscribe_tmdb_id(subscribe: ServarrSubscription) -> int | None:
|
||||
"""将通用订阅身份投影为 Servarr 固定使用的 TMDB ID。"""
|
||||
if (
|
||||
@@ -448,7 +457,10 @@ async def arr_add_movie(
|
||||
if sid:
|
||||
return _SchemaServarrIdResponse(id=sid)
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"添加订阅失败:{_subscribe_error_message(message)}",
|
||||
)
|
||||
|
||||
|
||||
@arr_router.delete(
|
||||
@@ -860,12 +872,15 @@ async def arr_add_series(
|
||||
except SubscriptionBatchWriteError as error:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"添加订阅失败:{error}",
|
||||
detail=f"添加订阅失败:{_subscribe_error_message(error)}",
|
||||
) from error
|
||||
|
||||
if sid:
|
||||
return _SchemaServarrIdResponse(id=sid)
|
||||
raise HTTPException(status_code=500, detail=f"添加订阅失败:{message}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"添加订阅失败:{_subscribe_error_message(message)}",
|
||||
)
|
||||
|
||||
|
||||
@arr_router.put(
|
||||
|
||||
@@ -2018,7 +2018,7 @@ def _build_agent_web_agent_stream(
|
||||
logger.error(f"Web智能助手执行失败: {str(err)}")
|
||||
error_event = {
|
||||
"type": "error",
|
||||
"message": f"智能助手执行失败: {str(err)}",
|
||||
"message": "智能助手执行失败,请稍后重试",
|
||||
}
|
||||
apply_web_agent_display_event(error_event, assistant_display_message)
|
||||
event_publisher.publish(error_event)
|
||||
|
||||
@@ -209,7 +209,7 @@ class PostCommitEffectError(RuntimeError):
|
||||
"""保存结构化完成状态及逐项原始异常。"""
|
||||
self.result = result
|
||||
self.errors = errors
|
||||
super().__init__(str(errors[0]) if errors else "提交后效果执行失败")
|
||||
super().__init__("提交后的相关处理未完成,系统将自动重试")
|
||||
|
||||
|
||||
def deliver_outbox_effect(
|
||||
|
||||
@@ -5,6 +5,7 @@ from dataclasses import dataclass
|
||||
from typing import Optional, Protocol
|
||||
|
||||
from app.application.subscription.execution import SearchBatchSnapshot, SearchTaskSnapshot
|
||||
from app.runtime.errors import public_error_message
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -218,4 +219,4 @@ class SubscriptionExecutionStatusService:
|
||||
"""压平并限制内部错误文本,避免把堆栈或超长响应暴露给界面。"""
|
||||
if not error:
|
||||
return None
|
||||
return " ".join(str(error).split())[:500]
|
||||
return public_error_message(error, context="subscription")[:500]
|
||||
|
||||
@@ -44,7 +44,7 @@ from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType
|
||||
|
||||
# 身份不完整时的固定返回。身份不全的订阅写进去就是一条永远匹配不上资源的僵尸订阅,
|
||||
# 而后续按身份去重也会失效,所以必须在查询与建模之前短路
|
||||
INCOMPLETE_IDENTITY = (0, "媒体身份不完整")
|
||||
INCOMPLETE_IDENTITY = (0, "未识别到媒体信息,请检查媒体来源和媒体 ID 后重试")
|
||||
|
||||
|
||||
class SubscriptionOutboxStager(Protocol):
|
||||
|
||||
+10
-2
@@ -163,7 +163,11 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met
|
||||
if kwargs.get("raise_exception"):
|
||||
raise err
|
||||
logger.error(f"运行插件 {plugin_id} 模块 {method} 出错:{str(err)}\n{traceback.format_exc()}")
|
||||
self.messagehelper.put(title=f"{plugin_name} 发生了错误", message=str(err), role="plugin")
|
||||
self.messagehelper.put(
|
||||
title=f"{plugin_name} 运行失败",
|
||||
message="插件运行失败,请稍后重试",
|
||||
role="plugin",
|
||||
)
|
||||
self.eventmanager.send_event(
|
||||
EventType.SystemError,
|
||||
{
|
||||
@@ -183,7 +187,11 @@ class ChainBase(RecognitionMixin, MessageProcessingMixin, NotificationMixin, met
|
||||
if kwargs.get("raise_exception"):
|
||||
raise err
|
||||
logger.error(f"运行模块 {module_id}.{method} 出错:{str(err)}\n{traceback.format_exc()}")
|
||||
self.messagehelper.put(title=f"{module_name}发生了错误", message=str(err), role="system")
|
||||
self.messagehelper.put(
|
||||
title=f"{module_name}运行失败",
|
||||
message="系统模块运行失败,请稍后重试",
|
||||
role="system",
|
||||
)
|
||||
self.eventmanager.send_event(
|
||||
EventType.SystemError,
|
||||
{
|
||||
|
||||
@@ -1471,9 +1471,11 @@ class MessageChain(ChainBase):
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理AI智能体消息失败: {e}")
|
||||
logger.error(f"处理AI智能体消息失败: {e}", exc_info=True)
|
||||
self.messagehelper.put(
|
||||
f"AI智能体处理失败: {str(e)}", role="system", title="MoviePilot助手"
|
||||
"智能助手执行失败,请稍后重试",
|
||||
role="system",
|
||||
title="MoviePilot助手",
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -1796,8 +1798,11 @@ class MessageChain(ChainBase):
|
||||
}
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"准备附件上下文失败: {attachment.ref}, error: {err}")
|
||||
payload["error"] = str(err)
|
||||
logger.error(
|
||||
f"准备附件上下文失败: {attachment.ref}, error: {err}",
|
||||
exc_info=True,
|
||||
)
|
||||
payload["error"] = "附件读取失败,请稍后重试"
|
||||
prepared_files.append(payload)
|
||||
|
||||
return prepared_files or None
|
||||
|
||||
@@ -446,7 +446,8 @@ class SubscribeCreateOwner(_SubscribeOwnerBase):
|
||||
**context.options,
|
||||
)
|
||||
except ValueError as error:
|
||||
err_msg = f"订阅分类无效:{error}"
|
||||
logger.error(f"订阅分类设置无效:{error}", exc_info=True)
|
||||
err_msg = "订阅分类设置无效,请重新选择分类后重试"
|
||||
self._SubscribeChain__notify_subscribe_create_failure(context, err_msg)
|
||||
return None, err_msg
|
||||
if not sid:
|
||||
@@ -475,7 +476,8 @@ class SubscribeCreateOwner(_SubscribeOwnerBase):
|
||||
**context.options,
|
||||
)
|
||||
except ValueError as error:
|
||||
err_msg = f"订阅分类无效:{error}"
|
||||
logger.error(f"订阅分类设置无效:{error}", exc_info=True)
|
||||
err_msg = "订阅分类设置无效,请重新选择分类后重试"
|
||||
await self._SubscribeChain__async_notify_subscribe_create_failure(
|
||||
context,
|
||||
err_msg,
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.chain.media import MediaChain
|
||||
from app.chain.transfer.contract import _TransferOwnerBase
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.runtime.errors import public_error_message
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.message import Message
|
||||
from app.schemas.tmdb import TmdbEpisode
|
||||
@@ -69,7 +70,7 @@ class TransferHistoryOwner(_TransferOwnerBase):
|
||||
channel=channel,
|
||||
title="手动整理失败",
|
||||
source=source,
|
||||
text=errmsg,
|
||||
text=public_error_message(errmsg, context="transfer"),
|
||||
userid=userid,
|
||||
link=self.runtime_config.history_url,
|
||||
save_history=False,
|
||||
@@ -106,7 +107,7 @@ class TransferHistoryOwner(_TransferOwnerBase):
|
||||
channel=channel,
|
||||
title="手动整理失败",
|
||||
source=source,
|
||||
text=errmsg,
|
||||
text=public_error_message(errmsg, context="transfer"),
|
||||
userid=userid,
|
||||
link=self.runtime_config.history_url,
|
||||
save_history=False,
|
||||
@@ -182,8 +183,7 @@ class TransferHistoryOwner(_TransferOwnerBase):
|
||||
if not mediainfo:
|
||||
return (
|
||||
False,
|
||||
f"媒体信息识别失败,media_source:{media_source},media_id:{media_id},"
|
||||
f"type: {mtype.value if mtype else None}",
|
||||
"未识别到媒体信息,请检查媒体来源和媒体 ID 后重试",
|
||||
)
|
||||
if media_source and not isinstance(mediainfo, MusicInfo):
|
||||
mediainfo.scrape_source = media_source
|
||||
|
||||
+10
-10
@@ -306,7 +306,7 @@ class TransferPlanningOwner(_TransferOwnerBase):
|
||||
target_oper=self._TransferChain__select_storage_oper(target_storage),
|
||||
)
|
||||
if not transferinfo:
|
||||
raise RuntimeError("文件整理模块未返回检查点执行结果")
|
||||
raise RuntimeError("整理服务没有返回有效结果,请稍后重试")
|
||||
if callback:
|
||||
return callback(task, transferinfo)
|
||||
return transferinfo.success, transferinfo.message or ""
|
||||
@@ -551,7 +551,7 @@ class TransferPlanningOwner(_TransferOwnerBase):
|
||||
task.bind_execution_checkpoint(error.snapshot.checkpoint)
|
||||
return TransferInfo(
|
||||
success=False,
|
||||
message=str(error),
|
||||
message="整理操作多次失败,请稍后重试",
|
||||
fileitem=task.fileitem,
|
||||
fail_list=[task.fileitem.path],
|
||||
transfer_type=checkpoint.resolved_transfer_type,
|
||||
@@ -599,14 +599,14 @@ class TransferPlanningOwner(_TransferOwnerBase):
|
||||
task.bind_execution_checkpoint(error.snapshot.checkpoint)
|
||||
return TransferInfo(
|
||||
success=False,
|
||||
message=str(error),
|
||||
message="整理操作多次失败,请稍后重试",
|
||||
fileitem=task.fileitem,
|
||||
fail_list=[task.fileitem.path],
|
||||
transfer_type=checkpoint.resolved_transfer_type,
|
||||
need_notify=checkpoint.need_notify,
|
||||
)
|
||||
if result is None:
|
||||
raise RuntimeError("文件整理模块未返回检查点执行结果")
|
||||
raise RuntimeError("整理服务没有返回有效结果,请稍后重试")
|
||||
if step_runner is not None:
|
||||
task.bind_execution_checkpoint(step_runner.checkpoint(result))
|
||||
return result
|
||||
@@ -648,7 +648,7 @@ class TransferPlanningOwner(_TransferOwnerBase):
|
||||
classification_snapshot=classification_snapshot,
|
||||
)
|
||||
if checkpoint is None:
|
||||
raise RuntimeError("文件整理模块未返回规划检查点")
|
||||
raise RuntimeError("整理服务暂时无法生成有效计划,请稍后重试")
|
||||
return replace(
|
||||
checkpoint,
|
||||
classification_snapshot=classification_snapshot,
|
||||
@@ -894,7 +894,7 @@ class TransferPlanningOwner(_TransferOwnerBase):
|
||||
self._TransferChain__release_task_claim(task, error=str(error))
|
||||
return TransferInfo(
|
||||
success=False,
|
||||
message=str(error),
|
||||
message="整理失败,请稍后重试",
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
@@ -904,12 +904,12 @@ class TransferPlanningOwner(_TransferOwnerBase):
|
||||
try:
|
||||
self._TransferChain__settle_legacy_transfer_result(task, result)
|
||||
except Exception as error:
|
||||
message = f"旧整理兼容命令 durable 终态结算失败:{error}"
|
||||
logger.error(message)
|
||||
self._TransferChain__release_task_claim(task, error=message)
|
||||
diagnostic = f"旧整理兼容命令 durable 终态结算失败:{error}"
|
||||
logger.error(diagnostic, exc_info=True)
|
||||
self._TransferChain__release_task_claim(task, error=diagnostic)
|
||||
return TransferInfo(
|
||||
success=False,
|
||||
message=message,
|
||||
message="整理结果确认失败,后台将自动重试",
|
||||
fileitem=fileitem,
|
||||
fail_list=[fileitem.path],
|
||||
transfer_type=transfer_type,
|
||||
|
||||
+11
-10
@@ -17,6 +17,7 @@ from app.chain.media import MediaChain
|
||||
from app.chain.storage import StorageChain
|
||||
from app.chain.transfer.contract import _TransferOwnerBase
|
||||
from app.domain.context import MusicInfo
|
||||
from app.runtime.errors import public_error_message
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.loop import main_loop_registry
|
||||
from app.runtime.tasks import get_task_registry
|
||||
@@ -53,7 +54,7 @@ def _request_durable_transfer_retry(
|
||||
task_id,
|
||||
error,
|
||||
)
|
||||
return False, str(error)
|
||||
return False, "整理任务暂时无法重试,请稍后重试"
|
||||
return result.accepted, result.message
|
||||
|
||||
|
||||
@@ -167,13 +168,14 @@ class FailedRetryMixin(_TransferOwnerBase):
|
||||
|
||||
state, errmsg = self.redo_transfer_history(history_id)
|
||||
if state:
|
||||
public_message = public_error_message(errmsg, context="transfer")
|
||||
self.post_message(
|
||||
Message(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title=errmsg or f"整理记录 #{history_id} 已重新整理",
|
||||
title=public_message or f"整理记录 #{history_id} 已重新整理",
|
||||
link=self.runtime_config.history_url,
|
||||
save_history=False,
|
||||
)
|
||||
@@ -187,7 +189,7 @@ class FailedRetryMixin(_TransferOwnerBase):
|
||||
userid=userid,
|
||||
username=username,
|
||||
title="重新整理失败",
|
||||
text=errmsg,
|
||||
text=public_error_message(errmsg, context="transfer"),
|
||||
link=self.runtime_config.history_url,
|
||||
save_history=False,
|
||||
)
|
||||
@@ -229,6 +231,7 @@ class FailedRetryMixin(_TransferOwnerBase):
|
||||
)
|
||||
if durable_retry is not None:
|
||||
accepted, message = durable_retry
|
||||
public_message = public_error_message(message, context="transfer")
|
||||
self.post_message(
|
||||
Message(
|
||||
channel=channel,
|
||||
@@ -236,11 +239,11 @@ class FailedRetryMixin(_TransferOwnerBase):
|
||||
userid=userid,
|
||||
username=username,
|
||||
title=(
|
||||
message
|
||||
public_message
|
||||
if accepted
|
||||
else "重新整理失败"
|
||||
),
|
||||
text=None if accepted else message,
|
||||
text=None if accepted else public_message,
|
||||
link=self.runtime_config.history_url,
|
||||
save_history=False,
|
||||
)
|
||||
@@ -306,6 +309,7 @@ class FailedRetryMixin(_TransferOwnerBase):
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"智能助手重新整理失败:{e}", exc_info=True)
|
||||
await self.async_post_message(
|
||||
Message(
|
||||
channel=channel,
|
||||
@@ -313,7 +317,7 @@ class FailedRetryMixin(_TransferOwnerBase):
|
||||
userid=userid,
|
||||
username=username,
|
||||
title="智能助手整理失败",
|
||||
text=str(e),
|
||||
text="智能助手整理失败,请稍后重试",
|
||||
link=self.runtime_config.history_url,
|
||||
save_history=False,
|
||||
)
|
||||
@@ -426,10 +430,7 @@ class FailedRetryMixin(_TransferOwnerBase):
|
||||
mediainfo = recognize_context.media_info if recognize_context else None
|
||||
# 音乐专辑目录允许无预识别信息,由整理链按音频后缀逐文件解析识别
|
||||
if not mediainfo and not (mtype == MediaType.MUSIC and src_path.is_dir()):
|
||||
return False, (
|
||||
f"未识别到媒体信息,类型:{mtype.value if mtype else None},"
|
||||
f"media_source:{media_source},media_id:{media_id}"
|
||||
)
|
||||
return False, "未识别到媒体信息,请检查媒体来源和媒体 ID 后重试"
|
||||
# 重新执行整理
|
||||
if mediainfo:
|
||||
logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}")
|
||||
|
||||
@@ -39,6 +39,7 @@ from app.chain.transfer.contract import _TransferOwnerBase
|
||||
from app.domain import episode as episode_rules
|
||||
from app.domain.context import MediaInfo, MusicInfo
|
||||
from app.domain.meta.metabase import MetaBase
|
||||
from app.runtime.errors import public_error_message
|
||||
from app.runtime.log import logger
|
||||
from app.runtime.loop import main_loop_registry
|
||||
from app.schemas.message import Message
|
||||
@@ -530,7 +531,10 @@ class TransferSettlementOwner(_TransferOwnerBase):
|
||||
else task.fileitem.name if task.fileitem else "未知媒体"
|
||||
),
|
||||
season_episode=getattr(task.meta, "season_episode", "") or "",
|
||||
reason=transferinfo.message or "未知",
|
||||
reason=(
|
||||
public_error_message(transferinfo.message, context="transfer")
|
||||
or "整理失败"
|
||||
),
|
||||
history_id=history_id,
|
||||
image=(
|
||||
task.mediainfo.get_message_image()
|
||||
|
||||
@@ -265,7 +265,7 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
mediainfo,
|
||||
normalized_source,
|
||||
normalized_media_id,
|
||||
f"未识别到媒体信息,media_source:{normalized_source},media_id:{normalized_media_id}",
|
||||
"未识别到媒体信息,请检查媒体来源和媒体 ID 后重试",
|
||||
)
|
||||
return mediainfo, normalized_source, normalized_media_id, None
|
||||
|
||||
@@ -840,9 +840,11 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
queued = self.put_to_queue(task=transfer_task)
|
||||
except Exception as err:
|
||||
all_success = False
|
||||
message = f"{file_path.name} 加入整理队列失败:{err}"
|
||||
err_msgs.append(message)
|
||||
logger.error(message)
|
||||
logger.error(
|
||||
f"{file_path.name} 加入整理队列失败:{err}",
|
||||
exc_info=True,
|
||||
)
|
||||
err_msgs.append(f"{file_path.name} 未能加入整理队列,请稍后重试")
|
||||
continue
|
||||
if queued:
|
||||
if cleanup_intent:
|
||||
@@ -981,7 +983,7 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
)
|
||||
if not preview:
|
||||
self._TransferChain__fail_transfer_task(transfer_task)
|
||||
state, err_msg = False, str(e)
|
||||
state, err_msg = False, "整理任务处理失败,请稍后重试"
|
||||
finally:
|
||||
durable_settled = self._TransferChain__finish_job_execution(
|
||||
transfer_task,
|
||||
@@ -990,7 +992,7 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
)
|
||||
if terminal and not durable_settled:
|
||||
state = False
|
||||
err_msg = "整理任务 durable 终态结算失去租约"
|
||||
err_msg = "整理任务结果暂未确认,后台将自动重试"
|
||||
if not state:
|
||||
all_success = False
|
||||
logger.warn(f"{transfer_task.fileitem.name} {err_msg}")
|
||||
|
||||
@@ -1152,13 +1152,13 @@ class TransactionalTransferExecutionRepository:
|
||||
accepted=True,
|
||||
state=state,
|
||||
retry_generation=pending.retry_generation,
|
||||
message="整理任务已在等待重试",
|
||||
message="整理任务已在等待重新处理",
|
||||
)
|
||||
if state is not TransferExecutionState.FAILED:
|
||||
message = (
|
||||
"人工复核任务必须先完成专门判定"
|
||||
"这条整理任务需要先完成人工确认,再重试"
|
||||
if state is TransferExecutionState.MANUAL_REVIEW
|
||||
else "整理任务当前状态不接受用户重试"
|
||||
else "这条整理任务当前无法重试,请刷新后再试"
|
||||
)
|
||||
return TransferRetryRequestResult(
|
||||
accepted=False,
|
||||
@@ -1184,13 +1184,13 @@ class TransactionalTransferExecutionRepository:
|
||||
accepted=True,
|
||||
state=state,
|
||||
retry_generation=pending.retry_generation,
|
||||
message="整理任务已由并发请求登记重试",
|
||||
message="整理任务已提交重试,请勿重复操作",
|
||||
)
|
||||
return TransferRetryRequestResult(
|
||||
accepted=False,
|
||||
state=state,
|
||||
retry_generation=pending.retry_generation,
|
||||
message="整理任务状态已变化,未登记重试",
|
||||
message="整理任务状态已变化,请刷新后重试",
|
||||
)
|
||||
session.flush()
|
||||
session.expire_all()
|
||||
@@ -1201,7 +1201,7 @@ class TransactionalTransferExecutionRepository:
|
||||
accepted=True,
|
||||
state=TransferExecutionState.RETRY_WAIT,
|
||||
retry_generation=pending.retry_generation,
|
||||
message="整理任务已登记重试",
|
||||
message="已提交重新整理,后台将自动处理",
|
||||
)
|
||||
transaction.commit()
|
||||
return result
|
||||
@@ -1229,20 +1229,20 @@ class TransactionalTransferExecutionRepository:
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=True,
|
||||
state=None,
|
||||
message="失败整理任务已由并发请求放弃",
|
||||
message="整理任务已被其他操作放弃",
|
||||
)
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
state=None,
|
||||
message="未找到与失败历史匹配的整理任务,请刷新后重试",
|
||||
message="没有找到对应的整理任务,请刷新后重试",
|
||||
)
|
||||
|
||||
state = TransferExecutionState(pending.execution_state)
|
||||
if state is not TransferExecutionState.FAILED:
|
||||
message = (
|
||||
"人工复核任务必须先完成专门判定"
|
||||
"这条整理任务需要先完成人工确认,再重试"
|
||||
if state is TransferExecutionState.MANUAL_REVIEW
|
||||
else "整理任务当前状态不能放弃"
|
||||
else "这条整理任务当前无法放弃,请刷新后重试"
|
||||
)
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
@@ -1253,7 +1253,7 @@ class TransactionalTransferExecutionRepository:
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
state=state,
|
||||
message="整理任务仍被执行器占用,不能放弃",
|
||||
message="整理任务正在处理中,暂时无法放弃,请稍后重试",
|
||||
)
|
||||
if (
|
||||
pending.terminal_history_id != history_id
|
||||
@@ -1262,7 +1262,7 @@ class TransactionalTransferExecutionRepository:
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
state=state,
|
||||
message="整理任务失败回执已变化,请刷新后重试",
|
||||
message="整理任务状态已变化,请刷新后重试",
|
||||
)
|
||||
|
||||
deleted = pending_oper.stage_delete_terminal_failure(
|
||||
@@ -1280,7 +1280,7 @@ class TransactionalTransferExecutionRepository:
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=True,
|
||||
state=None,
|
||||
message="失败整理任务已由并发请求放弃",
|
||||
message="整理任务已被其他操作放弃",
|
||||
)
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
@@ -1289,7 +1289,7 @@ class TransactionalTransferExecutionRepository:
|
||||
if pending is not None
|
||||
else None
|
||||
),
|
||||
message="整理任务状态已变化,未放弃失败任务",
|
||||
message="整理任务状态已变化,请刷新后重试",
|
||||
)
|
||||
|
||||
# PostgreSQL/启用外键的 SQLite 会级联删除;显式清理兼容独立测试库。
|
||||
@@ -1307,7 +1307,7 @@ class TransactionalTransferExecutionRepository:
|
||||
return TransferFailureDiscardResult(
|
||||
discarded=True,
|
||||
state=TransferExecutionState.FAILED,
|
||||
message="已放弃失败整理任务",
|
||||
message="已放弃这条失败的整理任务",
|
||||
)
|
||||
except Exception:
|
||||
self._rollback(transaction)
|
||||
|
||||
+10
-3
@@ -18,10 +18,12 @@ from app.adapters.web.security.access import (
|
||||
verify_token,
|
||||
)
|
||||
from app.api.response import ResponseAPIRoute
|
||||
from app.application.outbox import PostCommitEffectError
|
||||
from app.application.plugin.routes import configure_plugin_routes
|
||||
from app.application.plugin.runtime import get_plugin_manager
|
||||
from app.application.security.token import create_access_token, decode_access_token
|
||||
from app.runtime.correlation import get_correlation_id
|
||||
from app.runtime.errors import public_error_message
|
||||
from app.runtime.localization import LocaleHelper
|
||||
from app.runtime.log import configure_correlation_id_provider, logger
|
||||
from app.runtime.loop import main_loop_registry
|
||||
@@ -195,7 +197,7 @@ async def localized_http_exception_handler(
|
||||
exc: HTTPException,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
将 HTTPException 响应统一封装为 Response 结构并保留原始错误消息。
|
||||
将 HTTPException 响应统一封装为 Response 结构并隐藏内部实现细节。
|
||||
|
||||
:param request: 当前 HTTP 请求
|
||||
:param exc: FastAPI HTTP 异常
|
||||
@@ -203,7 +205,7 @@ async def localized_http_exception_handler(
|
||||
"""
|
||||
message = _localize_exception_message(
|
||||
request,
|
||||
_get_http_exception_message(exc.detail),
|
||||
public_error_message(_get_http_exception_message(exc.detail)),
|
||||
)
|
||||
native_ai_protocol = _get_native_ai_protocol(request)
|
||||
if native_ai_protocol:
|
||||
@@ -310,11 +312,16 @@ async def localized_unhandled_exception_handler(
|
||||
code=-32603,
|
||||
message="Internal error",
|
||||
)
|
||||
message = (
|
||||
str(exc)
|
||||
if isinstance(exc, PostCommitEffectError)
|
||||
else "未知错误"
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ApiResponse[None](
|
||||
success=False,
|
||||
message=_localize_exception_message(request, "未知错误"),
|
||||
message=_localize_exception_message(request, message),
|
||||
).model_dump(mode="json"),
|
||||
)
|
||||
|
||||
|
||||
@@ -294,6 +294,38 @@
|
||||
"媒体信息识别失败": "Media information recognition failed",
|
||||
"媒体信息中没有季集信息": "Media information does not contain season and episode details",
|
||||
"文件整理模块运行失败": "File organization module failed to run",
|
||||
"已提交重新整理,后台将自动处理": "Reorganization submitted; it will be processed in the background",
|
||||
"整理任务已在等待重新处理": "The organization task is already waiting to be retried",
|
||||
"这条整理任务需要先完成人工确认,再重试": "This organization task needs manual confirmation before it can be retried",
|
||||
"这条整理任务当前无法重试,请刷新后再试": "This organization task cannot be retried right now. Refresh and try again",
|
||||
"整理任务已提交重试,请勿重复操作": "The organization task has already been queued for retry. Do not submit it again",
|
||||
"整理任务状态已变化,请刷新后重试": "The organization task changed state. Refresh and try again",
|
||||
"整理任务已被其他操作放弃": "The organization task was discarded by another operation",
|
||||
"没有找到对应的整理任务,请刷新后重试": "The organization task was not found. Refresh and try again",
|
||||
"这条整理任务当前无法放弃,请刷新后重试": "This organization task cannot be discarded right now. Refresh and try again",
|
||||
"整理任务正在处理中,暂时无法放弃,请稍后重试": "The organization task is still running and cannot be discarded yet. Try again later",
|
||||
"已放弃这条失败的整理任务": "The failed organization task was discarded",
|
||||
"整理服务没有返回有效结果,请稍后重试": "The organization service did not return a valid result. Try again later",
|
||||
"整理服务暂时无法生成有效计划,请稍后重试": "The organization service cannot create a valid plan right now. Try again later",
|
||||
"未识别到媒体信息,请检查媒体来源和媒体 ID 后重试": "Unable to recognize the media. Check the media source and media ID, then try again",
|
||||
"整理结果确认失败,后台将自动重试": "The organization result could not be confirmed. It will be retried in the background",
|
||||
"整理任务处理失败,请稍后重试": "The organization task failed. Try again later",
|
||||
"整理操作多次失败,请稍后重试": "The organization operation failed repeatedly. Try again later",
|
||||
"整理任务结果暂未确认,后台将自动重试": "The organization result is not confirmed yet and will be retried in the background",
|
||||
"提交后的相关处理未完成,系统将自动重试": "Some follow-up processing is incomplete and will be retried automatically",
|
||||
"后台任务暂未完成,系统会自动重试": "The background task is not finished yet and will be retried automatically",
|
||||
"整理失败,请刷新后重试": "Organization failed. Refresh and try again",
|
||||
"整理任务暂时无法重试,请稍后重试": "The organization task cannot be retried right now. Try again later",
|
||||
"订阅分类设置无效,请重新选择分类后重试": "The subscription classification is invalid. Select it again and try again",
|
||||
"订阅操作失败,请刷新后重试": "The subscription operation failed. Refresh and try again",
|
||||
"智能助手执行失败,请稍后重试": "Assistant execution failed. Try again later",
|
||||
"消息处理失败,请稍后重试": "Message processing failed. Try again later",
|
||||
"消息验证失败,请稍后重试": "Message verification failed. Try again later",
|
||||
"附件读取失败,请稍后重试": "Attachment could not be read. Try again later",
|
||||
"搜索失败,请稍后重试": "Search failed. Try again later",
|
||||
"MCP 服务测试失败,请检查服务配置后重试": "The MCP service test failed. Check the service configuration and try again",
|
||||
"没有可发送的浏览器通知": "No browser notification subscriptions are available",
|
||||
"消息发送失败,请检查浏览器通知权限后重试": "Message delivery failed. Check browser notification permissions and try again",
|
||||
"缺少目录参数": "Directory parameter is missing",
|
||||
"目录不存在": "Directory does not exist",
|
||||
"没有可用于识别的样本文件": "No sample files are available for recognition",
|
||||
@@ -461,6 +493,34 @@
|
||||
"订阅日历预缓存完成": "Subscription calendar precache completed"
|
||||
},
|
||||
"message_patterns": [
|
||||
{
|
||||
"source": "{name} 未能加入整理队列,请稍后重试",
|
||||
"target": "Could not add {name} to the organization queue. Try again later"
|
||||
},
|
||||
{
|
||||
"source": "已提交 {count} 个整理任务,后台将自动处理",
|
||||
"target": "Submitted {count} organization tasks; they will be processed in the background"
|
||||
},
|
||||
{
|
||||
"source": "以下整理记录未能提交重试:{reason}",
|
||||
"target": "The following organization records could not be queued for retry: {reason}"
|
||||
},
|
||||
{
|
||||
"source": "第 {history_id} 条:{reason}",
|
||||
"target": "Record {history_id}: {reason}"
|
||||
},
|
||||
{
|
||||
"source": "已提交 {count} 条旧历史给智能助手处理",
|
||||
"target": "Submitted {count} legacy records to the Assistant"
|
||||
},
|
||||
{
|
||||
"source": "消息已发送到 {success_count} 个设备",
|
||||
"target": "Message sent to {success_count} devices"
|
||||
},
|
||||
{
|
||||
"source": "消息已发送到 {success_count} 个设备,{failure_count} 个设备发送失败",
|
||||
"target": "Message sent to {success_count} devices; delivery failed for {failure_count} devices"
|
||||
},
|
||||
{
|
||||
"source": "订阅分类无效:{reason}",
|
||||
"target": "Invalid subscription classification: {reason}"
|
||||
|
||||
@@ -286,6 +286,38 @@
|
||||
"媒体信息识别失败": "媒體資訊識別失敗",
|
||||
"媒体信息中没有季集信息": "媒體資訊中沒有季集資訊",
|
||||
"文件整理模块运行失败": "檔案整理模組執行失敗",
|
||||
"已提交重新整理,后台将自动处理": "已提交重新整理,背景將自動處理",
|
||||
"整理任务已在等待重新处理": "整理任務已在等待重新處理",
|
||||
"这条整理任务需要先完成人工确认,再重试": "這筆整理任務需要先完成人工確認,再重試",
|
||||
"这条整理任务当前无法重试,请刷新后再试": "這筆整理任務目前無法重試,請重新整理後再試",
|
||||
"整理任务已提交重试,请勿重复操作": "整理任務已提交重試,請勿重複操作",
|
||||
"整理任务状态已变化,请刷新后重试": "整理任務狀態已變更,請重新整理後再試",
|
||||
"整理任务已被其他操作放弃": "整理任務已由其他操作放棄",
|
||||
"没有找到对应的整理任务,请刷新后重试": "找不到對應的整理任務,請重新整理後再試",
|
||||
"这条整理任务当前无法放弃,请刷新后重试": "這筆整理任務目前無法放棄,請重新整理後再試",
|
||||
"整理任务正在处理中,暂时无法放弃,请稍后重试": "整理任務仍在處理中,暫時無法放棄,請稍後再試",
|
||||
"已放弃这条失败的整理任务": "已放棄這筆失敗的整理任務",
|
||||
"整理服务没有返回有效结果,请稍后重试": "整理服務沒有返回有效結果,請稍後再試",
|
||||
"整理服务暂时无法生成有效计划,请稍后重试": "整理服務目前無法產生有效計畫,請稍後再試",
|
||||
"未识别到媒体信息,请检查媒体来源和媒体 ID 后重试": "無法識別媒體資訊,請檢查媒體來源和媒體 ID 後再試",
|
||||
"整理结果确认失败,后台将自动重试": "整理結果確認失敗,背景將自動重試",
|
||||
"整理任务处理失败,请稍后重试": "整理任務處理失敗,請稍後再試",
|
||||
"整理操作多次失败,请稍后重试": "整理操作多次失敗,請稍後再試",
|
||||
"整理任务结果暂未确认,后台将自动重试": "整理任務結果尚未確認,背景將自動重試",
|
||||
"提交后的相关处理未完成,系统将自动重试": "提交後的相關處理尚未完成,系統將自動重試",
|
||||
"后台任务暂未完成,系统会自动重试": "背景任務尚未完成,系統將自動重試",
|
||||
"整理失败,请刷新后重试": "整理失敗,請重新整理後再試",
|
||||
"整理任务暂时无法重试,请稍后重试": "整理任務目前無法重試,請稍後再試",
|
||||
"订阅分类设置无效,请重新选择分类后重试": "訂閱分類設定無效,請重新選擇分類後再試",
|
||||
"订阅操作失败,请刷新后重试": "訂閱操作失敗,請重新整理後再試",
|
||||
"智能助手执行失败,请稍后重试": "智慧助手執行失敗,請稍後再試",
|
||||
"消息处理失败,请稍后重试": "訊息處理失敗,請稍後再試",
|
||||
"消息验证失败,请稍后重试": "訊息驗證失敗,請稍後再試",
|
||||
"附件读取失败,请稍后重试": "無法讀取附件,請稍後再試",
|
||||
"搜索失败,请稍后重试": "搜尋失敗,請稍後再試",
|
||||
"MCP 服务测试失败,请检查服务配置后重试": "MCP 服務測試失敗,請檢查服務設定後再試",
|
||||
"没有可发送的浏览器通知": "沒有可傳送的瀏覽器通知訂閱",
|
||||
"消息发送失败,请检查浏览器通知权限后重试": "訊息傳送失敗,請檢查瀏覽器通知權限後再試",
|
||||
"缺少目录参数": "缺少目錄參數",
|
||||
"目录不存在": "目錄不存在",
|
||||
"没有可用于识别的样本文件": "沒有可用於識別的樣本檔案",
|
||||
@@ -451,6 +483,34 @@
|
||||
"订阅日历预缓存完成": "訂閱日曆預快取完成"
|
||||
},
|
||||
"message_patterns": [
|
||||
{
|
||||
"source": "{name} 未能加入整理队列,请稍后重试",
|
||||
"target": "無法將 {name} 加入整理佇列,請稍後再試"
|
||||
},
|
||||
{
|
||||
"source": "已提交 {count} 个整理任务,后台将自动处理",
|
||||
"target": "已提交 {count} 個整理任務,背景將自動處理"
|
||||
},
|
||||
{
|
||||
"source": "以下整理记录未能提交重试:{reason}",
|
||||
"target": "以下整理記錄無法提交重試:{reason}"
|
||||
},
|
||||
{
|
||||
"source": "第 {history_id} 条:{reason}",
|
||||
"target": "第 {history_id} 筆:{reason}"
|
||||
},
|
||||
{
|
||||
"source": "已提交 {count} 条旧历史给智能助手处理",
|
||||
"target": "已提交 {count} 筆舊記錄交由智慧助手處理"
|
||||
},
|
||||
{
|
||||
"source": "消息已发送到 {success_count} 个设备",
|
||||
"target": "訊息已傳送至 {success_count} 部裝置"
|
||||
},
|
||||
{
|
||||
"source": "消息已发送到 {success_count} 个设备,{failure_count} 个设备发送失败",
|
||||
"target": "訊息已傳送至 {success_count} 部裝置,{failure_count} 部裝置傳送失敗"
|
||||
},
|
||||
{
|
||||
"source": "不支持的推荐来源: {source}",
|
||||
"target": "不支援的推薦來源: {source}"
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""把内部错误转换为可直接展示给用户的简短文案。"""
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
PublicErrorContext = Literal[
|
||||
"generic",
|
||||
"transfer",
|
||||
"subscription",
|
||||
"message",
|
||||
"outbox",
|
||||
]
|
||||
|
||||
_CONTEXT_FALLBACKS: dict[PublicErrorContext, str] = {
|
||||
"generic": "操作失败,请稍后重试",
|
||||
"transfer": "整理失败,请刷新后重试",
|
||||
"subscription": "订阅操作失败,请刷新后重试",
|
||||
"message": "消息处理失败,请稍后重试",
|
||||
"outbox": "后台任务暂未完成,系统会自动重试",
|
||||
}
|
||||
|
||||
_TECHNICAL_MARKERS = (
|
||||
"outbox",
|
||||
"durable",
|
||||
"checkpoint",
|
||||
"provider_pending",
|
||||
"schema_version",
|
||||
"operation_id",
|
||||
"operation id",
|
||||
"attempt_token",
|
||||
"attempt token",
|
||||
"lease_token",
|
||||
"transferinfo",
|
||||
"dispatcher",
|
||||
"traceback",
|
||||
"runtimeerror",
|
||||
"valueerror",
|
||||
"typeerror",
|
||||
"keyerror",
|
||||
"attributeerror",
|
||||
"operationalerror",
|
||||
"connectionerror",
|
||||
"timeout",
|
||||
"client error",
|
||||
"api error",
|
||||
"connecterror",
|
||||
"connect error",
|
||||
"connection",
|
||||
"errno",
|
||||
"http ",
|
||||
"http/",
|
||||
"max retries",
|
||||
"retryerror",
|
||||
"response status",
|
||||
"sslerror",
|
||||
"ssl error",
|
||||
"status code",
|
||||
"too many requests",
|
||||
"server error",
|
||||
"for url",
|
||||
"object at 0x",
|
||||
"no streaming chunk",
|
||||
"streaming chunk",
|
||||
"failed",
|
||||
"检查点",
|
||||
"步骤意图",
|
||||
"意图",
|
||||
"事实",
|
||||
"证据",
|
||||
"租约",
|
||||
"副作用",
|
||||
"执行凭证",
|
||||
"指纹",
|
||||
"回执",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_error(error: Optional[object]) -> str:
|
||||
"""压缩异常中的换行和多余空白,避免内部格式污染界面。"""
|
||||
if error is None:
|
||||
return ""
|
||||
return " ".join(str(error).split())
|
||||
|
||||
|
||||
def _contains_technical_marker(message: str) -> bool:
|
||||
"""判断文案是否包含只适合日志或开发者排查的实现术语。"""
|
||||
normalized = message.lower()
|
||||
return any(marker.lower() in normalized for marker in _TECHNICAL_MARKERS)
|
||||
|
||||
|
||||
def _context_fallback(
|
||||
context: PublicErrorContext,
|
||||
message: str,
|
||||
fallback: str | None,
|
||||
) -> str:
|
||||
"""根据业务上下文选择不泄露内部细节的兜底文案。"""
|
||||
if fallback:
|
||||
return fallback
|
||||
normalized = message.lower()
|
||||
if "订阅" in message or context == "subscription":
|
||||
return _CONTEXT_FALLBACKS["subscription"]
|
||||
if "智能助手" in message or "ai智能体" in normalized:
|
||||
return "智能助手执行失败,请稍后重试"
|
||||
if "整理" in message or context == "transfer":
|
||||
return _CONTEXT_FALLBACKS["transfer"]
|
||||
if "outbox" in normalized or "durable" in normalized or context == "outbox":
|
||||
return _CONTEXT_FALLBACKS["outbox"]
|
||||
return _CONTEXT_FALLBACKS[context]
|
||||
|
||||
|
||||
def public_error_message(
|
||||
error: Optional[object],
|
||||
*,
|
||||
context: PublicErrorContext = "generic",
|
||||
fallback: Optional[str] = None,
|
||||
) -> str:
|
||||
"""将内部异常或状态转换为人类可理解的前台提示。
|
||||
|
||||
只有明确识别为内部实现信息的文本才会被替换;普通的业务提示会原样保留,
|
||||
这样既能统一异常出口,又不会丢失“目录不存在”等可执行的处理建议。
|
||||
原始异常应继续写入日志或结构化诊断字段,不应作为本函数的返回值直接展示。
|
||||
"""
|
||||
message = _normalize_error(error)
|
||||
if not message:
|
||||
return fallback or _CONTEXT_FALLBACKS[context]
|
||||
|
||||
if _contains_technical_marker(message):
|
||||
return _context_fallback(context, message, fallback)
|
||||
return message
|
||||
+10
-1
@@ -1,7 +1,8 @@
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.runtime.errors import public_error_message
|
||||
from app.schemas.common import JsonData
|
||||
from app.schemas.media import OptionalMediaIdentityMixin
|
||||
from app.schemas.types import MediaSource
|
||||
@@ -148,6 +149,14 @@ class TransferHistory(OptionalMediaIdentityMixin, BaseModel):
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_validator("errmsg", mode="before")
|
||||
@classmethod
|
||||
def _sanitize_error_message(cls, value: object) -> Optional[str]:
|
||||
"""历史接口只返回可理解的整理失败原因,数据库原文仍用于诊断。"""
|
||||
if value is None or not str(value).strip():
|
||||
return None
|
||||
return public_error_message(value, context="transfer")
|
||||
|
||||
|
||||
class BatchTransferHistoryRedoRequest(BaseModel):
|
||||
"""批量重新整理历史请求。"""
|
||||
|
||||
@@ -2,9 +2,9 @@ from typing import Any, Generic, Optional, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_validator
|
||||
|
||||
from app.runtime.errors import public_error_message
|
||||
from app.runtime.localization import LocaleHelper
|
||||
|
||||
|
||||
DataT = TypeVar("DataT")
|
||||
|
||||
|
||||
@@ -26,10 +26,13 @@ class Response(BaseModel, Generic[DataT]):
|
||||
@field_validator("message", mode="before")
|
||||
@classmethod
|
||||
def localize_message(cls, value: Any) -> str:
|
||||
"""按当前请求语言直接本地化消息文本,并将空消息归一为空字符串。"""
|
||||
"""先移除内部实现术语,再按当前请求语言本地化消息文本。"""
|
||||
if value is None:
|
||||
return ""
|
||||
message = str(value)
|
||||
raw_message = str(value)
|
||||
if not raw_message.strip():
|
||||
return ""
|
||||
message = public_error_message(raw_message)
|
||||
if not message:
|
||||
return ""
|
||||
return LocaleHelper.translate_text(
|
||||
|
||||
@@ -200,15 +200,11 @@ class TestAgentBackgroundOutput:
|
||||
|
||||
result, _ = await agent._execute_agent([HumanMessage(content="测试超时")])
|
||||
|
||||
expected = (
|
||||
"智能助手执行失败: No streaming chunk received for 120.0s "
|
||||
"(model=mimo-v2.5-pro, chunks_received=1)."
|
||||
)
|
||||
expected = "智能助手执行失败,请稍后重试"
|
||||
assert result == expected
|
||||
agent.send_agent_message.assert_awaited_once_with(expected, title="")
|
||||
sent_message = agent.send_agent_message.await_args.args[0]
|
||||
assert "No streaming chunk received for 120.0s" in sent_message
|
||||
assert "Tune or disable" not in sent_message
|
||||
assert "No streaming chunk received for 120.0s" not in sent_message
|
||||
assert agent._streamed_output == expected
|
||||
|
||||
async def test_streaming_success_stops_streaming_once(self):
|
||||
|
||||
@@ -1141,7 +1141,7 @@ def test_summary_failure_preserves_database_history():
|
||||
|
||||
isolated_memory.clear_memory(session_id, user_id)
|
||||
recovered_messages = isolated_memory.get_agent_messages(session_id, user_id)
|
||||
assert result == "智能助手执行失败: 会话上下文压缩失败,原有上下文已保留,请稍后重试"
|
||||
assert result == "智能助手执行失败,请稍后重试"
|
||||
assert agent._compiled_agent_bundle is None
|
||||
assert [message.content for message in recovered_messages] == ["数据库中的旧事实"]
|
||||
send_usage_event.assert_called_once()
|
||||
|
||||
@@ -105,7 +105,7 @@ def test_durable_retry_progress_is_immediately_completed_for_existing_sse() -> N
|
||||
progress_key = "test_history_durable_retry_completed"
|
||||
await history_endpoint._complete_durable_retry_progress(
|
||||
progress_key=progress_key,
|
||||
text="整理任务已登记重试",
|
||||
text="已提交重新整理,后台将自动处理",
|
||||
history_ids=[8],
|
||||
)
|
||||
detail = await AsyncProgressHelper(progress_key).get()
|
||||
@@ -119,7 +119,7 @@ def test_durable_retry_progress_is_immediately_completed_for_existing_sse() -> N
|
||||
assert detail["data"]["history_ids"] == [8]
|
||||
assert detail["data"]["success"] is True
|
||||
assert detail["data"]["completed"] is True
|
||||
assert detail["data"]["message"] == "整理任务已登记重试"
|
||||
assert detail["data"]["message"] == "已提交重新整理,后台将自动处理"
|
||||
|
||||
|
||||
def test_single_ai_redo_requests_durable_retry_without_agent(monkeypatch) -> None:
|
||||
@@ -130,7 +130,7 @@ def test_single_ai_redo_requests_durable_retry_without_agent(monkeypatch) -> Non
|
||||
"task-11": _retry_result(
|
||||
accepted=True,
|
||||
state=TransferExecutionState.RETRY_WAIT,
|
||||
message="整理任务已登记重试",
|
||||
message="已提交重新整理,后台将自动处理",
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -160,7 +160,7 @@ def test_single_ai_redo_requests_durable_retry_without_agent(monkeypatch) -> Non
|
||||
assert response.success is True
|
||||
assert response.data is not None
|
||||
assert response.data["progress_key"].startswith("transfer_retry_11_")
|
||||
assert response.message == "整理任务已登记重试"
|
||||
assert response.message == "已提交重新整理,后台将自动处理"
|
||||
assert completed_progress[0]["history_ids"] == [11]
|
||||
assert _RetryCommand.calls == [
|
||||
(
|
||||
@@ -182,7 +182,7 @@ def test_single_ai_redo_reports_manual_review_rejection(monkeypatch) -> None:
|
||||
"task-12": _retry_result(
|
||||
accepted=False,
|
||||
state=TransferExecutionState.MANUAL_REVIEW,
|
||||
message="人工复核任务必须先完成专门判定",
|
||||
message="这条整理任务需要先完成人工确认,再重试",
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -204,7 +204,7 @@ def test_single_ai_redo_reports_manual_review_rejection(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
assert response.success is False
|
||||
assert response.message == "人工复核任务必须先完成专门判定"
|
||||
assert response.message == "这条整理任务需要先完成人工确认,再重试"
|
||||
|
||||
|
||||
def test_batch_ai_redo_returns_completed_progress_for_durable_tasks(monkeypatch) -> None:
|
||||
@@ -215,12 +215,12 @@ def test_batch_ai_redo_returns_completed_progress_for_durable_tasks(monkeypatch)
|
||||
"task-18": _retry_result(
|
||||
accepted=True,
|
||||
state=TransferExecutionState.RETRY_WAIT,
|
||||
message="整理任务已登记重试",
|
||||
message="已提交重新整理,后台将自动处理",
|
||||
),
|
||||
"task-19": _retry_result(
|
||||
accepted=True,
|
||||
state=TransferExecutionState.RETRY_WAIT,
|
||||
message="整理任务已在等待重试",
|
||||
message="整理任务已在等待重新处理",
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -269,12 +269,12 @@ def test_batch_ai_redo_reports_each_rejection_without_starting_legacy_agent(
|
||||
"task-21": _retry_result(
|
||||
accepted=True,
|
||||
state=TransferExecutionState.RETRY_WAIT,
|
||||
message="整理任务已登记重试",
|
||||
message="已提交重新整理,后台将自动处理",
|
||||
),
|
||||
"task-22": _retry_result(
|
||||
accepted=False,
|
||||
state=TransferExecutionState.RUNNING,
|
||||
message="整理任务当前状态不接受用户重试",
|
||||
message="这条整理任务当前无法重试,请刷新后再试",
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -308,8 +308,8 @@ def test_batch_ai_redo_reports_each_rejection_without_starting_legacy_agent(
|
||||
)
|
||||
|
||||
assert response.success is False
|
||||
assert "已登记 1 个持久整理任务重试" in response.message
|
||||
assert "#22 [running]: 整理任务当前状态不接受用户重试" in response.message
|
||||
assert "已提交 1 个整理任务,后台将自动处理" in response.message
|
||||
assert "第 22 条:这条整理任务当前无法重试,请刷新后再试" in response.message
|
||||
assert response.data is None
|
||||
assert "1 条旧历史未提交" in response.message
|
||||
assert prompted == []
|
||||
@@ -330,7 +330,7 @@ def test_batch_ai_redo_sends_only_legacy_records_after_durable_acceptance(
|
||||
"task-24": _retry_result(
|
||||
accepted=True,
|
||||
state=TransferExecutionState.RETRY_WAIT,
|
||||
message="整理任务已登记重试",
|
||||
message="已提交重新整理,后台将自动处理",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -146,14 +146,14 @@ def test_transfer_delete_rejects_nonfailed_durable_receipt_before_file_side_effe
|
||||
TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
state=TransferExecutionState.MANUAL_REVIEW,
|
||||
message="人工复核任务必须先完成专门判定",
|
||||
message="这条整理任务需要先完成人工确认,再重试",
|
||||
)
|
||||
)
|
||||
|
||||
result = command.delete(7, delete_source=True, delete_destination=True)
|
||||
|
||||
assert result.success is False
|
||||
assert result.message == "人工复核任务必须先完成专门判定"
|
||||
assert result.message == "这条整理任务需要先完成人工确认,再重试"
|
||||
assert result.history == "retained"
|
||||
dependencies["delete_media_file"].assert_not_called()
|
||||
dependencies["repository"].stage_delete.assert_not_called()
|
||||
@@ -170,7 +170,7 @@ def test_transfer_delete_discards_failed_durable_receipt_before_cleanup():
|
||||
TransferFailureDiscardResult(
|
||||
discarded=True,
|
||||
state=TransferExecutionState.FAILED,
|
||||
message="已放弃失败整理任务",
|
||||
message="已放弃这条失败的整理任务",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""统一前台错误文案的转换规则测试。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.outbox import PostCommitEffectError, PostCommitResult
|
||||
from app.factory import localized_unhandled_exception_handler
|
||||
from app.runtime.errors import public_error_message
|
||||
from app.schemas.history import TransferHistory
|
||||
from app.schemas.response import Response
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "expected"),
|
||||
[
|
||||
("已提交重新整理,后台将自动处理", "已提交重新整理,后台将自动处理"),
|
||||
("这条整理任务需要先完成人工确认,再重试", "这条整理任务需要先完成人工确认,再重试"),
|
||||
("这条整理任务当前无法重试,请刷新后再试", "这条整理任务当前无法重试,请刷新后再试"),
|
||||
("整理任务正在处理中,暂时无法放弃,请稍后重试", "整理任务正在处理中,暂时无法放弃,请稍后重试"),
|
||||
],
|
||||
)
|
||||
def test_transfer_status_uses_human_readable_messages(source: str, expected: str) -> None:
|
||||
"""整理状态不应把内部调度术语直接返回给用户。"""
|
||||
assert public_error_message(source, context="transfer") == expected
|
||||
|
||||
|
||||
def test_technical_transfer_message_is_hidden() -> None:
|
||||
"""整理检查点、操作身份等内部信息应统一降级为可执行提示。"""
|
||||
message = public_error_message(
|
||||
"整理步骤意图 operation_id=op-1 的 checkpoint evidence 无效",
|
||||
context="transfer",
|
||||
)
|
||||
|
||||
assert message == "整理失败,请刷新后重试"
|
||||
assert "意图" not in message
|
||||
assert "checkpoint" not in message
|
||||
|
||||
|
||||
def test_subscription_status_does_not_expose_provider_timeout() -> None:
|
||||
"""订阅执行状态不应把服务商和超时实现细节展示给前端。"""
|
||||
assert (
|
||||
public_error_message("provider timeout", context="subscription")
|
||||
== "订阅操作失败,请刷新后重试"
|
||||
)
|
||||
|
||||
|
||||
def test_post_commit_failure_explains_background_retry() -> None:
|
||||
"""提交后的副作用失败应说明业务已进入后台补偿,而不是暴露 Outbox 术语。"""
|
||||
assert (
|
||||
public_error_message("提交后的相关处理未完成,系统将自动重试", context="outbox")
|
||||
== "提交后的相关处理未完成,系统将自动重试"
|
||||
)
|
||||
|
||||
|
||||
def test_standard_response_applies_the_same_public_message_policy() -> None:
|
||||
"""标准接口响应和显式调用转换函数必须使用同一套规则。"""
|
||||
response = Response(
|
||||
success=False,
|
||||
message="整理结果确认失败,后台将自动重试",
|
||||
)
|
||||
|
||||
assert response.message == "整理结果确认失败,后台将自动重试"
|
||||
|
||||
|
||||
def test_clear_business_message_is_preserved() -> None:
|
||||
"""已有明确的业务提示不能因统一处理而丢失具体操作建议。"""
|
||||
assert public_error_message("源目录不存在:/downloads/demo") == "源目录不存在:/downloads/demo"
|
||||
|
||||
|
||||
def test_transfer_history_hides_internal_error_in_nested_public_data() -> None:
|
||||
"""整理历史的嵌套失败原因也不能绕过前台文案边界。"""
|
||||
history = TransferHistory(
|
||||
id=1,
|
||||
status=False,
|
||||
errmsg="整理步骤意图 operation_id=op-1 的 checkpoint evidence 无效",
|
||||
)
|
||||
|
||||
assert history.errmsg == "整理失败,请刷新后重试"
|
||||
|
||||
|
||||
def test_post_commit_error_keeps_background_retry_message() -> None:
|
||||
"""未捕获的提交后效果异常应说明业务已提交且会自动补偿。"""
|
||||
error = PostCommitEffectError(
|
||||
PostCommitResult(value=None, business_committed=True),
|
||||
(RuntimeError("provider failed"),),
|
||||
)
|
||||
response = asyncio.run(
|
||||
localized_unhandled_exception_handler(
|
||||
SimpleNamespace(
|
||||
query_params={},
|
||||
headers={"accept-language": "zh-CN"},
|
||||
),
|
||||
error,
|
||||
)
|
||||
)
|
||||
|
||||
assert json.loads(response.body)["message"] == "提交后的相关处理未完成,系统将自动重试"
|
||||
@@ -92,7 +92,7 @@ def test_failed_search_exposes_safe_error():
|
||||
statuses = asyncio.run(SubscriptionExecutionStatusService(repository).for_subscriptions((3,)))
|
||||
|
||||
assert statuses[3].state == "failed"
|
||||
assert statuses[3].error == "provider timeout"
|
||||
assert statuses[3].error == "订阅操作失败,请刷新后重试"
|
||||
|
||||
|
||||
def test_batch_requires_complete_subscription_access():
|
||||
|
||||
@@ -19,7 +19,7 @@ class _RetryCommand:
|
||||
accepted=True,
|
||||
state=TransferExecutionState.RETRY_WAIT,
|
||||
retry_generation=2,
|
||||
message="整理任务已登记重试",
|
||||
message="已提交重新整理,后台将自动处理",
|
||||
)
|
||||
|
||||
def __init__(self, repository: object) -> None:
|
||||
@@ -39,7 +39,7 @@ class _DiscardCommand:
|
||||
result = TransferFailureDiscardResult(
|
||||
discarded=True,
|
||||
state=TransferExecutionState.FAILED,
|
||||
message="已放弃失败整理任务",
|
||||
message="已放弃这条失败的整理任务",
|
||||
)
|
||||
|
||||
def __init__(self, repository: object) -> None:
|
||||
@@ -60,7 +60,7 @@ def _install_retry_port(monkeypatch) -> object:
|
||||
accepted=True,
|
||||
state=TransferExecutionState.RETRY_WAIT,
|
||||
retry_generation=2,
|
||||
message="整理任务已登记重试",
|
||||
message="已提交重新整理,后台将自动处理",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.chain.transfer.retry.TransferExecutionCommand",
|
||||
@@ -76,7 +76,7 @@ def _install_discard_port(monkeypatch) -> object:
|
||||
_DiscardCommand.result = TransferFailureDiscardResult(
|
||||
discarded=True,
|
||||
state=TransferExecutionState.FAILED,
|
||||
message="已放弃失败整理任务",
|
||||
message="已放弃这条失败的整理任务",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.chain.transfer.records.TransferExecutionCommand",
|
||||
@@ -115,7 +115,7 @@ def test_durable_history_redo_only_requests_persistent_retry(monkeypatch):
|
||||
state, message = chain._re_transfer(logid=81)
|
||||
|
||||
assert state is True
|
||||
assert message == "整理任务已登记重试"
|
||||
assert message == "已提交重新整理,后台将自动处理"
|
||||
assert _RetryCommand.calls == [
|
||||
(
|
||||
repository,
|
||||
@@ -273,7 +273,7 @@ def test_durable_manual_cleanup_rejects_nonfailed_state(monkeypatch):
|
||||
_DiscardCommand.result = TransferFailureDiscardResult(
|
||||
discarded=False,
|
||||
state=TransferExecutionState.MANUAL_REVIEW,
|
||||
message="人工复核任务必须先完成专门判定",
|
||||
message="这条整理任务需要先完成人工确认,再重试",
|
||||
)
|
||||
history = SimpleNamespace(
|
||||
id=85,
|
||||
@@ -313,7 +313,7 @@ def test_durable_manual_cleanup_rejects_nonfailed_state(monkeypatch):
|
||||
)
|
||||
|
||||
assert state is False
|
||||
assert message == "人工复核任务必须先完成专门判定"
|
||||
assert message == "这条整理任务需要先完成人工确认,再重试"
|
||||
|
||||
|
||||
def test_durable_ai_button_bypasses_agent_and_requests_scheduler(monkeypatch):
|
||||
@@ -353,7 +353,7 @@ def test_durable_ai_button_bypasses_agent_and_requests_scheduler(monkeypatch):
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0].title == "整理任务已登记重试"
|
||||
assert messages[0].title == "已提交重新整理,后台将自动处理"
|
||||
assert _RetryCommand.calls[0][1]["requested_by"] == "ai_retry_button"
|
||||
|
||||
|
||||
@@ -364,7 +364,7 @@ def test_durable_manual_review_rejection_does_not_fall_back_to_legacy(monkeypatc
|
||||
accepted=False,
|
||||
state=TransferExecutionState.MANUAL_REVIEW,
|
||||
retry_generation=1,
|
||||
message="人工复核任务必须先完成专门判定",
|
||||
message="这条整理任务需要先完成人工确认,再重试",
|
||||
)
|
||||
history = SimpleNamespace(
|
||||
id=84,
|
||||
@@ -386,4 +386,4 @@ def test_durable_manual_review_rejection_does_not_fall_back_to_legacy(monkeypatc
|
||||
state, message = chain._re_transfer(logid=84)
|
||||
|
||||
assert state is False
|
||||
assert message == "人工复核任务必须先完成专门判定"
|
||||
assert message == "这条整理任务需要先完成人工确认,再重试"
|
||||
|
||||
@@ -212,7 +212,8 @@ def test_legacy_settlement_double_failure_releases_claim_without_deleting_eviden
|
||||
returned = _invoke(chain, _fileitem())
|
||||
|
||||
assert returned.success is False
|
||||
assert "writer unavailable" in (returned.message or "")
|
||||
assert returned.message == "整理结果确认失败,后台将自动重试"
|
||||
assert "writer unavailable" not in (returned.message or "")
|
||||
assert executed == ["source-v1"]
|
||||
assert chain.durable_event_writer.transfer_result.call_count == 2
|
||||
chain._transfer_admissions.release_claim.assert_called_once_with(
|
||||
|
||||
@@ -211,4 +211,5 @@ def test_do_transfer_reports_durable_admission_failure():
|
||||
state, message = chain.do_transfer(fileitem=fileitem, background=True)
|
||||
|
||||
assert state is False
|
||||
assert "加入整理队列失败:db locked" in message
|
||||
assert "未能加入整理队列,请稍后重试" in message
|
||||
assert "db locked" not in message
|
||||
|
||||
@@ -2,9 +2,11 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.endpoints import message as message_endpoint
|
||||
from app.api.endpoints.message import is_webpush_subscription_gone
|
||||
from app.runtime.config import global_vars
|
||||
from app.runtime.webpush import webpush_registry
|
||||
from app.schemas.message import SubscriptionMessage
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -63,3 +65,57 @@ def test_is_webpush_subscription_gone_matches_404_and_410():
|
||||
assert not is_webpush_subscription_gone(
|
||||
SimpleNamespace(response=SimpleNamespace(status_code=500))
|
||||
)
|
||||
|
||||
|
||||
def test_send_notification_reports_all_delivery_failures(monkeypatch):
|
||||
"""所有浏览器通知发送失败时,接口应返回可执行的失败提示。"""
|
||||
webpush_registry.upsert(
|
||||
{"endpoint": "https://push.example/a", "keys": {"p256dh": "key"}}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
message_endpoint,
|
||||
"get_api_runtime_config_snapshot",
|
||||
lambda: SimpleNamespace(vapid_private_key="private", vapid_subject="mailto:test@example.com"),
|
||||
)
|
||||
|
||||
from pywebpush import WebPushException
|
||||
|
||||
def fail_delivery(**_):
|
||||
"""模拟 Web Push SDK 抛出发送异常。"""
|
||||
raise WebPushException("failed")
|
||||
|
||||
monkeypatch.setattr("pywebpush.webpush", fail_delivery)
|
||||
|
||||
response = message_endpoint.send_notification(SubscriptionMessage(title="测试"), object())
|
||||
|
||||
assert response.success is False
|
||||
assert response.message == "消息发送失败,请检查浏览器通知权限后重试"
|
||||
|
||||
|
||||
def test_send_notification_reports_partial_delivery(monkeypatch):
|
||||
"""部分设备发送成功时,接口应同时告知成功和失败数量。"""
|
||||
webpush_registry.upsert({"endpoint": "https://push.example/a", "keys": {}})
|
||||
webpush_registry.upsert({"endpoint": "https://push.example/b", "keys": {}})
|
||||
monkeypatch.setattr(
|
||||
message_endpoint,
|
||||
"get_api_runtime_config_snapshot",
|
||||
lambda: SimpleNamespace(vapid_private_key="private", vapid_subject="mailto:test@example.com"),
|
||||
)
|
||||
|
||||
from pywebpush import WebPushException
|
||||
|
||||
attempts = iter([None, WebPushException("failed")])
|
||||
|
||||
def deliver_once(**_):
|
||||
"""模拟一次成功和一次失败的设备发送。"""
|
||||
attempt = next(attempts)
|
||||
if isinstance(attempt, Exception):
|
||||
raise attempt
|
||||
return attempt
|
||||
|
||||
monkeypatch.setattr("pywebpush.webpush", deliver_once)
|
||||
|
||||
response = message_endpoint.send_notification(SubscriptionMessage(title="测试"), object())
|
||||
|
||||
assert response.success is True
|
||||
assert response.message == "消息已发送到 1 个设备,1 个设备发送失败"
|
||||
|
||||
Reference in New Issue
Block a user