diff --git a/app/src/doctor_workstation/ui/dialogs/ai_consult.py b/app/src/doctor_workstation/ui/dialogs/ai_consult.py index ad74eac48..a1f0b213e 100644 --- a/app/src/doctor_workstation/ui/dialogs/ai_consult.py +++ b/app/src/doctor_workstation/ui/dialogs/ai_consult.py @@ -3460,20 +3460,61 @@ def _merge_stream_meta(meta: dict[str, Any], payload: Mapping[str, Any]) -> None """Keep observed transfer results when completion only repeats preparation. Progress describes the latest actual request attempt, including a retry - without files. Completion may instead carry the original prepared manifest; - it is reply metadata and must not undo those observed transfer results. + without files. Terminal metadata may repeat preparation, but an explicit + failure receipt must still correct an earlier reference-sent event. """ live_fields = ("items", "attachment_count", "attachment_transfer", "required_sections") - terminal = str(payload.get("event") or "").lower() == "done" + event = str(payload.get("event") or "").lower() + terminal = event in {"done", "error"} observed = meta.get("_transfer_progress_observed") is True update = dict(payload) sections = _required_transfer_sections(payload) if sections is not None: update["required_sections"] = sections + required_failure = str(payload.get("code") or payload.get("error_code") or "").startswith("REQUIRED_FILES_") + if required_failure: + update.setdefault("phase", "attachments") + update.setdefault("status", "failed") + failed_submission = required_failure or ( + payload.get("phase") == "attachments" and payload.get("status") == "failed" + ) + if failed_submission: + # Older servers report only the failed columns/code. That is a failed + # attachment submission, not a later generation failure. Correct known + # items without inventing sources or changing explicitly omitted files. + for key in ("items", "required_sections"): + entries = update.get(key) + if isinstance(entries, list) and ( + entries or payload.get("transfer_manifest_final") is True + ): + continue + previous = meta.get(key) if key == "items" else _required_transfer_sections(meta) + if isinstance(previous, list): + update[key] = [ + {**item, "status": "failed"} + if isinstance(item, Mapping) and item.get("status") in { + "prepared", "uploading", "uploaded", "submitting", "submitted", + } else item + for item in previous + ] + error_receipt_fields: set[str] = set() + if event == "error": + for key in ("items", "required_sections"): + entries = update.get(key) + if isinstance(entries, list) and any( + isinstance(item, Mapping) and item.get("status") in {"submitted", "failed", "omitted"} + and (key == "items" or str(item.get("key")) in dict(_REQUIRED_TRANSFER_SECTIONS)) + for item in entries + ): + error_receipt_fields.add(key) + if "items" in error_receipt_fields: + error_receipt_fields.update(("attachment_count", "attachment_transfer")) if terminal and observed and payload.get("transfer_manifest_final") is not True: for key in live_fields: if key in meta: + if key in error_receipt_fields: + continue if key == "required_sections" and not any( isinstance(item, Mapping) and str(item.get("key")) in dict(_REQUIRED_TRANSFER_SECTIONS) @@ -3545,7 +3586,12 @@ def _transfer_description(meta: Mapping[str, Any], state: str) -> str: visible_items: list[Mapping[str, Any]] = [] if isinstance(items, list): kinds = {"image": "图片", "document": "文档", "audio": "音频", "custom": "附件"} - statuses = {"submitting": "提交中", "submitted": "引用已提交", "omitted": "未提交", "failed": "提交失败"} + statuses = { + "prepared": "等待上传", "uploading": "上传中", "uploaded": "已上传,待提交", + "submitting": "提交中", "submitted": "引用已提交", "omitted": "未提交", "failed": "提交失败", + } + if meta.get("attachment_transfer") == "uploaded_files": + statuses["submitted"] = "已上传并提交" visible_items = [item for item in items if isinstance(item, Mapping) and not _is_video_transfer_item(item)] for index, item in enumerate(visible_items, 1): @@ -6590,6 +6636,11 @@ class AiConsultDialog(QDialog): "upstream": "资料已提交,正在等待 AI 正文…", "cancelled": "本次请求已取消;上游是否停止尚未确认。", }.get(phase, "正在处理本请求资料…") + if phase == "attachments" and payload.get("status") == "uploading": + index, total = payload.get("upload_index"), payload.get("upload_total") + state = f"正在上传附件({index}/{total})…" if ( + type(index) is int and type(total) is int and 0 < index <= total + ) else "正在上传附件…" if phase == "attachments" and payload.get("status") == "submitted": items = payload.get("items") visible = [item for item in items if isinstance(item, Mapping) @@ -6702,12 +6753,12 @@ class AiConsultDialog(QDialog): self._flush_stream_chunks() message = friendly_error(error) error_data = getattr(error, "data", None) + if isinstance(error_data, Mapping): + _merge_stream_meta(self._stream_meta, {**error_data, "event": "error"}) cancelled = self._stream_meta.get("phase") == "cancelled" or ( isinstance(error_data, Mapping) and error_data.get("code") == "AI_ASSISTANT_CANCELLED" ) if cancelled: - if isinstance(error_data, Mapping): - self._stream_meta.update(error_data) self._stream_meta["phase"] = "cancelled" message = _cancel_description(self._stream_meta) if self._stream_worker is not None: diff --git a/app/tests/test_ai_required_sources_status_ui.py b/app/tests/test_ai_required_sources_status_ui.py index 4c939578d..e5ef41d9c 100644 --- a/app/tests/test_ai_required_sources_status_ui.py +++ b/app/tests/test_ai_required_sources_status_ui.py @@ -11,6 +11,7 @@ import pytest from PySide6.QtCore import QEvent, QTimer from PySide6.QtWidgets import QApplication +from doctor_workstation.core.errors import ApiBusinessError from doctor_workstation.ui.dialogs import ai_consult as module REQUIRED = ( @@ -207,3 +208,140 @@ def test_attachment_progress_does_not_block_first_actual_column_receipt(dialog, assert "基本信息:无(已提交)" in text and "日常整个页面:已提交" in text assert "图片附件1(图片):未提交" in text assert "状态未回报" not in text + + +def test_actual_file_upload_progress_is_distinct_from_model_submission(dialog): + items = [{"kind": "image", "label": "图片附件1", "status": "uploaded"}, + {"kind": "document", "label": "文档附件1", "status": "uploading"}] + emit(dialog, {"event": "progress", "phase": "attachments", "status": "uploading", + "upload_index": 2, "upload_total": 2, "items": items, + "attachment_transfer": "uploaded_files", "required_sections": sections("prepared")}) + text = dialog._stream_bubble.transfer_status.text() + assert "正在上传附件(2/2)" in text + assert "图片附件1(图片):已上传,待提交" in text + assert "文档附件1(文档):上传中" in text + assert "生命体征:已整理,待提交" in text + emit(dialog, {"event": "done", "answer": "合成正文", "transfer_manifest_final": True, + "items": [{**item, "status": "submitted"} for item in items], + "attachment_transfer": "uploaded_files", "required_sections": sections()}) + assert "图片附件1(图片):已上传并提交" in dialog._stream_bubble.transfer_status.text() + + +def submitted_images(): + return [ + {"kind": "image", "label": f"图片附件{index}", "status": "submitted"} + for index in (1, 2) + ] + + +def submit_images(dialog): + emit(dialog, {"event": "progress", "phase": "attachments", "status": "submitted", + "items": submitted_images(), "attachment_count": 2, + "required_sections": sections()}) + + +def fail(dialog, data): + dialog._stream_failed(dialog._generation, dialog._stream_generation, ApiBusinessError( + "合成必需资料提交失败", data=data, + )) + + +def test_failed_progress_without_items_does_not_keep_successful_file_receipts(dialog): + submit_images(dialog) + emit(dialog, {"event": "progress", "phase": "attachments", "status": "failed", + "required_sections": sections("failed")}) + text = dialog._stream_bubble.transfer_status.text() + assert "基本信息:无(提交失败)" in text + assert "图片附件1(图片):提交失败" in text + assert "图片附件2(图片):提交失败" in text + assert "引用已提交" not in text + fail(dialog, {"code": "REQUIRED_FILES_REJECTED"}) + dialog._stream_finished(dialog._generation, dialog._stream_generation, dialog._stream_worker) + assert "引用已提交" not in dialog._stream_bubble.transfer_status.text() + assert not dialog._asking and not dialog._stream_completed + assert dialog.send_button.isEnabled() and dialog._stream_ended_at is not None + assert dialog._stream_meta["status"] == "failed" and dialog._stream_worker is None + + +def test_failed_terminal_manifest_corrects_all_observed_receipts_and_keeps_tail(dialog): + submit_images(dialog) + emit(dialog, {"event": "delta", "text": "合成已有正文"}) + emit(dialog, {"event": "delta", "text": "未刷新尾段"}) + failed_items = [{**item, "status": "failed"} for item in submitted_images()] + fail(dialog, {"code": "REQUIRED_FILES_REJECTED", "transfer_manifest_final": True, + "items": failed_items, "required_sections": sections("failed"), + "attachment_count": 2, "source_summary": {"diagnosis_count": 3}}) + text = dialog._stream_bubble.transfer_status.text() + assert "图片附件1(图片):提交失败" in text and "引用已提交" not in text + assert "基本信息:无(提交失败)" in text and "诊单 3 条" in text + assert dialog._stream_meta["source_summary"]["required_sections"] == sections("failed") + assert dialog._stream_bubble._raw_payload.startswith("合成已有正文未刷新尾段") + assert "合成必需资料提交失败" in dialog._stream_bubble._raw_payload + assert not dialog._stream_completed and not dialog._stream_timeout_timer.isActive() + emit(dialog, {"event": "done", "answer": "不得覆盖失败", "required_sections": sections()}) + assert "不得覆盖失败" not in dialog._stream_bubble._raw_payload + assert "引用已提交" not in dialog._stream_bubble.transfer_status.text() + + +@pytest.mark.parametrize("code", ["REQUIRED_FILES_REJECTED", "REQUIRED_FILES_UNSUPPORTED", + "REQUIRED_FILES_INVALID"]) +@pytest.mark.parametrize("manifest", [{}, {"items": [], "required_sections": []}]) +def test_legacy_required_file_error_without_manifest_fails_known_receipts_only(dialog, code, manifest): + submit_images(dialog) + dialog._stream_meta["items"].append( + {"kind": "document", "label": "文档附件1", "status": "omitted"}, + ) + fail(dialog, {"code": code, **manifest}) + text = dialog._stream_bubble.transfer_status.text() + assert "图片附件1(图片):提交失败" in text and "引用已提交" not in text + assert "文档附件1(文档):未提交" in text + assert "基本信息:无(提交失败)" in text + + +def test_error_manifest_without_final_marker_can_correct_failed_items(dialog): + submit_images(dialog) + fail(dialog, {"items": [{**item, "status": "failed"} for item in submitted_images()], + "source_summary": {"required_sections": sections("failed")}}) + text = dialog._stream_bubble.transfer_status.text() + assert "图片附件1(图片):提交失败" in text and "引用已提交" not in text + assert "基本信息:无(提交失败)" in text + + +def test_failure_receipt_preserves_explicit_per_item_results(dialog): + submit_images(dialog) + emit(dialog, {"event": "progress", "phase": "attachments", "status": "failed", + "required_sections": sections("failed"), + "items": [{**submitted_images()[0], "status": "failed"}, + submitted_images()[1]]}) + text = dialog._stream_bubble.transfer_status.text() + assert "图片附件1(图片):提交失败" in text + assert "图片附件2(图片):引用已提交" in text + + +def test_authoritative_failure_with_empty_manifest_can_replace_previous_items(dialog): + submit_images(dialog) + fail(dialog, {"code": "REQUIRED_FILES_INVALID", "transfer_manifest_final": True, + "items": [], "attachment_count": 0, "required_sections": sections("failed")}) + text = dialog._stream_bubble.transfer_status.text() + assert "图片附件" not in text and "本请求附件通道:0 项" in text + assert "基本信息:无(提交失败)" in text + + +@pytest.mark.parametrize("data", [None, {"code": "UPSTREAM_STREAM_INTERRUPTED"}, + {"source_summary": {"required_sections": sections("prepared")}, + "items": [], "attachment_count": 0}]) +def test_generation_failure_keeps_actual_submitted_receipts(dialog, data): + submit_images(dialog) + fail(dialog, data) + text = dialog._stream_bubble.transfer_status.text() + assert "图片附件1(图片):引用已提交" in text + assert "基本信息:无(已提交)" in text + assert "本请求附件通道:2 项" in text + assert not dialog._stream_completed and not dialog._asking + + +def test_failure_with_no_manifest_does_not_invent_sources_or_file_items(dialog): + fail(dialog, {"code": "REQUIRED_FILES_REJECTED"}) + text = dialog._stream_bubble.transfer_status.text() + assert "资料栏目清单:服务器尚未回传。" in text + assert "图片附件" not in text and "无(提交失败)" not in text diff --git a/server/app/adminapi/controller/tcm/DiagnosisController.php b/server/app/adminapi/controller/tcm/DiagnosisController.php index 240a4939e..e232f4f9d 100755 --- a/server/app/adminapi/controller/tcm/DiagnosisController.php +++ b/server/app/adminapi/controller/tcm/DiagnosisController.php @@ -1058,7 +1058,8 @@ class DiagnosisController extends BaseAdminController + ($control !== null ? $control->publicState() : [])); } $emit('error', ['code' => $cancelled ? 'AI_ASSISTANT_CANCELLED' : DiagnosisAiLogic::getAssistantErrorCode(), - 'message' => $cancelled ? '本次请求已取消' : DiagnosisAiLogic::getError()]); + 'message' => $cancelled ? '本次请求已取消' : DiagnosisAiLogic::getError()] + + ($cancelled ? [] : DiagnosisAiLogic::getAssistantErrorMeta())); } /** @param array $prepared */ diff --git a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php index 50634b609..5ac1fae35 100644 --- a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php +++ b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php @@ -14,6 +14,7 @@ use app\common\service\NihaixiaClinicalSkill; use app\common\service\TcmAncientBooksReference; use app\adminapi\service\AssistantSseTiming; use app\adminapi\service\AiContextCompactionCache; +use app\adminapi\service\AiRequiredSource; use app\adminapi\service\AssistantRequestControl; use think\facade\Db; use think\facade\Log; @@ -26,6 +27,9 @@ class DiagnosisAiLogic extends BaseLogic /** @var string Safe machine-readable code for the current assistant request. */ private static $assistantErrorCode = 'AI_ASSISTANT_FAILED'; + /** @var array Safe final receipt, never upstream text/URLs. */ + private static $assistantErrorMeta = []; + private const PROMPT_VERSION = 'patient-context-case-explain-nihaixia-ancient-v4'; private const ASSISTANT_PROMPT_VERSION = 'patient-context-assistant-required-sources-v6'; @@ -379,6 +383,7 @@ class DiagnosisAiLogic extends BaseLogic $status = 'error'; try { self::$assistantErrorCode = 'AI_ASSISTANT_FAILED'; + self::$assistantErrorMeta = []; $task = strtolower(trim($task)); if (!isset(self::ASSISTANT_TASKS[$task])) { self::setError('不支持的 AI 助手任务'); @@ -517,6 +522,7 @@ class DiagnosisAiLogic extends BaseLogic ?AssistantSseTiming $timing = null, array $options = [] ): ?array { + self::$assistantErrorMeta = []; $diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0); $profile = (string) ($prepared['profile'] ?? ''); $adminId = (int) ($prepared['admin_id'] ?? 0); @@ -693,7 +699,9 @@ class DiagnosisAiLogic extends BaseLogic */ private static function formatAssistantResult(array $prepared, array $result): ?array { + self::$assistantErrorMeta = []; if (empty($result['ok'])) { + self::$assistantErrorMeta = self::safeAssistantErrorMeta($result); // 附带上游错误码,让医生反馈时管理员能直接定位是配置、体积还是上游拒绝。 $message = (string) ($result['error'] ?? 'AI 助手暂时不可用,请稍后重试'); $errorCode = trim((string) ($result['error_code'] ?? '')); @@ -753,6 +761,52 @@ class DiagnosisAiLogic extends BaseLogic return self::normaliseAssistantErrorCode(self::$assistantErrorCode); } + public static function getAssistantErrorMeta(): array + { + return self::$assistantErrorMeta; + } + + private static function safeAssistantErrorMeta(array $result): array + { + $meta = []; + $sections = $result['required_sections'] ?? $result['source_summary']['required_sections'] ?? null; + if (is_array($sections)) { + $meta['required_sections'] = []; + foreach ($sections as $section) { + $key = is_array($section) ? ($section['key'] ?? null) : null; + if (!is_string($key) || !isset(AiRequiredSource::LABELS[$key])) continue; + $status = $section['status'] ?? ''; + if (!in_array($status, ['prepared', 'submitting', 'submitted', 'failed', 'omitted'], true)) continue; + $meta['required_sections'][] = ['key' => $key, 'label' => AiRequiredSource::LABELS[$key], + 'count' => max(0, (int) ($section['count'] ?? 0)), + 'availability' => ($section['availability'] ?? '') === 'empty' ? 'empty' : 'present', 'status' => $status]; + } + $meta['source_summary'] = ['required_sections' => $meta['required_sections']]; + } + if (is_array($result['items'] ?? null)) { + $meta['items'] = []; + $labels = ['image' => '图片附件', 'document' => '文档附件', 'audio' => '音频附件', 'custom' => '附件']; + $counts = []; + foreach ($result['items'] as $item) { + if (!is_array($item)) continue; + $kind = $item['kind'] ?? ''; + $status = $item['status'] ?? ''; + if (!is_string($kind) || !isset($labels[$kind]) + || !in_array($status, ['prepared', 'uploading', 'uploaded', 'submitting', 'submitted', 'failed', 'omitted'], true)) continue; + $counts[$kind] = ($counts[$kind] ?? 0) + 1; + $meta['items'][] = ['kind' => $kind, 'label' => $labels[$kind] . $counts[$kind], 'status' => $status]; + } + } + if (is_int($result['attachment_count'] ?? null)) $meta['attachment_count'] = max(0, $result['attachment_count']); + if (in_array($result['attachment_transfer'] ?? '', ['remote_url_references', 'uploaded_files'], true)) { + $meta['attachment_transfer'] = $result['attachment_transfer']; + } + if (($result['transfer_manifest_final'] ?? false) === true) $meta['transfer_manifest_final'] = true; + if (is_int($result['upstream_http_status'] ?? null) && $result['upstream_http_status'] >= 100 + && $result['upstream_http_status'] <= 599) $meta['upstream_http_status'] = $result['upstream_http_status']; + return $meta; + } + private static function normaliseAssistantErrorCode(string $code): string { $code = strtoupper(trim($code)); diff --git a/server/app/common/service/DifyChatService.php b/server/app/common/service/DifyChatService.php index 68fa16673..d2378789f 100644 --- a/server/app/common/service/DifyChatService.php +++ b/server/app/common/service/DifyChatService.php @@ -33,6 +33,13 @@ class DifyChatService */ private const FILE_REJECTION_CODES = [400, 413, 415, 422]; + private const MAX_ERROR_BODY_BYTES = 16384; + + private const UPSTREAM_REASONS = [ + 'authentication', 'quota', 'unavailable', 'cancelled', 'inputs_invalid', + 'response_mode_unsupported', 'file_type', 'file_size', 'file_count', 'file_access', + ]; + /** * @param array $inputs * @return array{ok:bool,content?:string,message_id?:string,latency_ms?:int,error_code?:string,error?:string} @@ -107,7 +114,7 @@ class DifyChatService $requiredFiles = !empty($options['required_files']); if ($requiredFiles) { $checked = self::requiredFiles($files); - if (isset($checked['error_code'])) return self::requiredFilesFailure($options, $checked['error_code'], $checked['error']); + if (isset($checked['error_code'])) return self::requiredFilesFailure($options, $checked['error_code'], $checked['error'], $files); $files = $checked['files']; } $strictFiles = !empty($options['strict_files']) || $requiredFiles; @@ -119,6 +126,7 @@ class DifyChatService } $startedAt = microtime(true); $formatted = null; + $uploadCache = []; $attemptPlan = $strictFiles ? [['files' => $normalized['kept'], 'omitted' => []]] @@ -143,13 +151,14 @@ class DifyChatService ); $lastResponse = null; $lastSpec = []; + $lastTransferOptions = $options; foreach ($requestSpecs as $index => $requestSpec) { if ($shouldAbort !== null && $shouldAbort()) return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消'); // The legacy compatible path cannot transmit documents. Strict callers must // receive an explicit gap, never a successful text-only fallback. if ($strictFiles && !self::strictProtocolSupportsFiles($requestSpec['protocol'], $attempt['files'])) { - if ($requiredFiles) return self::requiredFilesFailure($options, 'REQUIRED_FILES_UNSUPPORTED', '必需资料提交失败:当前模型接口不支持原始文档等附件'); + if ($requiredFiles) return self::requiredFilesFailure($options, 'REQUIRED_FILES_UNSUPPORTED', '必需资料提交失败:当前模型接口不支持原始文档等附件', $attempt['files'], $requestSpec['protocol']); return self::error('FILE_TYPE_UNSUPPORTED', '当前模型接口不支持此类原始附件'); } $elapsedSeconds = (int) floor(microtime(true) - $startedAt); @@ -162,20 +171,28 @@ class DifyChatService ); } + $staged = self::prepareRequiredUpload($requestSpec, $attempt['files'], $user, $apiKey, + $remainingTimeout, $shouldAbort, $options, $config, $uploadCache); + if (empty($staged['ok'])) return $staged; + $requestSpec = $staged['request_spec']; + $lastTransferOptions = $staged['options']; + $remainingTimeout = $timeout - (int) floor(microtime(true) - $startedAt); + if ($remainingTimeout < self::MIN_TIMEOUT) return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', self::elapsedMilliseconds($startedAt)); $response = self::sendRequest( $requestSpec['url'], $requestSpec['payload'], $apiKey, $remainingTimeout, - $options + ['attachment_items' => self::attachmentItems($attempt['files'], $attempt['omitted'], $requestSpec['protocol'])] + $lastTransferOptions + ['attachment_items' => self::attachmentItems($attempt['files'], $attempt['omitted'], $requestSpec['protocol'])] ); if (!empty($response['client_aborted']) || ($shouldAbort !== null && $shouldAbort())) { return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消', self::elapsedMilliseconds($startedAt)); } $lastResponse = $response; $lastSpec = $requestSpec; - $fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']); - $inputRejected = $inputRejected || self::isInputRejection( + // A corrected inputs attempt or protocol response owns its own diagnosis. + $fileRejected = self::isFileRejection($response, $attempt['files'], $requiredFiles); + $inputRejected = self::isInputRejection( $response, $requestSpec, $attemptInputs @@ -203,15 +220,15 @@ class DifyChatService $formatted['transmitted_file_count'] = count($attempt['files']); $formatted['attachment_transport'] = (string) ($lastSpec['protocol'] ?? ''); } - if ($requiredFiles) $formatted += self::finalTransferManifest($options, $attempt['files'], (string) ($lastSpec['protocol'] ?? '')); + if ($requiredFiles) $formatted += self::finalTransferManifest($lastTransferOptions, $attempt['files'], (string) ($lastSpec['protocol'] ?? '')); return $formatted; } // Dify 只接受应用中已声明且满足长度约束的 inputs。病例正文已经完整 - // 放在 query 中,因此 invalid_param 时可安全地用空 inputs 重试一次。 + // 放在 query 中,因此明确的 inputs 校验错误可用空对象重试一次。 if ($inputIndex === 0 && $inputRejected) { continue; } - if ($requiredFiles && $fileRejected) return self::requiredFilesFailure($options, 'REQUIRED_FILES_REJECTED', '必需资料提交失败:模型拒绝本次附件,未降级为纯文字请求'); + if ($requiredFiles && $fileRejected) return self::requiredFilesFailure($lastTransferOptions, 'REQUIRED_FILES_REJECTED', '必需资料提交失败:模型拒绝本次附件,未降级为纯文字请求', $attempt['files'], (string) ($lastSpec['protocol'] ?? ''), (int) ($formatted['latency_ms'] ?? 0)); break; } // 附件整体被拒时退回纯文本重试,附件清单已在下一轮尝试中补齐。 @@ -288,7 +305,7 @@ class DifyChatService $requiredFiles = !empty($options['required_files']); if ($requiredFiles) { $checked = self::requiredFiles($files); - if (isset($checked['error_code'])) return self::requiredFilesFailure($options, $checked['error_code'], $checked['error']); + if (isset($checked['error_code'])) return self::requiredFilesFailure($options, $checked['error_code'], $checked['error'], $files); $files = $checked['files']; } $strictFiles = !empty($options['strict_files']) || $requiredFiles; @@ -298,6 +315,7 @@ class DifyChatService } $startedAt = microtime(true); $formatted = null; + $uploadCache = []; $attemptPlan = $strictFiles ? [['files' => $normalized['kept'], 'omitted' => []]] @@ -322,13 +340,14 @@ class DifyChatService ); $lastResponse = null; $lastSpec = []; + $lastTransferOptions = $options; foreach ($requestSpecs as $index => $requestSpec) { if ($shouldAbort !== null && $shouldAbort()) { return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消'); } if ($strictFiles && !self::strictProtocolSupportsFiles($requestSpec['protocol'], $attempt['files'])) { - if ($requiredFiles) return self::requiredFilesFailure($options, 'REQUIRED_FILES_UNSUPPORTED', '必需资料提交失败:当前模型接口不支持原始文档等附件'); + if ($requiredFiles) return self::requiredFilesFailure($options, 'REQUIRED_FILES_UNSUPPORTED', '必需资料提交失败:当前模型接口不支持原始文档等附件', $attempt['files'], $requestSpec['protocol']); return self::error('FILE_TYPE_UNSUPPORTED', '当前模型接口不支持此类原始附件'); } $elapsedSeconds = (int) floor(microtime(true) - $startedAt); @@ -341,6 +360,13 @@ class DifyChatService ); } + $staged = self::prepareRequiredUpload($requestSpec, $attempt['files'], $user, $apiKey, + $remainingTimeout, $shouldAbort, $options, $config, $uploadCache); + if (empty($staged['ok'])) return $staged; + $requestSpec = $staged['request_spec']; + $lastTransferOptions = $staged['options']; + $remainingTimeout = $timeout - (int) floor(microtime(true) - $startedAt); + if ($remainingTimeout < self::MIN_TIMEOUT) return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', self::elapsedMilliseconds($startedAt)); $response = self::sendStreamRequest( $requestSpec['protocol'], $requestSpec['url'], @@ -349,14 +375,14 @@ class DifyChatService $remainingTimeout, $onDelta, $shouldAbort, - $options + ['profile' => $profile, 'user' => $user, + $lastTransferOptions + ['profile' => $profile, 'user' => $user, 'source_summary' => $options['source_summary'] ?? [], 'attachment_items' => self::attachmentItems($attempt['files'], $attempt['omitted'], $requestSpec['protocol'])] ); $lastResponse = $response; $lastSpec = $requestSpec; - $fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']); - $inputRejected = $inputRejected || self::isInputRejection( + $fileRejected = self::isFileRejection($response, $attempt['files'], $requiredFiles); + $inputRejected = self::isInputRejection( $response, $requestSpec, $attemptInputs @@ -387,7 +413,7 @@ class DifyChatService $formatted['transmitted_file_count'] = count($attempt['files']); $formatted['attachment_transport'] = (string) ($lastSpec['protocol'] ?? ''); } - if ($requiredFiles) $formatted += self::finalTransferManifest($options, $attempt['files'], (string) ($lastSpec['protocol'] ?? '')); + if ($requiredFiles) $formatted += self::finalTransferManifest($lastTransferOptions, $attempt['files'], (string) ($lastSpec['protocol'] ?? '')); return $formatted; } if ( @@ -397,7 +423,7 @@ class DifyChatService ) { continue; } - if ($requiredFiles && $fileRejected) return self::requiredFilesFailure($options, 'REQUIRED_FILES_REJECTED', '必需资料提交失败:模型拒绝本次附件,未降级为纯文字请求'); + if ($requiredFiles && $fileRejected) return self::requiredFilesFailure($lastTransferOptions, 'REQUIRED_FILES_REJECTED', '必需资料提交失败:模型拒绝本次附件,未降级为纯文字请求', $attempt['files'], (string) ($lastSpec['protocol'] ?? ''), (int) ($formatted['latency_ms'] ?? 0)); break; } // 已经推给医生的文本不能重复输出,因此只在一个字都没发出去时才降级重试。 @@ -425,6 +451,72 @@ class DifyChatService && ($body['result'] ?? '') === 'success'; } + /** Stage each mandatory Dify file once; a failed upload cannot start generation. */ + private static function prepareRequiredUpload(array $spec, array $files, string $user, string $apiKey, + int $timeout, ?callable $shouldAbort, array $options, array $config, array &$cache): array + { + if (empty($options['required_files']) || $files === [] || ($spec['protocol'] ?? '') !== 'dify' + || ($config['required_attachment_transport'] ?? '') !== 'upload') { + return ['ok' => true, 'request_spec' => $spec, 'options' => $options]; + } + $options['attachment_transfer'] = 'uploaded_files'; + $cacheKey = hash('sha256', $spec['url'] . "\0" . $apiKey . "\0" . $user . "\0" . json_encode($files)); + if (!isset($cache[$cacheKey])) { + $uploadOptions = is_array($config['required_attachment_upload'] ?? null) ? $config['required_attachment_upload'] : []; + if (!array_key_exists('storage_origins', $uploadOptions)) { + // These URLs are already authorized clinical sources, not request + // parameters. This grants remote-origin permission only; the + // uploader still rejects private DNS/IPs, redirects and rebinding. + $origins = []; + foreach ($files as $file) { + $parts = parse_url($file['url']); + if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) continue; + $origin = strtolower($parts['scheme']) . '://' . strtolower($parts['host']) + . (isset($parts['port']) ? ':' . $parts['port'] : ''); + $origins[$origin] = true; + } + $uploadOptions['storage_origins'] = array_keys($origins); + } + $items = self::attachmentItems($files, [], 'dify'); + foreach ($items as &$item) $item['status'] = 'prepared'; + unset($item); + $uploadOptions['on_upload'] = static function (array $event) use ($options, &$items): void { + $index = (int) ($event['index'] ?? 0); + if ($index < 1 || !isset($items[$index - 1])) return; + $status = $event['status'] ?? ''; + if (!in_array($status, ['uploading', 'uploaded', 'failed'], true)) return; + $items[$index - 1]['status'] = $status; + $sections = self::requiredSections($options, 'prepared'); + $summary = is_array($options['source_summary'] ?? null) ? $options['source_summary'] : []; + $summary['required_sections'] = $sections; + if (is_callable($options['on_progress'] ?? null)) ($options['on_progress'])([ + 'phase' => 'attachments', 'status' => 'uploading', 'upload_index' => $index, + 'upload_total' => count($items), 'items' => $items, 'attachment_count' => count($items), + 'required_sections' => $sections, 'source_summary' => $summary, + 'source_form' => $options['source_form'] ?? 'full_context', + 'evidence_cache_hit' => !empty($options['evidence_cache_hit']), + 'attachment_transfer' => 'uploaded_files', + ]); + }; + $started = microtime(true); + $uploaded = DifyRequiredAttachmentUpload::prepare($spec['url'], $apiKey, $user, $files, + $timeout, $shouldAbort, $uploadOptions); + if (empty($uploaded['ok'])) { + if (($uploaded['error_code'] ?? '') === 'AI_ASSISTANT_CANCELLED') { + return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消', self::elapsedMilliseconds($started)); + } + return self::requiredFilesFailure($options, (string) ($uploaded['error_code'] ?? 'REQUIRED_FILES_UPLOAD_FAILED'), + (string) ($uploaded['error'] ?? '必需附件上传失败,本次未提交分析'), $files, 'dify', self::elapsedMilliseconds($started)); + } + if (!is_array($uploaded['files'] ?? null) || count($uploaded['files']) !== count($files)) { + return self::requiredFilesFailure($options, 'REQUIRED_FILES_UPLOAD_FAILED', '必需附件上传不完整,本次未提交分析', $files); + } + $cache[$cacheKey] = $uploaded['files']; + } + $spec['payload']['files'] = $cache[$cacheKey]; + return ['ok' => true, 'request_spec' => $spec, 'options' => $options]; + } + /** Mandatory clinical files have no lossy normalization or text-only fallback. */ private static function requiredFiles(array $files): array { @@ -468,16 +560,33 @@ class DifyChatService return $result; } - private static function requiredFilesFailure(array $options, string $code, string $message): array + private static function requiredFilesFailure( + array $options, + string $code, + string $message, + array $files = [], + string $protocol = 'dify', + int $latencyMs = 0 + ): array { $sections = self::requiredSections($options, 'failed'); $summary = is_array($options['source_summary'] ?? null) ? $options['source_summary'] : []; $summary['required_sections'] = $sections; + // Even a preflight-invalid file gets a safe label; never expose its URL/name. + $labels = array_map(static function ($file): array { + $kind = is_array($file) && is_string($file['type'] ?? null) ? strtolower($file['type']) : 'custom'; + return ['type' => in_array($kind, ['image', 'document', 'audio', 'video', 'custom'], true) ? $kind : 'custom']; + }, $files); + $items = self::attachmentItems($labels, [], $protocol); + foreach ($items as &$item) $item['status'] = 'failed'; + unset($item); + $manifest = ['required_sections' => $sections, 'source_summary' => $summary, + 'items' => $items, 'attachment_count' => count($items), + 'attachment_transfer' => $options['attachment_transfer'] ?? 'remote_url_references', 'transfer_manifest_final' => true]; if (is_callable($options['on_progress'] ?? null)) ($options['on_progress'])([ 'phase' => 'attachments', 'status' => 'failed', 'message' => $message, - 'required_sections' => $sections, 'source_summary' => $summary, - ]); - return self::error($code, $message); + ] + $manifest); + return self::error($code, $message, $latencyMs) + $manifest; } private static function finalTransferManifest(array $options, array $files, string $protocol): array @@ -490,7 +599,7 @@ class DifyChatService unset($item); return ['required_sections' => $sections, 'source_summary' => $summary, 'items' => $items, 'attachment_count' => count($files), - 'attachment_transfer' => 'remote_url_references', 'transfer_manifest_final' => true]; + 'attachment_transfer' => $options['attachment_transfer'] ?? 'remote_url_references', 'transfer_manifest_final' => true]; } /** Safe labels only; submitted means request references sent, never remote file reading. */ @@ -747,24 +856,9 @@ class DifyChatService } $httpCode = (int) ($response['http_code'] ?? 0); - if (in_array($httpCode, [413, 422], true)) { - return true; - } - - $upstreamCode = strtolower(self::responseUpstreamCode($response)); - if ($httpCode === 400) { - // 标准 Dify 会给出 invalid_param;部分兼容网关只保留 400,因此空码 - // 也允许一次无 inputs 重试。额度、模型或应用状态错误不能重复提交。 - return $upstreamCode === '' - || in_array($upstreamCode, ['invalid_param', 'payload_too_large', 'request_too_large'], true); - } - - return !empty($response['upstream_error']) - && in_array( - $upstreamCode, - ['invalid_param', 'payload_too_large', 'request_too_large'], - true - ); + return (in_array($httpCode, [400, 413, 422], true) + || ($httpCode >= 200 && $httpCode < 300 && !empty($response['upstream_error']))) + && self::responseUpstreamReason($response) === 'inputs_invalid'; } /** @param array $response */ @@ -774,8 +868,73 @@ class DifyChatService if ($upstreamCode !== '' || !isset($response['body'])) { return $upstreamCode; } - $decoded = json_decode((string) $response['body'], true); - return is_array($decoded) ? self::cleanUpstreamCode($decoded['code'] ?? '') : ''; + return self::errorBodyMetadata((string) $response['body'])['upstream_code']; + } + + /** Only short codes and fixed classifications may leave the parser. */ + private static function errorBodyMetadata(string $body): array + { + if (strlen($body) > self::MAX_ERROR_BODY_BYTES) return ['upstream_code' => '', 'upstream_reason' => '']; + $decoded = json_decode($body, true); + return is_array($decoded) ? self::upstreamErrorMetadata($decoded) : ['upstream_code' => '', 'upstream_reason' => '']; + } + + private static function responseUpstreamReason(array $response): string + { + $reason = $response['upstream_reason'] ?? ''; + if (is_string($reason) && in_array($reason, self::UPSTREAM_REASONS, true)) return $reason; + if (isset($response['body'])) { + $metadata = self::errorBodyMetadata((string) $response['body']); + if ($metadata['upstream_reason'] !== '') return $metadata['upstream_reason']; + } + return self::upstreamErrorMetadata(['code' => self::responseUpstreamCode($response)])['upstream_reason']; + } + + /** + * Error messages are inspected only in memory and immediately reduced to an + * enum. They can contain signed attachment URLs or clinical text: never return + * or log them. Generic invalid_param/413/422 alone prove no specific cause. + */ + private static function upstreamErrorMetadata(array $decoded): array + { + $nested = is_array($decoded['error'] ?? null) ? $decoded['error'] : []; + $code = self::cleanUpstreamCode($decoded['code'] ?? $nested['code'] ?? $nested['type'] ?? ''); + $normalizedCode = strtolower($code); + $message = $decoded['message'] ?? $nested['message'] ?? ''; + $message = is_string($message) ? substr($message, 0, self::MAX_ERROR_BODY_BYTES) : ''; + $reason = ''; + $codeGroups = [ + 'authentication' => ['unauthorized', 'forbidden', 'invalid_api_key', 'invalid_credentials', 'authentication_error', 'authorization_error', 'provider_not_initialize', 'provider_token_not_init'], + 'quota' => ['provider_quota_exceeded', 'quota_exceeded', 'insufficient_quota', 'rate_limit_exceeded', 'rate_limit_error', 'too_many_requests'], + 'unavailable' => ['provider_unavailable', 'service_unavailable', 'internal_server_error', 'server_error', 'model_unavailable'], + 'cancelled' => ['cancelled', 'canceled', 'task_cancelled', 'task_canceled', 'generation_cancelled'], + 'response_mode_unsupported' => ['unsupported_response_mode', 'response_mode_not_supported'], + 'file_type' => ['unsupported_file_type', 'file_type_not_supported', 'invalid_file_type', 'unsupported_image_type', 'invalid_image_format', 'invalid_image'], + 'file_size' => ['file_too_large', 'image_too_large', 'file_size_exceeded', 'file_size_limit_exceeded', 'max_file_size_exceeded'], + 'file_count' => ['too_many_files', 'file_count_exceeded', 'file_count_limit_exceeded', 'files_limit_exceeded', 'max_files_exceeded'], + 'file_access' => ['file_not_found', 'file_not_accessible', 'file_access_error', 'file_download_error', 'file_download_failed', 'remote_file_download_failed', 'file_fetch_error', 'invalid_file_url', 'invalid_image_url', 'image_download_failed', 'image_fetch_failed'], + 'inputs_invalid' => ['invalid_inputs', 'invalid_input_variable', 'input_validation_error', 'input_variable_error'], + ]; + foreach ($codeGroups as $candidate => $codes) { + if (in_array($normalizedCode, $codes, true)) { $reason = $candidate; break; } + } + // Exact provider codes take priority over words from an error message. + if ($reason === '' && $message !== '') { + $patterns = [ + 'authentication' => '/\b(?:api[ _-]?key|credentials?|authentication|authorization)\b.{0,80}\b(?:invalid|missing|failed|unauthorized)\b|\b(?:invalid|missing)\b.{0,40}\b(?:api[ _-]?key|credentials?)\b/i', + 'quota' => '/\b(?:quota|rate[ _-]?limit)\b.{0,60}\b(?:exceeded|exhausted|reached|insufficient)\b|\binsufficient\s+(?:quota|credits?|balance)\b/i', + 'response_mode_unsupported' => '/\bresponse_mode\b.{0,100}\b(?:invalid|unsupported|not supported|blocking|streaming)\b|\bstreaming\b.{0,50}\b(?:not supported|unsupported|disabled)\b/i', + 'file_count' => '/\b(?:number|count)\s+of\s+(?:uploaded\s+)?(?:files|images|attachments)\b.{0,100}\b(?:limit|exceed|maximum)\b|\btoo many\s+(?:files|images|attachments)\b|\b(?:file|image|attachment)[ _-]?(?:count|number|limit)\b.{0,80}\b(?:exceed|maximum|limit)\b/i', + 'file_size' => '/\b(?:file|image|attachment)(?:\s+size)?\b.{0,80}\b(?:too large|size limit|maximum size|exceeds? (?:the )?(?:size|maximum))\b/i', + 'file_type' => '/\b(?:file|image|attachment)\s+(?:type|format|extension)\b.{0,80}\b(?:unsupported|not supported|not allowed|invalid)\b|\b(?:unsupported|invalid|not supported)\s+(?:file|image|attachment)(?:\s+(?:type|format|extension))?\b/i', + 'file_access' => '/\b(?:failed|unable|cannot|could not)\s+to\s+(?:download|fetch|read|access)\b.{0,160}\b(?:file|image|attachment)\b|\b(?:file|image|attachment)(?:\s+url)?\b.{0,100}\b(?:download failed|fetch failed|not accessible|unreachable|not found|access denied)\b/i', + 'inputs_invalid' => '/\b(?:inputs|input (?:variable|field|form)s?)\b.{0,120}\b(?:unknown|undeclared|unexpected|invalid|not (?:defined|declared|allowed)|too long|exceeds?|less than|maximum length)\b|\b(?:unknown|undeclared|unexpected|invalid)\s+(?:inputs|input (?:variable|field)s?)\b/i', + ]; + foreach ($patterns as $candidate => $pattern) { + if (preg_match($pattern, $message) === 1) { $reason = $candidate; break; } + } + } + return ['upstream_code' => $code, 'upstream_reason' => $reason]; } /** @@ -795,11 +954,23 @@ class DifyChatService * @param array $response * @param array> $files */ - private static function isFileRejection(array $response, array $files): bool + private static function isFileRejection(array $response, array $files, bool $requireExplicit = false): bool { if ($files === [] || (int) ($response['errno'] ?? 0) !== 0) { return false; } + $httpCode = (int) ($response['http_code'] ?? 0); + if (!empty($response['client_aborted']) || !empty($response['callback_error']) + || in_array($httpCode, [401, 403, 429], true) || $httpCode >= 500) return false; + $reason = self::responseUpstreamReason($response); + if (in_array($reason, ['authentication', 'quota', 'unavailable', 'cancelled', 'inputs_invalid', 'response_mode_unsupported'], true)) return false; + if ($requireExplicit) { + return (in_array($httpCode, self::FILE_REJECTION_CODES, true) + || ($httpCode >= 200 && $httpCode < 300 && !empty($response['upstream_error']))) + && in_array($reason, ['file_type', 'file_size', 'file_count', 'file_access'], true); + } + // Preserve the legacy optional-files fallback contract. Mandatory files + // instead require affirmative attachment evidence and are never removed. if (self::shouldRetryWithoutFiles((int) ($response['http_code'] ?? 0), $files)) { return true; } @@ -871,6 +1042,11 @@ class DifyChatService if ((int) ($response['errno'] ?? 0) !== 0) { return false; } + if (!empty($response['client_aborted']) || !empty($response['callback_error']) + || in_array(self::responseUpstreamReason($response), [ + 'authentication', 'quota', 'unavailable', 'cancelled', 'inputs_invalid', + 'file_type', 'file_size', 'file_count', 'file_access', + ], true)) return false; $httpCode = (int) ($response['http_code'] ?? 0); if (in_array($httpCode, [404, 405, 501], true)) { return true; @@ -995,12 +1171,14 @@ class DifyChatService if (!$clientAborted && ($errno !== 0 || $httpCode >= 400)) $notify('failed'); } + $responseBody = is_string($responseBody) ? $responseBody : ''; + $metadata = self::errorBodyMetadata($responseBody); return [ - 'body' => is_string($responseBody) ? $responseBody : '', + 'body' => $responseBody, 'errno' => $errno, 'http_code' => $httpCode, 'client_aborted' => $clientAborted, - ]; + ] + $metadata; } private static function transferProgressNotifier(array $options): callable @@ -1022,7 +1200,7 @@ class DifyChatService 'required_sections' => $sections, 'source_form' => $options['source_form'] ?? 'full_context', 'evidence_cache_hit' => !empty($options['evidence_cache_hit']), - 'attachment_transfer' => 'remote_url_references', + 'attachment_transfer' => $options['attachment_transfer'] ?? 'remote_url_references', 'attachment_count' => count(array_filter($items, static fn (array $item): bool => $item['status'] !== 'omitted')), 'items' => $items]); if ($status === 'submitted') ($options['on_progress'])([ @@ -1141,9 +1319,13 @@ class DifyChatService return $accepted; }; $responseCode = 0; - $header = static function ($handle, string $line) use (&$responseCode, $markSubmitted): int { + $errorBody = ''; + $errorBodyOverflow = false; + $header = static function ($handle, string $line) use (&$responseCode, &$errorBody, &$errorBodyOverflow, $markSubmitted): int { if (preg_match('/^HTTP\/\S+\s+(\d{3})(?:\s|$)/i', trim($line), $matches) === 1) { $responseCode = (int) $matches[1]; + $errorBody = ''; + $errorBodyOverflow = false; if ($responseCode >= 200 && $responseCode < 300) $markSubmitted(); } return strlen($line); @@ -1153,12 +1335,20 @@ class DifyChatService &$buffer, &$state, &$responseCode, + &$errorBody, + &$errorBodyOverflow, $forward, $abort ): int { if ($responseCode < 200 || $responseCode >= 300) { - // Never decode or forward an error response body. Besides preventing - // leakage, this keeps 404/405 protocol fallback side-effect free. + // Keep a bounded JSON error only until its safe code/reason has + // been extracted. Never feed HTTP error bodies to the SSE parser. + if (!$errorBodyOverflow && strlen($errorBody) + strlen($chunk) <= self::MAX_ERROR_BODY_BYTES) { + $errorBody .= $chunk; + } else { + $errorBody = ''; + $errorBodyOverflow = true; + } return strlen($chunk); } self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $forward); @@ -1231,6 +1421,13 @@ class DifyChatService if (is_callable($options['on_transfer_end'] ?? null)) ($options['on_transfer_end'])($state['task_id']); if (!$cancelled && ($errno !== 0 || $httpCode >= 400)) $notify('failed'); + if ($httpCode < 200 || $httpCode >= 300) { + $metadata = self::errorBodyMetadata($errorBody); + $state['upstream_code'] = $metadata['upstream_code']; + $state['upstream_reason'] = $metadata['upstream_reason']; + } + unset($errorBody); + return [ 'errno' => $errno, 'http_code' => $httpCode, @@ -1239,6 +1436,7 @@ class DifyChatService 'emitted' => $state['emitted'], 'upstream_error' => $state['upstream_error'], 'upstream_code' => $state['upstream_code'], + 'upstream_reason' => $state['upstream_reason'], 'client_aborted' => $state['client_aborted'], 'callback_error' => $state['callback_error'], 'finished' => $state['finished'], @@ -1289,6 +1487,7 @@ class DifyChatService 'emitted' => false, 'upstream_error' => false, 'upstream_code' => '', + 'upstream_reason' => '', 'client_aborted' => false, 'callback_error' => false, 'finished' => false, @@ -1414,8 +1613,9 @@ class DifyChatService } if ($event === 'error') { $state['upstream_error'] = true; - // 只留可枚举的错误码用于排障;message 可能含患者资源地址,不落日志。 - $state['upstream_code'] = self::cleanUpstreamCode($decoded['code'] ?? ''); + $metadata = self::upstreamErrorMetadata($decoded); + $state['upstream_code'] = $metadata['upstream_code']; + $state['upstream_reason'] = $metadata['upstream_reason']; return; } if (!in_array($event, ['message', 'agent_message'], true)) { @@ -1425,6 +1625,13 @@ class DifyChatService $state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']); } else { if ($state['finished'] || $state['upstream_error']) return; + if (is_array($decoded['error'] ?? null)) { + $state['upstream_error'] = true; + $metadata = self::upstreamErrorMetadata($decoded); + $state['upstream_code'] = $metadata['upstream_code']; + $state['upstream_reason'] = $metadata['upstream_reason']; + return; + } $delta = self::extractStreamDelta($decoded); $state['message_id'] = (string) ($decoded['id'] ?? $state['message_id']); } @@ -1509,6 +1716,7 @@ class DifyChatService 'emitted' => false, 'upstream_error' => false, 'upstream_code' => '', + 'upstream_reason' => '', 'client_aborted' => false, 'callback_error' => false, 'finished' => false, @@ -1544,14 +1752,8 @@ class DifyChatService } $httpCode = (int) ($response['http_code'] ?? 0); - if ($httpCode === 401 || $httpCode === 403) { - return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs); - } - if ($httpCode === 429 || $httpCode >= 500) { - return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs); - } if ($httpCode >= 400 || $httpCode < 200 || !empty($response['upstream_error'])) { - return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs); + return self::formatUpstreamRejection($response, $latencyMs); } if (empty($response['finished'])) { return self::error('INCOMPLETE_RESPONSE', '模型响应不完整,请重试', $latencyMs); @@ -1593,14 +1795,9 @@ class DifyChatService } $decoded = json_decode($response['body'], true); - if ($httpCode === 401 || $httpCode === 403) { - return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs); - } - if ($httpCode === 429 || $httpCode >= 500) { - return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs); - } - if ($httpCode >= 400 || $httpCode < 200) { - return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs); + if ($httpCode >= 400 || $httpCode < 200 || !empty($response['upstream_error']) + || (is_array($decoded) && is_array($decoded['error'] ?? null))) { + return self::formatUpstreamRejection($response, $latencyMs); } if (!is_array($decoded)) { return self::error('INVALID_RESPONSE', '模型返回格式异常,请重试', $latencyMs); @@ -1621,6 +1818,22 @@ class DifyChatService ]; } + private static function formatUpstreamRejection(array $response, int $latencyMs): array + { + $httpCode = (int) ($response['http_code'] ?? 0); + if (in_array($httpCode, [401, 403], true)) { + return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs); + } + if ($httpCode === 429 || $httpCode >= 500) { + return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs); + } + $reason = self::responseUpstreamReason($response); + if ($reason === 'authentication') return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs); + if (in_array($reason, ['quota', 'unavailable'], true)) return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs); + if ($reason === 'cancelled') return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消', $latencyMs); + return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs); + } + /** Missing provider usage remains unknown, never a fabricated zero-token charge. */ private static function normalizedUsage($usage): array { @@ -1684,16 +1897,24 @@ class DifyChatService 'curl_errno' => (int) ($response['errno'] ?? 0), // 上游自有错误码(如 invalid_param),用于区分附件超限、鉴权、模型故障。 'upstream_code' => self::responseUpstreamCode($response), + 'upstream_reason' => self::responseUpstreamReason($response), 'query_bytes' => strlen($query), 'file_count' => count($files), 'error_code' => (string) ($formatted['error_code'] ?? 'UNKNOWN'), 'latency_ms' => (int) ($formatted['latency_ms'] ?? 0), ]; - // ThinkPHP 文件日志不会自动输出未参与占位符替换的 context;显式序列化 - // 这组不含凭据、主机名、患者正文的诊断字段,确保线上日志真正可用。 - Log::warning( - 'prescription ai upstream request failed ' . json_encode($context, JSON_UNESCAPED_SLASHES) - ); + // SSE controllers exit before normal buffered-log flushing. Write this + // allowlisted record immediately, while retaining lightweight test stubs. + try { + $message = 'prescription ai upstream request failed ' . json_encode($context, JSON_UNESCAPED_SLASHES); + if (method_exists(Log::class, '__callStatic') || method_exists(Log::class, 'write')) { + Log::write($message, 'warning'); + } else { + Log::warning($message); + } + } catch (\Throwable $ignored) { + // Telemetry must never replace the actual upstream result. + } } /** diff --git a/server/app/common/service/DifyRequiredAttachmentUpload.php b/server/app/common/service/DifyRequiredAttachmentUpload.php new file mode 100644 index 000000000..7141fed6b --- /dev/null +++ b/server/app/common/service/DifyRequiredAttachmentUpload.php @@ -0,0 +1,424 @@ + true, 'files' => [], 'attachment_transport' => 'uploaded_files']; + if ($timeout < 1 || $timeout > 300 || $user === '' || strlen($user) > 1024 + || trim($apiKey) === '' || strpbrk($apiKey . $user, "\r\n\0") !== false) { + self::fail('CONFIG_INVALID'); + } + if (!function_exists('curl_init') || !function_exists('curl_multi_init')) self::fail('CURL_UNAVAILABLE'); + $endpoint = self::uploadEndpoint($chatEndpoint); + $publicRoot = realpath((string) ($options['public_root'] ?? dirname(__DIR__, 3) . '/public')); + if ($publicRoot === false || !is_dir($publicRoot)) self::fail('CONFIG_INVALID'); + $localOrigins = self::origins($options['local_origins'] ?? []); + if (!empty($options['local_origin'])) $localOrigins += self::origins([(string) $options['local_origin']]); + $storageOrigins = self::origins($options['storage_origins'] ?? []); + $maxBytes = (int) ($options['max_file_bytes'] ?? self::DEFAULT_MAX_BYTES); + if ($maxBytes < 1 || $maxBytes > 100 * 1024 * 1024) self::fail('CONFIG_INVALID'); + $tempRoot = self::tempRoot((string) ($options['temp_root'] ?? dirname(__DIR__, 3) . '/runtime/dify-required-attachments'), $publicRoot); + + // Validate the whole manifest before the first upload. Never truncate it. + foreach ($plan as &$item) { + $origin = self::origin($item['parts']); + if (isset($localOrigins[$origin])) { + $item['path'] = self::localPath($publicRoot, $item['parts']['path'] ?? '/'); + } elseif (!isset($storageOrigins[$origin])) { + self::fail('REQUIRED_FILES_SOURCE_UNTRUSTED'); + } + } + unset($item); + $prepared = []; + $cache = []; + foreach ($plan as $position => $item) { + $index = $position + 1; + self::check($deadline, $shouldAbort); + self::notify($options, $index, $total, 'uploading'); + $cacheKey = hash('sha256', $item['type'] . "\0" . $item['url']); + if (!isset($cache[$cacheKey])) { + $path = @tempnam($tempRoot, 'required-'); + if ($path === false) self::fail('REQUIRED_FILES_PREPARE_FAILED'); + $temporary[] = $path; + if (!self::inside($tempRoot, (string) realpath($path))) self::fail('REQUIRED_FILES_PREPARE_FAILED'); + @chmod($path, 0600); + if (isset($item['path'])) { + self::copyLocal($item['path'], $path, $maxBytes, $deadline, $shouldAbort); + } else { + self::download($item['url'], $item['parts'], $path, $maxBytes, $deadline, $shouldAbort, $options); + } + self::check($deadline, $shouldAbort); + $mime = @(new \finfo(FILEINFO_MIME_TYPE))->file($path) ?: 'application/octet-stream'; + if (str_starts_with(strtolower($mime), 'video/')) self::fail('REQUIRED_FILES_INVALID'); + $extension = strtolower(pathinfo(rawurldecode($item['parts']['path'] ?? ''), PATHINFO_EXTENSION)); + if (!preg_match('/^[a-z0-9]{1,10}$/D', $extension)) { + $extension = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'application/pdf' => 'pdf', + 'audio/wav' => 'wav', 'audio/mpeg' => 'mp3', 'text/plain' => 'txt'][$mime] ?? 'bin'; + } + $cache[$cacheKey] = self::upload($endpoint, $apiKey, $user, $path, $mime, + 'attachment-' . $index . '.' . $extension, $deadline, $shouldAbort); + } + $prepared[] = ['type' => $item['type'], 'transfer_method' => 'local_file', 'upload_file_id' => $cache[$cacheKey]]; + self::notify($options, $index, $total, 'uploaded'); + } + self::check($deadline, $shouldAbort); + return ['ok' => true, 'files' => $prepared, 'attachment_transport' => 'uploaded_files']; + } catch (\Throwable $error) { + // Never expose cURL response bodies, URLs, local paths, credentials or exception text. + $code = $error instanceof \RuntimeException ? $error->getMessage() : 'REQUIRED_FILES_PREPARE_FAILED'; + $messages = [ + 'CONFIG_INVALID' => '附件上传服务配置无效', + 'CURL_UNAVAILABLE' => '服务器尚未启用附件上传所需的 cURL 扩展', + 'AI_ASSISTANT_CANCELLED' => '本次请求已取消', + 'REQUIRED_FILES_TIMEOUT' => '必需附件上传超时,请重试', + 'REQUIRED_FILES_INVALID' => '必需附件格式或来源地址无效', + 'REQUIRED_FILES_SOURCE_UNTRUSTED' => '必需附件来源未在服务器可信列表中', + 'REQUIRED_FILES_SOURCE_UNAVAILABLE' => '必需附件不可读取,请检查资料后重试', + 'REQUIRED_FILES_TOO_LARGE' => '必需附件超过服务器允许的文件大小', + 'REQUIRED_FILES_DOWNLOAD_FAILED' => '必需附件下载失败,请检查资料后重试', + 'REQUIRED_FILES_UPLOAD_FAILED' => '必需附件上传失败,本次未提交分析', + 'REQUIRED_FILES_PREPARE_FAILED' => '必需附件准备失败,本次未提交分析', + ]; + if (!isset($messages[$code])) $code = 'REQUIRED_FILES_PREPARE_FAILED'; + if ($index > 0 && $total > 0) { + try { self::notify($options, $index, $total, 'failed'); } catch (\Throwable $ignored) {} + } + return ['ok' => false, 'error_code' => $code, 'error' => $messages[$code], + 'failed_index' => $index, 'attachment_count' => $total]; + } finally { + foreach ($temporary as $path) if (is_file($path)) @unlink($path); + } + } + + private static function options(array $options): array + { + $configured = []; + if (function_exists('config')) { + try { $configured = \config('prescription_ai.required_attachment_upload', []); } + catch (\Throwable $ignored) { $configured = []; } + } + return array_replace(is_array($configured) ? $configured : [], $options); + } + + private static function plan(array $files): array + { + $plan = []; + foreach ($files as $file) { + if (!is_array($file)) self::fail('REQUIRED_FILES_INVALID'); + $type = strtolower((string) ($file['type'] ?? '')); + $mime = strtolower((string) ($file['mime_type'] ?? $file['mime'] ?? $file['content_type'] ?? '')); + $url = $file['url'] ?? ''; + $path = is_string($url) ? (parse_url($url, PHP_URL_PATH) ?: '') : ''; + $extension = strtolower(pathinfo(rawurldecode($path), PATHINFO_EXTENSION)); + if ($type === 'video' || str_starts_with($mime, 'video/') || in_array($extension, self::VIDEO_EXTENSIONS, true)) continue; + if (!in_array($type, ['image', 'document', 'audio'], true) + || ($file['transfer_method'] ?? 'remote_url') !== 'remote_url' || !is_string($url)) self::fail('REQUIRED_FILES_INVALID'); + $plan[] = ['type' => $type, 'url' => $url, 'parts' => self::url($url)]; + } + return $plan; + } + + private static function url(string $url): array + { + if ($url === '' || strlen($url) > 8192 || preg_match('/[\x00-\x20\x7f\\\\]/', $url)) self::fail('REQUIRED_FILES_INVALID'); + $parts = parse_url($url); + if (!is_array($parts) || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) + || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment'])) self::fail('REQUIRED_FILES_INVALID'); + $host = strtolower($parts['host']); + $ip = trim($host, '[]'); + if (filter_var($ip, FILTER_VALIDATE_IP) === false && !preg_match('/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/D', $host)) self::fail('REQUIRED_FILES_INVALID'); + $parts['host'] = $host; + $parts['scheme'] = strtolower($parts['scheme']); + return $parts; + } + + private static function origin(array $parts): string + { + $port = (int) ($parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80)); + if ($port < 1 || $port > 65535) self::fail('REQUIRED_FILES_INVALID'); + return $parts['scheme'] . '://' . $parts['host'] . ':' . $port; + } + + private static function origins($origins): array + { + if (!is_array($origins)) self::fail('CONFIG_INVALID'); + $result = []; + foreach ($origins as $origin) { + if (!is_string($origin)) self::fail('CONFIG_INVALID'); + $parts = self::url($origin); + if (($parts['path'] ?? '/') !== '/' || isset($parts['query'])) self::fail('CONFIG_INVALID'); + $result[self::origin($parts)] = true; + } + return $result; + } + + private static function uploadEndpoint(string $endpoint): string + { + $parts = self::url($endpoint); + if (isset($parts['query']) || preg_match('~/chat/completions/?$~', $parts['path'] ?? '')) self::fail('CONFIG_INVALID'); + $path = rtrim($parts['path'] ?? '', '/'); + $path = preg_replace('~/chat-messages$~', '', $path); + $authority = $parts['scheme'] . '://' . $parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : ''); + return $authority . $path . '/files/upload'; + } + + private static function inside(string $root, string $path): bool + { + $root = rtrim(str_replace('\\', '/', $root), '/') . '/'; + $path = str_replace('\\', '/', $path); + if (PHP_OS_FAMILY === 'Windows') { $root = strtolower($root); $path = strtolower($path); } + return str_starts_with($path, $root); + } + + private static function localPath(string $publicRoot, string $urlPath): string + { + if (preg_match('/%(?:2f|5c)/i', $urlPath)) self::fail('REQUIRED_FILES_INVALID'); + $decoded = rawurldecode($urlPath); + if (!str_starts_with($decoded, '/uploads/') || preg_match('/[\x00-\x1f\x7f\\\\%:?#]/', $decoded)) self::fail('REQUIRED_FILES_INVALID'); + foreach (explode('/', substr($decoded, 1)) as $segment) { + if ($segment === '' || $segment === '.' || $segment === '..' || trim($segment) !== $segment + || str_ends_with($segment, '.')) self::fail('REQUIRED_FILES_INVALID'); + } + $uploads = realpath($publicRoot . '/uploads'); + $real = realpath($publicRoot . $decoded); + if ($uploads === false || !self::inside($publicRoot, $uploads) || $real === false + || !self::inside($uploads, $real) || !is_file($real) || !is_readable($real)) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE'); + return $real; + } + + private static function tempRoot(string $root, string $publicRoot): string + { + if (!is_dir($root) && !@mkdir($root, 0700, true) && !is_dir($root)) self::fail('REQUIRED_FILES_PREPARE_FAILED'); + $real = realpath($root); + if ($real === false || $real === $publicRoot || self::inside($publicRoot, $real) || !is_writable($real)) self::fail('CONFIG_INVALID'); + @chmod($real, 0700); + return $real; + } + + private static function copyLocal(string $source, string $destination, int $maxBytes, float $deadline, ?callable $shouldAbort): void + { + $input = @fopen($source, 'rb'); + $output = @fopen($destination, 'wb'); + try { + if ($input === false || $output === false) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE'); + $bytes = 0; + while (!feof($input)) { + self::check($deadline, $shouldAbort); + $chunk = fread($input, 65536); + if ($chunk === false) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE'); + $bytes += strlen($chunk); + if ($bytes > $maxBytes) self::fail('REQUIRED_FILES_TOO_LARGE'); + if (fwrite($output, $chunk) !== strlen($chunk)) self::fail('REQUIRED_FILES_PREPARE_FAILED'); + } + if ($bytes === 0) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE'); + } finally { + if (is_resource($input)) fclose($input); + if (is_resource($output)) fclose($output); + } + } + + private static function download(string $url, array $parts, string $path, int $maxBytes, float $deadline, ?callable $shouldAbort, array $options): void + { + self::check($deadline, $shouldAbort); + $host = trim($parts['host'], '[]'); + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + $addresses = [$host]; + } elseif (is_callable($options['resolve_host'] ?? null)) { + $addresses = ($options['resolve_host'])($host); + } else { + // Validate every returned address, then pin the actual transfer to one. + // Native PHP DNS is synchronous; its elapsed time still consumes this deadline. + $records = @dns_get_record($host, DNS_A | DNS_AAAA); + $addresses = []; + foreach (is_array($records) ? $records : [] as $record) { + if (isset($record['ip'])) $addresses[] = $record['ip']; + if (isset($record['ipv6'])) $addresses[] = $record['ipv6']; + } + } + self::check($deadline, $shouldAbort); + if (!is_array($addresses) || $addresses === []) self::fail('REQUIRED_FILES_DOWNLOAD_FAILED'); + foreach ($addresses as $address) if (!is_string($address) || !self::publicIp($address)) self::fail('REQUIRED_FILES_SOURCE_UNTRUSTED'); + $ip = $addresses[0]; + $port = (int) ($parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80)); + $output = @fopen($path, 'wb'); + if ($output === false) self::fail('REQUIRED_FILES_PREPARE_FAILED'); + $ch = curl_init(); + if ($ch === false) { fclose($output); self::fail('CURL_UNAVAILABLE'); } + $bytes = 0; + $writeError = ''; + try { + $curlOptions = self::curlOptions($url, $deadline, $shouldAbort); + $curlOptions[CURLOPT_RESOLVE] = [$host . ':' . $port . ':' . (str_contains($ip, ':') ? '[' . $ip . ']' : $ip)]; + $curlOptions[CURLOPT_HEADERFUNCTION] = static function ($handle, string $line) use ($maxBytes, &$writeError): int { + if (preg_match('/^Content-Length:\s*(\d+)/i', $line, $match) && (float) $match[1] > $maxBytes) { + $writeError = 'REQUIRED_FILES_TOO_LARGE'; return 0; + } + return strlen($line); + }; + $curlOptions[CURLOPT_WRITEFUNCTION] = static function ($handle, string $chunk) use ($output, $maxBytes, &$bytes, &$writeError): int { + $length = strlen($chunk); + $bytes += $length; + if ($bytes > $maxBytes) { $writeError = 'REQUIRED_FILES_TOO_LARGE'; return 0; } + $written = fwrite($output, $chunk); + if ($written !== $length) { $writeError = 'REQUIRED_FILES_PREPARE_FAILED'; return 0; } + return $written; + }; + if (!curl_setopt_array($ch, $curlOptions)) self::fail('REQUIRED_FILES_DOWNLOAD_FAILED'); + $result = self::execute($ch, $deadline, $shouldAbort); + if ($writeError !== '') self::fail($writeError); + if ($result['errno'] === CURLE_OPERATION_TIMEDOUT) self::fail('REQUIRED_FILES_TIMEOUT'); + if ($result['errno'] !== CURLE_OK || $result['status'] !== 200 || $bytes === 0) self::fail('REQUIRED_FILES_DOWNLOAD_FAILED'); + } finally { curl_close($ch); fclose($output); } + } + + private static function publicIp(string $ip): bool + { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) return false; + if (str_contains($ip, ':')) { + // Global unicast only; exclude transition/documentation/special-use ranges. + return self::cidr($ip, '2000::', 3) && !self::cidr($ip, '2001::', 23) + && !self::cidr($ip, '2002::', 16) && !self::cidr($ip, '3fff::', 20); + } + foreach ([['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], + ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], + ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]] as [$network, $bits]) { + if (self::cidr($ip, $network, $bits)) return false; + } + return true; + } + + private static function cidr(string $ip, string $network, int $bits): bool + { + $packed = @inet_pton($ip); $base = @inet_pton($network); + if ($packed === false || $base === false || strlen($packed) !== strlen($base)) return false; + $bytes = intdiv($bits, 8); $remaining = $bits % 8; + return substr($packed, 0, $bytes) === substr($base, 0, $bytes) + && ($remaining === 0 || (ord($packed[$bytes]) & (255 << (8 - $remaining))) === (ord($base[$bytes]) & (255 << (8 - $remaining)))); + } + + private static function upload(string $endpoint, string $apiKey, string $user, string $path, string $mime, string $name, float $deadline, ?callable $shouldAbort): string + { + self::check($deadline, $shouldAbort); + $ch = curl_init(); + if ($ch === false) self::fail('CURL_UNAVAILABLE'); + $body = ''; + $tooLarge = false; + try { + $options = self::curlOptions($endpoint, $deadline, $shouldAbort); + $options[CURLOPT_POST] = true; + $options[CURLOPT_HTTPHEADER] = ['Authorization: Bearer ' . trim($apiKey), 'Accept: application/json', 'Expect:']; + $options[CURLOPT_POSTFIELDS] = ['user' => $user, 'file' => new \CURLFile($path, $mime, $name)]; + $options[CURLOPT_WRITEFUNCTION] = static function ($handle, string $chunk) use (&$body, &$tooLarge): int { + if (strlen($body) + strlen($chunk) > self::MAX_RESPONSE_BYTES) { $tooLarge = true; return 0; } + $body .= $chunk; + return strlen($chunk); + }; + if (!curl_setopt_array($ch, $options)) self::fail('REQUIRED_FILES_UPLOAD_FAILED'); + $result = self::execute($ch, $deadline, $shouldAbort); + if ($result['errno'] === CURLE_OPERATION_TIMEDOUT) self::fail('REQUIRED_FILES_TIMEOUT'); + if ($result['errno'] !== CURLE_OK || $tooLarge || !in_array($result['status'], [200, 201], true)) self::fail('REQUIRED_FILES_UPLOAD_FAILED'); + $decoded = json_decode($body, true); + $id = is_array($decoded) ? ($decoded['id'] ?? '') : ''; + if (!is_string($id) || !preg_match('/^[a-zA-Z0-9_-]{1,128}$/D', $id)) self::fail('REQUIRED_FILES_UPLOAD_FAILED'); + return $id; + } finally { curl_close($ch); } + } + + private static function curlOptions(string $url, float $deadline, ?callable $shouldAbort): array + { + $remaining = max(1, (int) ceil(($deadline - microtime(true)) * 1000)); + $options = [CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => false, + CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0, CURLOPT_PROXY => '', + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, + CURLOPT_TIMEOUT_MS => $remaining, CURLOPT_CONNECTTIMEOUT_MS => min(10000, $remaining), + CURLOPT_NOSIGNAL => true, CURLOPT_NOPROGRESS => false]; + foreach (['CURLOPT_XFERINFOFUNCTION', 'CURLOPT_PROGRESSFUNCTION'] as $name) { + if (!defined($name)) continue; + $options[constant($name)] = static function (...$unused) use ($deadline, $shouldAbort): int { + try { self::check($deadline, $shouldAbort); return 0; } + catch (\Throwable $ignored) { return 1; } + }; + break; + } + return $options; + } + + /** curl_multi polling observes cancellation even while no headers/body arrive. */ + private static function execute($ch, float $deadline, ?callable $shouldAbort): array + { + $multi = curl_multi_init(); + $added = false; + try { + if (curl_multi_add_handle($multi, $ch) !== CURLM_OK) self::fail('REQUIRED_FILES_PREPARE_FAILED'); + $added = true; + $errno = CURLE_OK; + do { + self::check($deadline, $shouldAbort); + do { $status = curl_multi_exec($multi, $running); } while ($status === CURLM_CALL_MULTI_PERFORM); + if ($status !== CURLM_OK) self::fail('REQUIRED_FILES_PREPARE_FAILED'); + while (($info = curl_multi_info_read($multi)) !== false) $errno = (int) $info['result']; + self::check($deadline, $shouldAbort); + if ($running && curl_multi_select($multi, 0.05) === -1) usleep(1000); + } while ($running); + return ['errno' => $errno, 'status' => (int) curl_getinfo($ch, CURLINFO_HTTP_CODE)]; + } finally { + if ($added) curl_multi_remove_handle($multi, $ch); + curl_multi_close($multi); + } + } + + private static function check(float $deadline, ?callable $shouldAbort): void + { + if ($shouldAbort !== null && $shouldAbort()) self::fail('AI_ASSISTANT_CANCELLED'); + if (microtime(true) >= $deadline) self::fail('REQUIRED_FILES_TIMEOUT'); + } + + private static function notify(array $options, int $index, int $total, string $status): void + { + if (is_callable($options['on_upload'] ?? null)) ($options['on_upload'])(['index' => $index, 'total' => $total, 'status' => $status]); + } + + private static function fail(string $code): void { throw new \RuntimeException($code); } +} diff --git a/server/config/prescription_ai.php b/server/config/prescription_ai.php index 9e0f8a9bd..f44954d45 100644 --- a/server/config/prescription_ai.php +++ b/server/config/prescription_ai.php @@ -18,6 +18,18 @@ return [ 'prescription_ai.TIMEOUT', env('prescription_ai.timeout', 90) ), + // Clinical source files are mandatory: upload their bytes first instead of + // relying on the model server to reach the workstation's resource URLs. + 'required_attachment_transport' => 'upload', + 'required_attachment_upload' => [ + // Explicit aliases for this deployment's upload directory. Do not derive + // local-file access from an untrusted request Host or attachment URL. + 'local_origins' => array_values(array_filter(array_map('trim', explode(',', (string) env( + 'prescription_ai.REQUIRED_ATTACHMENT_LOCAL_ORIGINS', + 'https://admin.zhenyangtang.com.cn,https://css.zhenyangtang.com.cn,http://admin.zhenyangtang.com.cn,http://css.zhenyangtang.com.cn' + ))))), + 'max_file_bytes' => (int) env('prescription_ai.REQUIRED_ATTACHMENT_MAX_BYTES', 20 * 1024 * 1024), + ], // Reuse only evidence for an identical, freshly authorized source; 0 disables. 'context_compaction_cache_ttl' => (int) env('prescription_ai.CONTEXT_COMPACTION_CACHE_TTL', 300), /** diff --git a/server/tests/DiagnosisAiAssistantErrorReceiptTest.php b/server/tests/DiagnosisAiAssistantErrorReceiptTest.php new file mode 100644 index 000000000..23fef1990 --- /dev/null +++ b/server/tests/DiagnosisAiAssistantErrorReceiptTest.php @@ -0,0 +1,49 @@ + false, 'error_code' => 'REQUIRED_FILES_REJECTED', 'error' => '合成附件失败', + 'required_sections' => $sections, 'source_summary' => ['patient_name' => '不得回传的姓名', 'url' => 'https://private.invalid', 'required_sections' => $sections], + 'items' => [['kind' => 'image', 'status' => 'failed', 'label' => '不得回传的姓名', 'url' => 'https://private.invalid/image.png', 'upload_file_id' => 'private-id'], + ['kind' => 'video', 'status' => 'submitted', 'label' => 'private-video']], + 'attachment_count' => 1, 'attachment_transfer' => 'uploaded_files', 'transfer_manifest_final' => true, + 'upstream_http_status' => 400, 'upstream_message' => 'provider private message', 'api_key' => 'private-api-key']; +errorReceiptExpect($format->invoke(null, [], $result) === null, 'real formatter retains failure'); +$meta = DiagnosisAiLogic::getAssistantErrorMeta(); +errorReceiptExpect(count($meta['required_sections']) === 13 && $meta['items'][0]['status'] === 'failed' + && count($meta['items']) === 1 && $meta['attachment_transfer'] === 'uploaded_files', 'safe complete final receipt survives failure'); +$encoded = json_encode($meta, JSON_UNESCAPED_UNICODE); +foreach (['不得回传的姓名', 'private.invalid', 'private-id', 'private-api-key', 'provider private message', 'private-video'] as $private) { + errorReceiptExpect(!str_contains($encoded, $private), 'error metadata excludes private fields'); +} +$protocol = new AssistantSseProtocol(); $protocol->encode('start', []); +$frames = []; +$emit = static function (string $event, array $payload) use ($protocol, &$frames): bool { + $frames[] = $protocol->encode($event, $payload); return true; +}; +$controller = (new ReflectionClass(DiagnosisController::class))->newInstanceWithoutConstructor(); +$finish = new ReflectionMethod(DiagnosisController::class, 'finishAssistantError'); +$finish->invoke($controller, $emit, null, false); +$frame = end($frames); +errorReceiptExpect(str_contains($frame, 'REQUIRED_FILES_REJECTED') && str_contains($frame, '"transfer_manifest_final":true') + && str_contains($frame, '"status":"failed"'), 'real controller SSE error carries authoritative failure receipt'); +$frames = []; $cancelProtocol = new AssistantSseProtocol(); $cancelProtocol->encode('start', []); +$cancelEmit = static function (string $event, array $payload) use ($cancelProtocol, &$frames): bool { + $frames[] = $cancelProtocol->encode($event, $payload); return true; +}; +$finish->invoke($controller, $cancelEmit, null, true); +errorReceiptExpect(!str_contains(end($frames), 'required_sections') && str_contains(end($frames), 'AI_ASSISTANT_CANCELLED'), + 'cancel terminal does not reuse unrelated failed receipt'); +$format->invoke(null, [], ['ok' => true, 'content' => '合成回复']); +errorReceiptExpect(DiagnosisAiLogic::getAssistantErrorMeta() === [], 'success clears stale error metadata'); +echo "Assistant final error receipt / real controller SSE / private metadata filter / cancellation isolation: OK\n"; diff --git a/server/tests/DifyRequiredAttachmentUploadTest.php b/server/tests/DifyRequiredAttachmentUploadTest.php new file mode 100644 index 000000000..0a403c67c --- /dev/null +++ b/server/tests/DifyRequiredAttachmentUploadTest.php @@ -0,0 +1,309 @@ + 'upload', 'user' => $_POST['user'] ?? '', 'name' => $upload['name'] ?? '', + 'mime' => $upload['type'] ?? '', 'size' => $upload['size'] ?? 0, + 'hash' => is_file($temporary) ? hash_file('sha256', $temporary) : '', 'id' => $id, + 'authorization' => $_SERVER['HTTP_AUTHORIZATION'] ?? '', 'path' => $path]); + if (str_starts_with($path, '/slow-headers/')) usleep(1200000); + if (str_starts_with($path, '/budget/')) usleep(650000); + header('Content-Type: application/json'); + if (str_starts_with($path, '/fail/')) { http_response_code(422); echo '{"error":"fixture-private-path-url-key-body"}'; return; } + if (str_starts_with($path, '/fail-second/')) { + $entries = array_map(static fn (string $line): array => json_decode($line, true), file($root . '/requests.jsonl', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)); + $uploads = array_filter($entries, static fn (array $entry): bool => ($entry['kind'] ?? '') === 'upload'); + if (count($uploads) === 2) { http_response_code(422); echo '{"error":"fixture-private-path-url-key-body"}'; return; } + } + if (str_starts_with($path, '/bad-id/')) { http_response_code(201); echo '{"id":"invalid/id"}'; return; } + if (str_starts_with($path, '/large-response/')) { http_response_code(201); echo str_repeat('x', 300000); return; } + http_response_code(201); + if (str_starts_with($path, '/slow-body/')) { echo '{"id":"'; flush(); usleep(1200000); echo $id . '"}'; return; } + echo json_encode(['id' => $id]); + return; + } + $append(['kind' => 'download', 'path' => $path, 'authorization' => $_SERVER['HTTP_AUTHORIZATION'] ?? '']); + if ($path === '/redirect') { header('Location: http://169.254.169.254/latest/meta-data/'); http_response_code(302); echo 'redirect'; return; } + if ($path === '/oversize-header') { header('Content-Length: 2000000'); echo 'x'; return; } + if ($path === '/oversize-stream') { echo str_repeat('x', 30000); return; } + if ($path === '/slow-download') usleep(1200000); + if ($path === '/budget-download') usleep(650000); + header('Content-Type: image/png'); + readfile($root . '/public/uploads/first.png'); + return; + } + + require dirname(__DIR__) . '/app/common/service/DifyRequiredAttachmentUpload.php'; + use app\common\service\DifyRequiredAttachmentUpload; + + function attachmentExpect(bool $ok, string $message): void + { + if (!$ok) throw new RuntimeException($message); + } + function attachmentError(array $result, string $code, string $message): void + { + attachmentExpect(empty($result['ok']) && ($result['error_code'] ?? '') === $code && !isset($result['files']), + $message . ' (' . ($result['error_code'] ?? 'no error') . ')'); + attachmentExpect(!str_contains(json_encode($result), 'fixture-private-path-url-key-body'), 'upstream body never escapes'); + } + function attachmentRemoveTree(string $path, string $workspace): void + { + $resolved = realpath($path); $root = realpath($workspace); + if ($resolved === false || $root === false || ($resolved !== $root && !str_starts_with(str_replace('\\', '/', $resolved), str_replace('\\', '/', $root) . '/'))) { + throw new RuntimeException('Refusing to clean outside synthetic test workspace'); + } + foreach (new DirectoryIterator($resolved) as $item) { + if ($item->isDot()) continue; + if ($item->isDir() && !$item->isLink()) attachmentRemoveTree($item->getPathname(), $workspace); + else unlink($item->getPathname()); + } + rmdir($resolved); + } + + $root = sys_get_temp_dir() . '/dify-required-synthetic-' . bin2hex(random_bytes(8)); + mkdir($root . '/public/uploads/nested', 0700, true); + mkdir($root . '/private', 0700); + mkdir($root . '/public/uploads-evil', 0700); + $image = imagecreatetruecolor(64, 64); + imagefill($image, 0, 0, imagecolorallocate($image, 50, 130, 180)); + imagepng($image, $root . '/public/uploads/first.png'); + imagefill($image, 0, 0, imagecolorallocate($image, 180, 130, 50)); + imagepng($image, $root . '/public/uploads/nested/second.png'); + imagedestroy($image); + file_put_contents($root . '/public/uploads/report.pdf', "%PDF-1.4\n1 0 obj<>endobj\n%%EOF\n"); + file_put_contents($root . '/public/uploads/audio.wav', 'RIFF' . pack('V', 38) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 8000, 16000, 2, 16) . 'data' . pack('V', 2) . "\0\0"); + file_put_contents($root . '/public/uploads/large.txt', str_repeat('x', 30000)); + file_put_contents($root . '/private/do-not-read.txt', 'synthetic-private-file'); + file_put_contents($root . '/public/uploads-evil/escape.txt', 'synthetic-private-file'); + $socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); + attachmentExpect(is_resource($socket), 'allocate loopback fixture port'); + $address = stream_socket_get_name($socket, false); + fclose($socket); + $port = (int) substr($address, strrpos($address, ':') + 1); + $attachmentFixturePort = $port; + $attachmentCurlOptions = []; + $process = proc_open([PHP_BINARY, '-S', '127.0.0.1:' . $port, __FILE__], + [['pipe', 'r'], ['file', $root . '/fixture-output.log', 'ab'], ['file', $root . '/fixture-error.log', 'ab']], + $pipes, $root, ['DIFY_ATTACHMENT_FIXTURE_ROOT' => $root, + 'SystemRoot' => (string) getenv('SystemRoot'), 'TEMP' => (string) getenv('TEMP'), 'TMP' => (string) getenv('TMP')], ['bypass_shell' => true]); + try { + attachmentExpect(is_resource($process), 'start loopback fixture'); + if (isset($pipes[0]) && is_resource($pipes[0])) fclose($pipes[0]); + $ready = false; + for ($attempt = 0; $attempt < 150; ++$attempt) { + $probe = @fsockopen('127.0.0.1', $port, $errno, $error, 0.02); + if (is_resource($probe)) { fclose($probe); $ready = true; break; } + usleep(20000); + } + attachmentExpect($ready, 'loopback fixture becomes ready'); + $endpoint = 'http://127.0.0.1:' . $port . '/v1/chat-messages'; + $options = ['local_origins' => ['https://site.synthetic.test', 'https://admin.synthetic.test'], + 'storage_origins' => ['https://storage.synthetic.test'], 'public_root' => $root . '/public', + 'temp_root' => $root . '/private/uploads', 'max_file_bytes' => 20000, + 'resolve_host' => static fn (string $host): array => ['8.8.8.8', '2606:4700:4700::1111']]; + $files = [ + ['type' => 'image', 'transfer_method' => 'remote_url', 'url' => 'https://site.synthetic.test/uploads/first.png'], + ['type' => 'image', 'url' => 'https://admin.synthetic.test/uploads/nested/second.png'], + ['type' => 'document', 'url' => 'https://site.synthetic.test/uploads/report.pdf'], + ]; + $requests = static function () use ($root): array { + if (!is_file($root . '/requests.jsonl')) return []; + return array_map(static fn (string $line): array => json_decode($line, true), file($root . '/requests.jsonl', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES)); + }; + $clear = static function () use ($root, &$attachmentCurlOptions): void { + file_put_contents($root . '/requests.jsonl', ''); $attachmentCurlOptions = []; + }; + $clean = static function () use ($root): void { + attachmentExpect((glob($root . '/private/uploads/required-*') ?: []) === [], 'temporary files are removed after every terminal outcome'); + }; + $run = static function (array $input, array $extra = [], ?callable $abort = null, string $user = 'synthetic-user', ?string $target = null, int $timeout = 5) use ($endpoint, $options): array { + return DifyRequiredAttachmentUpload::prepare($target ?? $endpoint, 'fixture-only-key', $user, $input, $timeout, $abort, array_replace($options, $extra)); + }; + + $clear(); $events = []; + $result = $run(array_merge($files, [ + ['type' => 'video', 'url' => 'https://untrusted.invalid/record.mp4'], + ['type' => 'document', 'url' => 'https://untrusted.invalid/record.MOV'], + ['type' => 'image', 'url' => 'https://untrusted.invalid/opaque', 'mime_type' => 'video/webm'], + ]), ['on_upload' => static function (array $event) use (&$events): void { $events[] = $event; }]); + attachmentExpect(!empty($result['ok']) && count($result['files']) === 3 && $result['attachment_transport'] === 'uploaded_files', 'two images and PDF all become local_file references'); + attachmentExpect(array_column($result['files'], 'type') === ['image', 'image', 'document'] + && array_unique(array_column($result['files'], 'transfer_method')) === ['local_file'], 'types and attachment order preserved'); + attachmentExpect(count($requests()) === 3 && array_unique(array_column($requests(), 'user')) === ['synthetic-user'], 'every multipart upload has the exact chat user'); + attachmentExpect(array_column($requests(), 'hash') === [hash_file('sha256', $root . '/public/uploads/first.png'), + hash_file('sha256', $root . '/public/uploads/nested/second.png'), hash_file('sha256', $root . '/public/uploads/report.pdf')], 'multipart contains complete binary bytes'); + attachmentExpect(count($events) === 6 && array_column($events, 'status') === ['uploading', 'uploaded', 'uploading', 'uploaded', 'uploading', 'uploaded'], 'safe one-based progress follows each actual upload'); + foreach ($events as $event) attachmentExpect(array_keys($event) === ['index', 'total', 'status'] && $event['total'] === 3, 'progress contains safe counts/status only'); + foreach ($attachmentCurlOptions as $curlOptions) { + $temporary = $curlOptions[CURLOPT_POSTFIELDS]['file']->getFilename(); + attachmentExpect(str_starts_with(str_replace('\\', '/', $temporary), str_replace('\\', '/', $root) . '/private/uploads/') && !is_file($temporary), 'CURLFile uses cleaned nonpublic snapshot'); + attachmentExpect($curlOptions[CURLOPT_SSL_VERIFYPEER] === true && $curlOptions[CURLOPT_SSL_VERIFYHOST] === 2 && $curlOptions[CURLOPT_PROXY] === '', 'transport verifies TLS and ignores environment proxies'); + } + $clean(); + + $clear(); + $duplicates = $run([$files[0], $files[0]]); + attachmentExpect(!empty($duplicates['ok']) && count($duplicates['files']) === 2 && count($requests()) === 1 + && $duplicates['files'][0]['upload_file_id'] === $duplicates['files'][1]['upload_file_id'], 'same request reuses ID while preserving both references'); + $otherUser = $run([$files[0]], [], null, 'another-synthetic-user'); + attachmentExpect(!empty($otherUser['ok']) && count($requests()) === 2 + && $otherUser['files'][0]['upload_file_id'] !== $duplicates['files'][0]['upload_file_id'] + && $requests()[1]['user'] === 'another-synthetic-user', 'no upload IDs or cache cross user boundaries'); + $sameUserNextRequest = $run([$files[0]]); + attachmentExpect(!empty($sameUserNextRequest['ok']) && count($requests()) === 3 + && $sameUserNextRequest['files'][0]['upload_file_id'] !== $duplicates['files'][0]['upload_file_id'], 'even same-user requests have no global ID cache'); + $clean(); + $clear(); + $audio = $run([['type' => 'audio', 'url' => 'https://site.synthetic.test/uploads/audio.wav']]); + attachmentExpect(!empty($audio['ok']) && $audio['files'][0]['type'] === 'audio' && $requests()[0]['mime'] === 'audio/x-wav', 'audio bytes retain audio type'); + $clean(); + + $clear(); + $source = static fn (string $url): array => [['type' => 'document', 'url' => $url]]; + foreach ([ + 'https://evil.synthetic.test/uploads/first.png', + 'https://site.synthetic.test.evil.invalid/uploads/first.png', + 'http://site.synthetic.test/uploads/first.png', + 'https://site.synthetic.test:444/uploads/first.png', + ] as $url) attachmentError($run($source($url)), 'REQUIRED_FILES_SOURCE_UNTRUSTED', 'exact origin prevents alias/scheme/port bypass'); + foreach ([ + 'https://site.synthetic.test/uploads/../private/do-not-read.txt', + 'https://site.synthetic.test/uploads/%2e%2e/private/do-not-read.txt', + 'https://site.synthetic.test/uploads/%252e%252e/private/do-not-read.txt', + 'https://site.synthetic.test/uploads/nested%2fsecond.png', + 'https://site.synthetic.test/uploads/nested%5csecond.png', + 'https://site.synthetic.test/uploads//first.png', + 'https://site.synthetic.test/uploads/first.png::$DATA', + 'https://site.synthetic.test/uploads/first.png.', + 'https://site.synthetic.test/uploads-evil/escape.txt', + 'https://site.synthetic.test/private/do-not-read.txt', + 'https://site.synthetic.test@evil.invalid/uploads/first.png', + 'https://site.synthetic.test/uploads/first.png#fragment', + 'file:///private/do-not-read.txt', + '/uploads/first.png', + ] as $url) attachmentError($run($source($url)), 'REQUIRED_FILES_INVALID', 'local path/URL bypass rejected before network'); + attachmentError($run($source('https://site.synthetic.test/uploads/missing.png')), 'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'missing local files do not trigger network fallback'); + attachmentExpect($requests() === [], 'origin/path failures never upload or download'); + if (@symlink($root . '/private/do-not-read.txt', $root . '/public/uploads/link.txt')) { + attachmentError($run($source('https://site.synthetic.test/uploads/link.txt')), 'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'realpath rejects symlink escape'); + } + $clean(); + + $clear(); + $remote = [['type' => 'image', 'url' => 'https://storage.synthetic.test/download.png']]; + $downloaded = $run($remote); + attachmentExpect(!empty($downloaded['ok']) && count($requests()) === 2 && $requests()[0]['kind'] === 'download' + && $requests()[0]['authorization'] === '' && $requests()[1]['authorization'] === 'Bearer fixture-only-key', 'remote bytes upload without sharing Dify key with storage'); + attachmentExpect($attachmentCurlOptions[0][CURLOPT_RESOLVE] === ['storage.synthetic.test:443:8.8.8.8'] + && $attachmentCurlOptions[0][CURLOPT_FOLLOWLOCATION] === false, 'validated DNS address is pinned and redirect following disabled'); + $clear(); $dnsCalls = 0; + $rebound = $run($remote, ['resolve_host' => static function () use (&$dnsCalls): array { return [++$dnsCalls === 1 ? '8.8.8.8' : '127.0.0.1']; }]); + attachmentExpect(!empty($rebound['ok']) && $dnsCalls === 1 + && $attachmentCurlOptions[0][CURLOPT_RESOLVE] === ['storage.synthetic.test:443:8.8.8.8'], 'a rebound DNS answer cannot replace the pinned validated address'); + $clean(); + + foreach (['127.0.0.1', '10.0.0.1', '172.16.0.1', '192.168.1.1', '169.254.169.254', '100.100.100.200', + '0.0.0.0', '224.0.0.1', '::1', 'fc00::1', 'fe80::1', '::ffff:127.0.0.1', '64:ff9b::7f00:1', '2002:7f00:1::', '2001:db8::1'] as $ip) { + $clear(); + attachmentError($run($remote, ['resolve_host' => static fn (): array => [$ip]]), 'REQUIRED_FILES_SOURCE_UNTRUSTED', 'private/metadata/transition DNS addresses rejected'); + attachmentExpect($requests() === [] && $attachmentCurlOptions === [], 'unsafe DNS never opens a connection'); + } + $clear(); + attachmentError($run($remote, ['resolve_host' => static fn (): array => ['8.8.8.8', '127.0.0.1']]), 'REQUIRED_FILES_SOURCE_UNTRUSTED', 'mixed public/private DNS is rejected in full'); + attachmentError($run($remote, ['resolve_host' => static fn (): array => []]), 'REQUIRED_FILES_DOWNLOAD_FAILED', 'unresolvable storage fails explicitly'); + foreach (['https://storage.synthetic.test/redirect', 'https://storage.synthetic.test/oversize-header', 'https://storage.synthetic.test/oversize-stream'] as $url) { + $clear(); + attachmentError($run($source($url)), str_ends_with($url, '/redirect') ? 'REQUIRED_FILES_DOWNLOAD_FAILED' : 'REQUIRED_FILES_TOO_LARGE', 'redirect/oversize downloads fail without dropping mandatory file'); + attachmentExpect(count($requests()) === 1 && $requests()[0]['kind'] === 'download', 'redirect is never followed and bad download is never uploaded'); + $clean(); + } + $clear(); + attachmentError($run($source('https://site.synthetic.test/uploads/large.txt')), 'REQUIRED_FILES_TOO_LARGE', 'local file copy has the same byte limit'); + attachmentExpect($requests() === [], 'oversize local file never uploads'); + $clean(); + + foreach (['fail', 'bad-id', 'large-response'] as $mode) { + $clear(); $failedEvents = []; + $failed = $run($files, ['on_upload' => static function (array $event) use (&$failedEvents): void { $failedEvents[] = $event; }], null, 'synthetic-user', + 'http://127.0.0.1:' . $port . '/' . $mode . '/v1/chat-messages'); + attachmentError($failed, 'REQUIRED_FILES_UPLOAD_FAILED', 'provider upload error never returns a partial attachment list'); + attachmentExpect(count($requests()) === 1 && $failedEvents[count($failedEvents) - 1]['status'] === 'failed', 'upload failure stops before a model request and marks safe progress failed'); + $clean(); + } + $clear(); + $midway = $run($files, [], null, 'synthetic-user', 'http://127.0.0.1:' . $port . '/fail-second/v1/chat-messages'); + attachmentError($midway, 'REQUIRED_FILES_UPLOAD_FAILED', 'failure after a successful upload still returns no partial files'); + attachmentExpect(count($requests()) === 2 && $midway['failed_index'] === 2 && $midway['attachment_count'] === 3, 'failure identifies safe index and never skips ahead to remaining attachment'); + $clean(); + $clear(); + attachmentError($run($files, [], static fn (): bool => true), 'AI_ASSISTANT_CANCELLED', 'cancellation before preparation opens no transport'); + attachmentExpect($requests() === [], 'early cancellation has no network side effects'); + $cancelDuringCopy = false; + attachmentError($run([$files[0]], ['on_upload' => static function () use (&$cancelDuringCopy): void { $cancelDuringCopy = true; }], + static function () use (&$cancelDuringCopy): bool { return $cancelDuringCopy; }), 'AI_ASSISTANT_CANCELLED', 'cancellation during local copy cleans its new temporary file'); + attachmentExpect($requests() === [], 'cancelled local preparation never uploads'); + $clean(); + foreach (['slow-headers', 'slow-body'] as $mode) { + $clear(); $start = microtime(true); + $cancelled = $run([$files[0]], [], static fn (): bool => microtime(true) - $start > 0.15, 'synthetic-user', + 'http://127.0.0.1:' . $port . '/' . $mode . '/v1/chat-messages'); + attachmentError($cancelled, 'AI_ASSISTANT_CANCELLED', 'cancellation interrupts upload response ' . $mode); + attachmentExpect(microtime(true) - $start < 0.7, 'curl_multi observes cancellation while waiting for headers/body'); + $clean(); usleep(1250000); + } + $clear(); $start = microtime(true); + attachmentError($run($source('https://storage.synthetic.test/slow-download'), [], static fn (): bool => microtime(true) - $start > 0.15), + 'AI_ASSISTANT_CANCELLED', 'cancellation interrupts remote download header wait'); + attachmentExpect(microtime(true) - $start < 0.7, 'download cancellation does not wait for its full timeout'); + $clean(); usleep(1250000); + $clear(); $start = microtime(true); + $budget = $run(array_slice($files, 0, 2), [], null, 'synthetic-user', 'http://127.0.0.1:' . $port . '/budget/v1/chat-messages', 1); + attachmentError($budget, 'REQUIRED_FILES_TIMEOUT', 'multiple uploads share one deadline'); + attachmentExpect(microtime(true) - $start < 1.4 && count($requests()) === 2, 'second upload receives only remaining request budget'); + $clean(); usleep(700000); + $clear(); $start = microtime(true); + $combinedBudget = $run($source('https://storage.synthetic.test/budget-download'), [], null, 'synthetic-user', + 'http://127.0.0.1:' . $port . '/budget/v1/chat-messages', 1); + attachmentError($combinedBudget, 'REQUIRED_FILES_TIMEOUT', 'download and subsequent upload consume the same deadline'); + attachmentExpect(microtime(true) - $start < 1.4 && count($requests()) === 2, 'upload does not receive a fresh budget after downloading'); + $clean(); usleep(700000); + $clear(); + attachmentError($run([$files[0]], ['temp_root' => $root . '/public/unsafe-temp']), 'CONFIG_INVALID', 'temporary files cannot be created in public'); + attachmentExpect($requests() === [], 'unsafe temp configuration opens no connection'); + attachmentError($run([['type' => 'image', 'transfer_method' => 'local_file', 'url' => $files[0]['url']]]), 'REQUIRED_FILES_INVALID', 'callers cannot inject upload IDs from other requests'); + $clean(); + echo "Dify required attachment upload: binary multipart, all sources, origins/paths/DNS/pinning/redirects/limits, isolation, cancellation/deadline, cleanup: OK\n"; + } finally { + if (is_resource($process)) { proc_terminate($process); proc_close($process); } + attachmentRemoveTree($root, $root); + } +} diff --git a/server/tests/DifyRequiredFilesRejectionClassificationTest.php b/server/tests/DifyRequiredFilesRejectionClassificationTest.php new file mode 100644 index 000000000..d86a07fb9 --- /dev/null +++ b/server/tests/DifyRequiredFilesRejectionClassificationTest.php @@ -0,0 +1,220 @@ + 'synthetic']; } + } + function config(string $name): array { return $GLOBALS['classificationConfig']; } + function curl_init(): \stdClass { return (object) ['errno' => 0]; } + function curl_setopt_array(\stdClass $handle, array $options): bool { + $handle->options = $options; + $handle->payload = json_decode($options[CURLOPT_POSTFIELDS], true); + return true; + } + function curl_exec(\stdClass $handle): string|bool { + $options = $handle->options; + $payload = $handle->payload; + $streaming = ($payload['response_mode'] ?? '') === 'streaming' || !empty($payload['stream']); + $GLOBALS['classificationRequests'][] = [ + 'mode' => $streaming ? 'streaming' : 'blocking', 'payload' => $payload, + 'raw' => $options[CURLOPT_POSTFIELDS], 'timeout' => $options[CURLOPT_TIMEOUT], + ]; + $fixture = array_shift($GLOBALS['classificationFixtures']); + if (!is_array($fixture)) throw new \RuntimeException('Unexpected extra upstream request'); + $handle->code = $fixture['http'] ?? 200; + $handle->errno = $fixture['errno'] ?? 0; + if (!empty($fixture['delay_us'])) usleep($fixture['delay_us']); + if (!empty($fixture['cancel'])) $GLOBALS['classificationCancelled'] = true; + if (isset($options[CURLOPT_HEADERFUNCTION])) { + $options[CURLOPT_HEADERFUNCTION]($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n"); + } + $progress = $options[defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION] ?? null; + if (is_callable($progress) && $progress($handle, 0, 0, 100, 100)) { + $handle->errno = CURLE_ABORTED_BY_CALLBACK; + return false; + } + if (isset($fixture['body'])) { + $body = $fixture['body']; + } elseif (isset($fixture['error'])) { + $body = $streaming && $handle->code === 200 + ? 'data: ' . json_encode(['event' => 'error'] + $fixture['error']) . "\n\n" + : json_encode($fixture['error']); + } else { + $body = !$streaming ? '{"answer":"synthetic answer"}' + : (isset($payload['messages']) + ? "data: {\"choices\":[{\"delta\":{\"content\":\"synthetic answer\"}}]}\n\ndata: [DONE]\n\n" + : "data: {\"event\":\"message\",\"answer\":\"synthetic answer\"}\n\ndata: {\"event\":\"message_end\"}\n\n"); + } + if (!$streaming) return $body; + // Exercise bounded HTTP error parsing across arbitrary network chunks. + $write = $options[CURLOPT_WRITEFUNCTION]; + foreach (str_split($body, 11) as $chunk) { + if ($write($handle, $chunk) !== strlen($chunk)) { $handle->errno = CURLE_WRITE_ERROR; break; } + } + return true; + } + function curl_errno(\stdClass $handle): int { return $handle->errno; } + function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code ?? 200; } + function curl_close(\stdClass $handle): void { ++$GLOBALS['classificationClosed']; } +} + +namespace { + $root = dirname(__DIR__, 2); + require $root . '/server/app/common/logic/BaseLogic.php'; + require $root . '/server/app/common/service/DifyChatService.php'; + require $root . '/server/app/adminapi/service/AssistantSseTiming.php'; + require $root . '/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php'; + use app\common\service\DifyChatService; + use app\adminapi\logic\tcm\DiagnosisAiLogic; + use app\adminapi\service\AssistantSseTiming; + + function classificationExpect(bool $condition, string $message): void { + if (!$condition) throw new RuntimeException($message); + } + $classificationConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages', + 'timeout' => 5, 'max_files' => 3, + 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'synthetic-only']]]; + $files = array_map(static fn (int $id): array => ['type' => 'image', 'url' => 'https://synthetic.invalid/' . $id . '.png'], [1, 2]); + $sections = array_map(static fn (int $id): array => ['key' => 'source_' . $id, 'label' => 'synthetic', + 'count' => $id === 13 ? 0 : 1, 'availability' => $id === 13 ? 'empty' : 'present'], range(1, 13)); + $options = ['required_files' => true, 'knowledge_mode' => 'evidence', 'required_sections' => $sections]; + $reset = static function (array $fixtures): void { + $GLOBALS['classificationFixtures'] = $fixtures; + $GLOBALS['classificationRequests'] = []; + $GLOBALS['classificationLogs'] = []; + $GLOBALS['classificationCancelled'] = false; + $GLOBALS['classificationClosed'] = 0; + $GLOBALS['classificationThrowLog'] = false; + }; + $call = static function (bool $streaming, array $inputs = [], array $extra = []) use ($files, $options): array { + $selected = array_replace($options, $extra); + return $streaming + ? DifyChatService::streamChat('qwen', $inputs, 'synthetic query', 'synthetic-user', static fn (): bool => true, null, $files, $selected) + : DifyChatService::chat('qwen', $inputs, 'synthetic query', 'synthetic-user', $files, $selected); + }; + $private = static fn (string $method, array $args) => (new ReflectionClass(DifyChatService::class))->getMethod($method)->invokeArgs(null, $args); + $inputError = ['http' => 400, 'error' => ['code' => 'invalid_param', 'message' => 'Unknown input variable: synthetic_field']]; + $fileError = ['http' => 400, 'error' => ['code' => 'invalid_param', 'message' => 'Number of files exceeds the limit.']]; + $secret = 'SYNTHETIC_PRIVATE_MARKER'; + + foreach ([false, true] as $streaming) { + // Mere status codes/invalid_param do not identify a file or an inputs error. + foreach ([400, 413, 415, 422] as $http) { + $reset([['http' => $http, 'error' => ['code' => 'invalid_param', 'message' => $secret . ' https://synthetic.invalid/private.png']]]); + $result = $call($streaming, ['synthetic_field' => 'value']); + classificationExpect($result['error_code'] === 'UPSTREAM_REJECTED' && count($classificationRequests) === 1, + 'Generic validation does not invent attachment or inputs rejection'); + classificationExpect(!str_contains(json_encode([$result, $classificationLogs]), $secret), 'Provider messages never enter errors or logs'); + classificationExpect(count($classificationLogs) === 1 + && str_contains($classificationLogs[0], '"upstream_code":"invalid_param"') + && !str_contains($classificationLogs[0], 'https://'), 'Safe failure code is logged immediately without hostnames or resource URLs'); + } + foreach ([[401, 'CONFIG_INVALID'], [403, 'CONFIG_INVALID'], [429, 'UPSTREAM_BUSY'], [503, 'UPSTREAM_BUSY']] as [$http, $expected]) { + $reset([$inputError, ['http' => $http, 'error' => ['code' => 'invalid_param', 'message' => 'file type not supported']]]); + $result = $call($streaming, ['synthetic_field' => 'value']); + classificationExpect($result['error_code'] === $expected && count($classificationRequests) === 2, + 'Latest auth/availability response is not overwritten by an earlier inputs rejection'); + classificationExpect(array_map(static fn (array $request): int => count($request['payload']['files']), $classificationRequests) === [2, 2], + 'Every compatibility request retains both mandatory files'); + } + $reset([$inputError, []]); + $result = $call($streaming, ['synthetic_field' => 'value']); + classificationExpect(!empty($result['ok']) && count($classificationRequests) === 2 + && $classificationRequests[1]['payload']['inputs'] === [] + && str_contains($classificationRequests[1]['raw'], '"inputs":{}') + && $classificationRequests[0]['payload']['query'] === $classificationRequests[1]['payload']['query'], + 'Explicit inputs compatibility succeeds with identical query/files and object inputs'); + + foreach ([$fileError, ['http' => 413, 'error' => ['code' => 'file_too_large']], + ['http' => 415, 'error' => ['code' => 'unsupported_file_type']], + ['http' => 422, 'error' => ['code' => 'invalid_param', 'message' => 'File type is not supported.']]] as $fixture) { + $reset([$fixture]); $events = []; + $result = $call($streaming, ['synthetic_field' => 'value'], + ['on_progress' => static function (array $event) use (&$events): void { $events[] = $event; }]); + classificationExpect($result['error_code'] === 'REQUIRED_FILES_REJECTED' && count($classificationRequests) === 1, + 'Affirmative file count/type/size failure never strips mandatory files or retries inputs'); + $last = $events[count($events) - 1]; + foreach ([$result, $last] as $manifest) { + classificationExpect($manifest['transfer_manifest_final'] === true && $manifest['attachment_count'] === 2 + && count($manifest['required_sections']) === 13 + && array_unique(array_column($manifest['items'], 'status')) === ['failed'] + && array_unique(array_column($manifest['required_sections'], 'status')) === ['failed'] + && $manifest['required_sections'][12]['availability'] === 'empty', 'Failure results and progress carry a complete final failed manifest'); + } + } + } + + // Business SSE errors may be reported under HTTP 200. An ambiguous base must + // not re-submit quotas, credentials, cancellation or affirmative file failures. + $classificationConfig['base_url'] = 'https://synthetic.invalid/v1'; + foreach ([['provider_quota_exceeded', 'UPSTREAM_BUSY'], ['invalid_api_key', 'CONFIG_INVALID'], + ['provider_unavailable', 'UPSTREAM_BUSY'], ['task_cancelled', 'AI_ASSISTANT_CANCELLED'], + ['file_download_failed', 'REQUIRED_FILES_REJECTED']] as [$code, $expected]) { + $reset([['http' => 200, 'error' => ['code' => $code, 'message' => $secret]]]); + $result = $call(true); + classificationExpect($result['error_code'] === $expected && count($classificationRequests) === 1, + 'SSE business failure retains its diagnosis without cross-protocol replay'); + } + $classificationConfig['base_url'] = 'https://synthetic.invalid/v1/chat-messages'; + $reset([['http' => 200, 'error' => ['code' => 'unknown_provider_error', 'message' => $secret]]]); + classificationExpect($call(true)['error_code'] === 'UPSTREAM_REJECTED', 'Unknown SSE errors do not implicate files'); + + $modeError = ['http' => 400, 'delay_us' => 3000, + 'error' => ['code' => 'invalid_param', 'message' => 'response_mode must be blocking. ' . $secret]]; + $prepared = ['profile' => 'qwen', 'task' => 'summary', 'query' => 'synthetic query', 'user' => 'synthetic-user', + 'files' => $files, 'inputs' => ['synthetic_field' => 'value'], 'required_sections' => $sections, 'upstream_timeout' => 5]; + $reset([$modeError, []]); + $answer = DiagnosisAiLogic::streamPreparedAssistant($prepared, static fn (): bool => true, null, + new AssistantSseTiming(), ['knowledge_mode' => 'evidence']); + classificationExpect(is_array($answer) && array_column($classificationRequests, 'mode') === ['streaming', 'blocking'] + && count($classificationRequests[1]['payload']['files']) === 2 + && $classificationRequests[0]['payload']['files'] === $classificationRequests[1]['payload']['files'] + && $classificationRequests[0]['payload']['inputs'] === $classificationRequests[1]['payload']['inputs'] + && $classificationRequests[1]['timeout'] <= 4, 'Actual assistant recovers response_mode with both files and the remaining shared budget'); + + // An emitted clinical delta prevents replay even if a later SSE error names inputs. + $reset([['http' => 200, 'body' => "data: {\"event\":\"message\",\"answer\":\"synthetic partial\"}\n\ndata: {\"event\":\"error\",\"code\":\"invalid_inputs\"}\n\n"]]); + $answer = DiagnosisAiLogic::streamPreparedAssistant($prepared, static fn (): bool => true, null, + new AssistantSseTiming(), ['knowledge_mode' => 'evidence']); + classificationExpect($answer === null && count($classificationRequests) === 1, 'No inputs or blocking replay after delivered text'); + + $reset([['http' => 400, 'cancel' => true, 'error' => ['code' => 'file_too_large']]]); + $result = DifyChatService::chat('qwen', [], 'synthetic query', 'synthetic-user', $files, + $options + ['should_abort' => static fn (): bool => $GLOBALS['classificationCancelled']]); + classificationExpect($result['error_code'] === 'AI_ASSISTANT_CANCELLED' && count($classificationRequests) === 1 + && $classificationClosed === 1, 'Cancellation closes transport and wins over apparent file errors'); + + // Optional-file and generic strict callers retain their existing distinction. + $reset([['http' => 400], []]); + $result = DifyChatService::chat('qwen', [], 'synthetic query', 'synthetic-user', $files, ['knowledge_mode' => 'evidence']); + classificationExpect(!empty($result['ok']) && !isset($classificationRequests[1]['payload']['files']), 'Legacy optional files keep the explicit text fallback'); + $reset([['http' => 400]]); + $result = DifyChatService::chat('qwen', [], 'synthetic query', 'synthetic-user', $files, ['knowledge_mode' => 'evidence', 'strict_files' => true]); + classificationExpect($result['error_code'] === 'UPSTREAM_REJECTED' && count($classificationRequests) === 1, 'Legacy strict never drops mandatory batch data'); + + $reset([['http' => 400, 'error' => ['code' => 'invalid_param', 'message' => 'Failed to download image ' . $secret]]]); + $transport = $private('sendStreamRequest', ['dify', 'https://synthetic.invalid/v1/chat-messages', ['response_mode' => 'streaming'], 'synthetic-only', 5, static fn (): bool => true, null]); + classificationExpect($transport['upstream_code'] === 'invalid_param' && $transport['upstream_reason'] === 'file_access' + && !isset($transport['body']) && !str_contains(json_encode($transport), $secret), 'Split HTTP error JSON retains only a short code and fixed reason'); + $reset([['http' => 400, 'error' => ['code' => 'file_too_large', 'message' => str_repeat($secret, 1500)]]]); + $transport = $private('sendStreamRequest', ['dify', 'https://synthetic.invalid/v1/chat-messages', ['response_mode' => 'streaming'], 'synthetic-only', 5, static fn (): bool => true, null]); + classificationExpect($transport['upstream_code'] === '' && $transport['upstream_reason'] === '' && !isset($transport['body']), 'Oversized error bodies are discarded instead of retained or partially decoded'); + classificationExpect($private('upstreamErrorMetadata', [['error' => ['code' => 'insufficient_quota', 'message' => $secret]]])['upstream_reason'] === 'quota', 'Nested OpenAI JSON errors use the same safe classification'); + $reset([['http' => 503]]); $classificationThrowLog = true; + classificationExpect($call(true)['error_code'] === 'UPSTREAM_BUSY', 'Logging failure cannot alter or throw away the upstream diagnosis'); + echo "Dify mandatory-file error classification / compatibility / final manifests / cancellation: OK\n"; +} diff --git a/server/tests/DifyRequiredSourcesLoopbackTest.php b/server/tests/DifyRequiredSourcesLoopbackTest.php index e50ca4415..6e180995d 100644 --- a/server/tests/DifyRequiredSourcesLoopbackTest.php +++ b/server/tests/DifyRequiredSourcesLoopbackTest.php @@ -40,7 +40,7 @@ namespace { $payload = json_decode($body, true); $stream = ($payload['response_mode'] ?? '') === 'streaming' || !empty($payload['stream']); $status = ($argv[2] ?? '') === 'reject' ? 413 : 200; - $response = $status === 413 ? '{"code":"invalid_param"}' + $response = $status === 413 ? '{"code":"invalid_param","message":"Number of files exceeds the limit."}' : ($stream ? "data: {\"event\":\"message\",\"answer\":\"synthetic-answer\"}\n\ndata: {\"event\":\"message_end\"}\n\n" : '{"answer":"synthetic-answer"}'); fwrite($client, 'HTTP/1.1 ' . $status . " Synthetic\r\nContent-Type: " . ($stream ? 'text/event-stream' : 'application/json') . "\r\nContent-Length: " . strlen($response) . "\r\nConnection: close\r\n\r\n" . $response); diff --git a/server/tests/DifyRequiredSourcesTransportTest.php b/server/tests/DifyRequiredSourcesTransportTest.php index a74246cb2..93ecd40b6 100644 --- a/server/tests/DifyRequiredSourcesTransportTest.php +++ b/server/tests/DifyRequiredSourcesTransportTest.php @@ -29,7 +29,16 @@ namespace app\common\service { if ($scenario === 'cancel') $GLOBALS['requiredCancelled'] = true; $progress = $handle->options[defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION] ?? null; if (is_callable($progress) && $progress($handle, 0, 0, 100, 100)) return false; - if ($handle->code !== 200) return $stream ? true : '{}'; + if ($handle->code !== 200) { + $errorBody = json_encode(['code' => $scenario === 'fallback' ? 'not_found' : 'invalid_param', + 'message' => $scenario === 'reject' ? 'Number of files exceeds the limit.' + : ($scenario === 'input_rejected' ? 'Unknown input variable: synthetic_undeclared' : 'Synthetic path not found')]); + if ($stream) { + ($handle->options[CURLOPT_WRITEFUNCTION])($handle, $errorBody); + return true; + } + return $errorBody; + } if (!$stream) return json_encode(['answer' => '合成回答']); $write = $handle->options[CURLOPT_WRITEFUNCTION]; if (str_ends_with($handle->url, '/chat-messages')) { diff --git a/server/tests/DifyRequiredUploadTransportTest.php b/server/tests/DifyRequiredUploadTransportTest.php new file mode 100644 index 000000000..8a672a9aa --- /dev/null +++ b/server/tests/DifyRequiredUploadTransportTest.php @@ -0,0 +1,87 @@ + false, 'error_code' => 'AI_ASSISTANT_CANCELLED', 'error' => '合成取消']; + if ($GLOBALS['uploadTransportScenario'] === 'upload-failed') return ['ok' => false, 'error_code' => 'REQUIRED_FILES_UPLOAD_FAILED', 'error' => '合成上传失败']; + $result = []; + foreach ($files as $index => $file) { + foreach (['uploading', 'uploaded'] as $status) { + if (is_callable($options['on_upload'] ?? null)) ($options['on_upload'])(['index' => $index + 1, 'total' => count($files), 'status' => $status]); + } + $result[] = ['type' => $file['type'], 'transfer_method' => 'local_file', 'upload_file_id' => 'fixture-upload-' . ($index + 1)]; + } + return ['ok' => true, 'files' => $result, 'attachment_transport' => 'uploaded_files']; + } + } + function curl_init(): \stdClass { return new \stdClass(); } + function curl_setopt_array(\stdClass $handle, array $options): bool { $handle->options = $options; return true; } + function curl_exec(\stdClass $handle): string|bool { + $payload = json_decode($handle->options[CURLOPT_POSTFIELDS], true); + $GLOBALS['uploadTransportRequests'][] = $payload; + $stream = ($payload['response_mode'] ?? '') === 'streaming'; + $handle->code = $GLOBALS['uploadTransportScenario'] === 'inputs' && !empty($payload['inputs']) ? 400 : 200; + ($handle->options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n"); + if ($handle->code !== 200) { + $body = json_encode(['code' => 'invalid_param', 'message' => 'synthetic_undeclared_input input variable invalid']); + if ($stream) { ($handle->options[CURLOPT_WRITEFUNCTION])($handle, $body); return true; } + return $body; + } + if (!$stream) return json_encode(['answer' => '合成回答']); + $write = $handle->options[CURLOPT_WRITEFUNCTION]; + $write($handle, 'data: ' . json_encode(['event' => 'message', 'answer' => '合成回答']) . "\n\n"); + $write($handle, "data: {\"event\":\"message_end\"}\n\n"); + return true; + } + function curl_errno(\stdClass $handle): int { return 0; } + function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code; } + function curl_close(\stdClass $handle): void {} +} +namespace { + require dirname(__DIR__) . '/vendor/autoload.php'; + use app\common\service\DifyChatService; + use app\adminapi\service\AiRequiredSource; + function uploadTransportExpect(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); } + $uploadTransportConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages', + 'timeout' => 5, 'required_attachment_transport' => 'upload', + 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'fixture-only']]]; + $files = [['type' => 'image', 'url' => 'https://synthetic.invalid/one.png'], + ['type' => 'image', 'url' => 'https://synthetic.invalid/two.png'], + ['type' => 'document', 'url' => 'https://synthetic.invalid/three.pdf']]; + $options = ['required_files' => true, 'knowledge_mode' => 'evidence', 'required_sections' => AiRequiredSource::manifest([])]; + foreach ([false, true] as $stream) foreach (['success', 'inputs', 'upload-failed', 'cancel'] as $scenario) { + $uploadTransportScenario = $scenario; $uploadTransportStages = []; $uploadTransportRequests = []; $events = []; + $opts = $options + ['on_progress' => static function (array $event) use (&$events): void { $events[] = $event; }]; + $result = $stream ? DifyChatService::streamChat('qwen', ['synthetic_undeclared_input' => 'value'], '合成资料', 'fixture-same-user', + static fn (string $text): bool => true, null, $files, $opts) + : DifyChatService::chat('qwen', ['synthetic_undeclared_input' => 'value'], '合成资料', 'fixture-same-user', $files, $opts); + uploadTransportExpect(count($uploadTransportStages) === 1, 'all required files are staged once per request'); + uploadTransportExpect($uploadTransportStages[0]['user'] === 'fixture-same-user' && count($uploadTransportStages[0]['files']) === 3, + 'upload and chat share end user and complete source set'); + if (in_array($scenario, ['upload-failed', 'cancel'], true)) { + uploadTransportExpect(empty($result['ok']) && $uploadTransportRequests === [], 'failed or cancelled upload never starts model generation'); + uploadTransportExpect($result['error_code'] === ($scenario === 'cancel' ? 'AI_ASSISTANT_CANCELLED' : 'REQUIRED_FILES_UPLOAD_FAILED'), + 'upload failure retains actual cause'); + continue; + } + uploadTransportExpect(!empty($result['ok']) && $result['attachment_transfer'] === 'uploaded_files', 'final receipt identifies actual uploads'); + uploadTransportExpect(count($uploadTransportRequests) === ($scenario === 'inputs' ? 2 : 1), 'bounded input compatibility remains intact'); + foreach ($uploadTransportRequests as $payload) { + uploadTransportExpect($payload['user'] === 'fixture-same-user' && count($payload['files']) === 3, 'every generation attempt retains all uploaded files'); + uploadTransportExpect(array_column($payload['files'], 'transfer_method') === ['local_file', 'local_file', 'local_file'], 'Dify gets upload IDs, not resource URLs'); + uploadTransportExpect(!array_key_exists('url', $payload['files'][0]) && $payload['files'][0]['upload_file_id'] === 'fixture-upload-1', 'original URL never leaks into uploaded wire files'); + } + uploadTransportExpect(count(array_filter($events, static fn (array $event): bool => ($event['status'] ?? '') === 'uploading')) >= 3, + 'safe per-file upload progress reaches the desktop'); + } + echo "Dify required upload transport / complete IDs / same user / input retry reuse / failure / cancel: OK\n"; +} diff --git a/server/tests/PrescriptionAiUpstreamContractTest.php b/server/tests/PrescriptionAiUpstreamContractTest.php index e9a306e85..b88774c16 100644 --- a/server/tests/PrescriptionAiUpstreamContractTest.php +++ b/server/tests/PrescriptionAiUpstreamContractTest.php @@ -288,11 +288,11 @@ $openAiInputSpec = ['protocol' => 'openai']; expectSame( true, callPrivate('isInputRejection', [ - ['errno' => 0, 'http_code' => 400, 'body' => '{"code":"invalid_param"}'], + ['errno' => 0, 'http_code' => 400, 'body' => '{"code":"invalid_param","message":"Unknown input variable: prompt_version"}'], $difyInputSpec, ['prompt_version' => 'v2'], ]), - 'Dify invalid_param retries with query-only input' + 'an explicit undeclared Dify input retries with query-only input' ); expectSame( false, @@ -329,12 +329,13 @@ expectSame( 'http_code' => 200, 'upstream_error' => true, 'upstream_code' => 'invalid_param', + 'upstream_reason' => 'inputs_invalid', 'emitted' => false, ], $difyInputSpec, ['prompt_version' => 'v2'], ]), - 'a streaming invalid_param before any delta also retries without inputs' + 'an explicit streaming inputs error before any delta retries without inputs' ); expectSame( false, @@ -344,6 +345,7 @@ expectSame( 'http_code' => 200, 'upstream_error' => true, 'upstream_code' => 'invalid_param', + 'upstream_reason' => 'inputs_invalid', 'emitted' => true, ], $difyInputSpec, @@ -358,8 +360,8 @@ expectSame(false, callPrivate('shouldRetryWithoutFiles', [400, []]), 'a text-onl expectSame(false, callPrivate('shouldRetryWithoutFiles', [401, $capped['kept']]), 'a credential failure is not retried'); expectSame(false, callPrivate('shouldRetryWithoutFiles', [500, $capped['kept']]), 'an upstream outage is not retried here'); -// 协议回退会把最初的“附件被拒”换成另一协议的状态码(Dify 400 -> OpenAI 404), -// 降级判断必须按每次响应累计,否则去掉附件的重试永远不会发生。 +// Optional legacy files retain their historic text fallback; required clinical +// files use the affirmative-evidence flag tested by DifyRequiredFilesRejectionClassificationTest. expectSame( true, callPrivate('isFileRejection', [['errno' => 0, 'http_code' => 400], $capped['kept']]),