fix(api): humanize user-facing error messages

This commit is contained in:
jxxghp
2026-09-04 17:55:56 +08:00
parent 0ecc2825e2
commit ff01449cdd
37 changed files with 718 additions and 146 deletions
+4 -4
View File
@@ -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:
+3 -3
View File
@@ -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,
},
+16 -11
View File
@@ -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},
)
+32 -5
View File
@@ -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="消息发送失败,请检查浏览器通知权限后重试",
)
+5 -1
View File
@@ -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
+29 -4
View File
@@ -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])
+66 -14
View File
@@ -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=errormsg)
return _SchemaResponse(
success=False,
message=_public_transfer_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
View File
@@ -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(
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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(
+2 -1
View File
@@ -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]
+1 -1
View File
@@ -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
View File
@@ -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,
{
+9 -4
View File
@@ -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
+4 -2
View File
@@ -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,
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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}")
+5 -1
View File
@@ -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()
+8 -6
View File
@@ -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}")
+15 -15
View File
@@ -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
View File
@@ -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"),
)
+60
View File
@@ -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}"
+60
View File
@@ -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}"
+128
View File
@@ -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
View File
@@ -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):
"""批量重新整理历史请求。"""
+6 -3
View File
@@ -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(
+2 -6
View File
@@ -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):
+1 -1
View File
@@ -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()
+13 -13
View File
@@ -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="已提交重新整理,后台将自动处理",
)
},
)
+3 -3
View File
@@ -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="已放弃这条失败整理任务",
)
)
+100
View File
@@ -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"] == "提交后的相关处理未完成,系统将自动重试"
+1 -1
View File
@@ -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():
+10 -10
View File
@@ -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(
+2 -1
View File
@@ -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
+56
View File
@@ -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 个设备发送失败"