From ff01449cddb89cffdf0c602fff2c674aee3e9171 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 4 Sep 2026 17:55:56 +0800 Subject: [PATCH] fix(api): humanize user-facing error messages --- app/agent/orchestrator.py | 8 +- app/api/endpoints/agent.py | 6 +- app/api/endpoints/history.py | 27 ++-- app/api/endpoints/message.py | 37 ++++- app/api/endpoints/search.py | 6 +- app/api/endpoints/subscribe.py | 33 ++++- app/api/endpoints/transfer.py | 80 +++++++++-- app/api/servarr.py | 21 ++- app/application/messaging/agent.py | 2 +- app/application/outbox.py | 2 +- app/application/subscription/status.py | 3 +- app/application/subscription/write.py | 2 +- app/chain/base.py | 12 +- app/chain/message.py | 13 +- app/chain/subscribe/create.py | 6 +- app/chain/transfer/history.py | 8 +- app/chain/transfer/plan.py | 20 +-- app/chain/transfer/retry.py | 21 +-- app/chain/transfer/settlement.py | 6 +- app/chain/transfer/workflow.py | 14 +- app/db/adapters/transfer/execution.py | 30 ++-- app/factory.py | 13 +- app/locales/en-US.json | 60 ++++++++ app/locales/zh-TW.json | 60 ++++++++ app/runtime/errors.py | 128 ++++++++++++++++++ app/schemas/history.py | 11 +- app/schemas/response.py | 9 +- tests/test_agent_background_output.py | 8 +- tests/test_agent_summarization_streaming.py | 2 +- tests/test_history_ai_retry_gate.py | 26 ++-- tests/test_history_mutation_command.py | 6 +- tests/test_public_error_messages.py | 100 ++++++++++++++ tests/test_subscription_execution_status.py | 2 +- tests/test_transfer_durable_retry_owner.py | 20 +-- tests/test_transfer_legacy_terminal_compat.py | 3 +- tests/test_transfer_queue_service.py | 3 +- tests/test_webpush_subscription.py | 56 ++++++++ 37 files changed, 718 insertions(+), 146 deletions(-) create mode 100644 app/runtime/errors.py create mode 100644 tests/test_public_error_messages.py diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py index 906d7cde6..6016ad003 100644 --- a/app/agent/orchestrator.py +++ b/app/agent/orchestrator.py @@ -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: diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index e0435c0c6..4972160f7 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -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, }, diff --git a/app/api/endpoints/history.py b/app/api/endpoints/history.py index 15338c41d..dcd815c38 100644 --- a/app/api/endpoints/history.py +++ b/app/api/endpoints/history.py @@ -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}, ) diff --git a/app/api/endpoints/message.py b/app/api/endpoints/message.py index aeafe2e35..390d409a0 100644 --- a/app/api/endpoints/message.py +++ b/app/api/endpoints/message.py @@ -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="消息发送失败,请检查浏览器通知权限后重试", + ) diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index d383849cd..ab8da8919 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -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 diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 0bc566ab5..92f747cee 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -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]) diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index 1bea2f754..c6c77b2cf 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -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}" ) diff --git a/app/api/servarr.py b/app/api/servarr.py index 312752c54..3fb25ad45 100644 --- a/app/api/servarr.py +++ b/app/api/servarr.py @@ -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( diff --git a/app/application/messaging/agent.py b/app/application/messaging/agent.py index 05f543642..d595e2fb7 100644 --- a/app/application/messaging/agent.py +++ b/app/application/messaging/agent.py @@ -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) diff --git a/app/application/outbox.py b/app/application/outbox.py index 1b6d58faa..4f79d9c60 100644 --- a/app/application/outbox.py +++ b/app/application/outbox.py @@ -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( diff --git a/app/application/subscription/status.py b/app/application/subscription/status.py index c0e848d94..f5843f32c 100644 --- a/app/application/subscription/status.py +++ b/app/application/subscription/status.py @@ -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] diff --git a/app/application/subscription/write.py b/app/application/subscription/write.py index 233a0795e..029f693ad 100644 --- a/app/application/subscription/write.py +++ b/app/application/subscription/write.py @@ -44,7 +44,7 @@ from app.schemas.types import MUSIC_ENTITY_ALBUM, MediaSource, MediaType # 身份不完整时的固定返回。身份不全的订阅写进去就是一条永远匹配不上资源的僵尸订阅, # 而后续按身份去重也会失效,所以必须在查询与建模之前短路 -INCOMPLETE_IDENTITY = (0, "媒体身份不完整") +INCOMPLETE_IDENTITY = (0, "未识别到媒体信息,请检查媒体来源和媒体 ID 后重试") class SubscriptionOutboxStager(Protocol): diff --git a/app/chain/base.py b/app/chain/base.py index c7a6b5cac..84c26b49e 100644 --- a/app/chain/base.py +++ b/app/chain/base.py @@ -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, { diff --git a/app/chain/message.py b/app/chain/message.py index b23ac9082..99ebec201 100644 --- a/app/chain/message.py +++ b/app/chain/message.py @@ -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 diff --git a/app/chain/subscribe/create.py b/app/chain/subscribe/create.py index 9d362febc..875f2b78c 100644 --- a/app/chain/subscribe/create.py +++ b/app/chain/subscribe/create.py @@ -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, diff --git a/app/chain/transfer/history.py b/app/chain/transfer/history.py index f5d6baa71..040eba589 100644 --- a/app/chain/transfer/history.py +++ b/app/chain/transfer/history.py @@ -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 diff --git a/app/chain/transfer/plan.py b/app/chain/transfer/plan.py index a92bdfcb8..24e06fbf9 100644 --- a/app/chain/transfer/plan.py +++ b/app/chain/transfer/plan.py @@ -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, diff --git a/app/chain/transfer/retry.py b/app/chain/transfer/retry.py index df7ac624e..c51473cb6 100644 --- a/app/chain/transfer/retry.py +++ b/app/chain/transfer/retry.py @@ -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}") diff --git a/app/chain/transfer/settlement.py b/app/chain/transfer/settlement.py index e8cfe1d55..c386f065c 100644 --- a/app/chain/transfer/settlement.py +++ b/app/chain/transfer/settlement.py @@ -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() diff --git a/app/chain/transfer/workflow.py b/app/chain/transfer/workflow.py index ae84dfe35..47c47a081 100644 --- a/app/chain/transfer/workflow.py +++ b/app/chain/transfer/workflow.py @@ -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}") diff --git a/app/db/adapters/transfer/execution.py b/app/db/adapters/transfer/execution.py index 8e5d08f9a..dedb06070 100644 --- a/app/db/adapters/transfer/execution.py +++ b/app/db/adapters/transfer/execution.py @@ -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) diff --git a/app/factory.py b/app/factory.py index 661d9d3b1..7c3ae8b8b 100644 --- a/app/factory.py +++ b/app/factory.py @@ -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"), ) diff --git a/app/locales/en-US.json b/app/locales/en-US.json index c9097319a..b8c435c13 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.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}" diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index e0d5792dc..db8be27a8 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -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}" diff --git a/app/runtime/errors.py b/app/runtime/errors.py new file mode 100644 index 000000000..a199164da --- /dev/null +++ b/app/runtime/errors.py @@ -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 diff --git a/app/schemas/history.py b/app/schemas/history.py index d667b083a..bb16b8824 100644 --- a/app/schemas/history.py +++ b/app/schemas/history.py @@ -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): """批量重新整理历史请求。""" diff --git a/app/schemas/response.py b/app/schemas/response.py index 822eb7491..662f7cfbe 100644 --- a/app/schemas/response.py +++ b/app/schemas/response.py @@ -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( diff --git a/tests/test_agent_background_output.py b/tests/test_agent_background_output.py index 5221f47a7..70683a95e 100644 --- a/tests/test_agent_background_output.py +++ b/tests/test_agent_background_output.py @@ -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): diff --git a/tests/test_agent_summarization_streaming.py b/tests/test_agent_summarization_streaming.py index 93190147d..0b2ac85b5 100644 --- a/tests/test_agent_summarization_streaming.py +++ b/tests/test_agent_summarization_streaming.py @@ -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() diff --git a/tests/test_history_ai_retry_gate.py b/tests/test_history_ai_retry_gate.py index 1a4862a97..f98d5b16b 100644 --- a/tests/test_history_ai_retry_gate.py +++ b/tests/test_history_ai_retry_gate.py @@ -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="已提交重新整理,后台将自动处理", ) }, ) diff --git a/tests/test_history_mutation_command.py b/tests/test_history_mutation_command.py index 09921ed2e..73383e8ab 100644 --- a/tests/test_history_mutation_command.py +++ b/tests/test_history_mutation_command.py @@ -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="已放弃这条失败的整理任务", ) ) diff --git a/tests/test_public_error_messages.py b/tests/test_public_error_messages.py new file mode 100644 index 000000000..1a7b8ce8e --- /dev/null +++ b/tests/test_public_error_messages.py @@ -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"] == "提交后的相关处理未完成,系统将自动重试" diff --git a/tests/test_subscription_execution_status.py b/tests/test_subscription_execution_status.py index 66ad5ef1c..625933f35 100644 --- a/tests/test_subscription_execution_status.py +++ b/tests/test_subscription_execution_status.py @@ -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(): diff --git a/tests/test_transfer_durable_retry_owner.py b/tests/test_transfer_durable_retry_owner.py index 56efa690b..d00e80419 100644 --- a/tests/test_transfer_durable_retry_owner.py +++ b/tests/test_transfer_durable_retry_owner.py @@ -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 == "这条整理任务需要先完成人工确认,再重试" diff --git a/tests/test_transfer_legacy_terminal_compat.py b/tests/test_transfer_legacy_terminal_compat.py index 1fad69788..b05222dab 100644 --- a/tests/test_transfer_legacy_terminal_compat.py +++ b/tests/test_transfer_legacy_terminal_compat.py @@ -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( diff --git a/tests/test_transfer_queue_service.py b/tests/test_transfer_queue_service.py index 247d746ee..c4dc77312 100644 --- a/tests/test_transfer_queue_service.py +++ b/tests/test_transfer_queue_service.py @@ -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 diff --git a/tests/test_webpush_subscription.py b/tests/test_webpush_subscription.py index f10d11281..5e8dda00b 100644 --- a/tests/test_webpush_subscription.py +++ b/tests/test_webpush_subscription.py @@ -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 个设备发送失败"