From 86476920cfed36ed120498f6b9fd89b0c0ce096a Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 23 Oct 2026 11:17:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/dialogs/ai_consult.py | 62 +++++-- .../test_ai_required_sources_status_ui.py | 112 ++++++++++++ .../adminapi/logic/tcm/DiagnosisAiLogic.php | 10 +- server/app/common/service/DifyChatService.php | 145 +++++++++++++--- .../service/DifyRequiredAttachmentUpload.php | 136 +++++++++++---- server/config/prescription_ai.php | 7 +- .../DiagnosisAiAssistantErrorReceiptTest.php | 23 +++ .../DifyMissingAttachmentFallbackTest.php | 94 ++++++++++ .../DifyRequiredAttachmentUploadTest.php | 164 +++++++++++++++++- .../tests/DifyRequiredUploadTransportTest.php | 50 +++++- 10 files changed, 709 insertions(+), 94 deletions(-) create mode 100644 server/tests/DifyMissingAttachmentFallbackTest.php diff --git a/app/src/doctor_workstation/ui/dialogs/ai_consult.py b/app/src/doctor_workstation/ui/dialogs/ai_consult.py index a1f0b213e..1718e5d0c 100644 --- a/app/src/doctor_workstation/ui/dialogs/ai_consult.py +++ b/app/src/doctor_workstation/ui/dialogs/ai_consult.py @@ -3464,7 +3464,10 @@ def _merge_stream_meta(meta: dict[str, Any], payload: Mapping[str, Any]) -> None failure receipt must still correct an earlier reference-sent event. """ - live_fields = ("items", "attachment_count", "attachment_transfer", "required_sections") + live_fields = ( + "items", "attachment_count", "submitted_attachment_count", "skipped_attachment_count", + "attachment_transfer", "required_sections", + ) event = str(payload.get("event") or "").lower() terminal = event in {"done", "error"} observed = meta.get("_transfer_progress_observed") is True @@ -3482,7 +3485,7 @@ def _merge_stream_meta(meta: dict[str, Any], payload: Mapping[str, Any]) -> None 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. + # items without inventing sources or changing omitted/skipped files. for key in ("items", "required_sections"): entries = update.get(key) if isinstance(entries, list) and ( @@ -3498,18 +3501,42 @@ def _merge_stream_meta(meta: dict[str, Any], payload: Mapping[str, Any]) -> None } else item for item in previous ] + previous_items, items = meta.get("items"), update.get("items") + if isinstance(previous_items, list) and isinstance(items, list): + # A later bulk upload/submission result cannot submit a file that was + # already confirmed missing. Retain its original source and skip reason. + items = list(items) + for index, previous in enumerate(previous_items): + if not isinstance(previous, Mapping) or previous.get("status") != "skipped": + continue + match = next(( + position for position, item in enumerate(items) + if isinstance(item, Mapping) + and (item.get("kind"), item.get("label")) + == (previous.get("kind"), previous.get("label")) + ), None) + if match is None: + items.insert(min(index, len(items)), dict(previous)) + else: + items[match] = {**items[match], **previous} + update["items"] = items 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"} + isinstance(item, Mapping) and item.get("status") in { + "submitted", "failed", "omitted", "skipped", + } 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")) + error_receipt_fields.update(( + "attachment_count", "submitted_attachment_count", "skipped_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: @@ -3589,6 +3616,7 @@ def _transfer_description(meta: Mapping[str, Any], state: str) -> str: statuses = { "prepared": "等待上传", "uploading": "上传中", "uploaded": "已上传,待提交", "submitting": "提交中", "submitted": "引用已提交", "omitted": "未提交", "failed": "提交失败", + "skipped": "已跳过", } if meta.get("attachment_transfer") == "uploaded_files": statuses["submitted"] = "已上传并提交" @@ -3600,13 +3628,21 @@ def _transfer_description(meta: Mapping[str, Any], state: str) -> str: label = str(item.get("label") or "") if not re.fullmatch(r"(?:图片附件|文档附件|文件附件|音频附件|自定义附件|附件|检查报告|舌象)\s*\d+", label): label = f"{kind}附件{index}" - lines.append(f"{label}({kind}):{statuses.get(str(item.get('status')), '等待提交')}") - count = meta.get("attachment_count") - if isinstance(items, list) and any( + status = statuses.get(str(item.get("status")), "等待提交") + if item.get("status") == "skipped" and item.get("reason") == "not_found": + status = "文件不存在,已跳过" + lines.append(f"{label}({kind}):{status}") + submitted_count = meta.get("submitted_attachment_count") + valid_submitted_count = type(submitted_count) is int and submitted_count >= 0 + count = submitted_count if valid_submitted_count else meta.get("attachment_count") + has_video = isinstance(items, list) and any( isinstance(item, Mapping) and _is_video_transfer_item(item) for item in items - ): - count = sum(item.get("status") != "omitted" for item in visible_items) + ) + if has_video or (not valid_submitted_count and any( + item.get("status") == "skipped" for item in visible_items + )): + count = sum(item.get("status") not in {"omitted", "skipped"} for item in visible_items) if type(count) is int and count >= 0: lines.append(f"本请求附件通道:{count} 项;模型实际读取进度不可获取。") return "\n".join(lines) @@ -6642,12 +6678,14 @@ class AiConsultDialog(QDialog): 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") + items = self._stream_meta.get("items") visible = [item for item in items if isinstance(item, Mapping) and not _is_video_transfer_item(item)] if isinstance(items, list) else [] - if visible and all(item.get("status") == "omitted" for item in visible): + if visible and all(item.get("status") == "skipped" for item in visible): + state = "资料正文已提交;不存在的附件已跳过,等待 AI 正文…" + elif visible and all(item.get("status") in {"omitted", "skipped"} for item in visible): state = "资料正文已提交;本次附件未提交,等待 AI 正文…" - elif payload.get("attachment_count") == 0: + elif self._stream_meta.get("submitted_attachment_count") == 0 or self._stream_meta.get("attachment_count") == 0: state = "资料正文已提交,等待 AI 正文…" else: state = "附件引用已提交,等待模型处理…" diff --git a/app/tests/test_ai_required_sources_status_ui.py b/app/tests/test_ai_required_sources_status_ui.py index e5ef41d9c..77efb0b73 100644 --- a/app/tests/test_ai_required_sources_status_ui.py +++ b/app/tests/test_ai_required_sources_status_ui.py @@ -345,3 +345,115 @@ def test_failure_with_no_manifest_does_not_invent_sources_or_file_items(dialog): text = dialog._stream_bubble.transfer_status.text() assert "资料栏目清单:服务器尚未回传。" in text assert "图片附件" not in text and "无(提交失败)" not in text + + +def missing_image(): + return {"kind": "image", "label": "图片附件1", "status": "skipped", "reason": "not_found"} + + +def test_missing_file_stays_skipped_through_bulk_upload_submission_and_done(dialog): + available = {"kind": "document", "label": "文档附件1", "status": "uploading"} + emit(dialog, {"event": "progress", "phase": "attachments", "status": "uploading", + "items": [missing_image(), available], "attachment_count": 2, + "submitted_attachment_count": 0, "skipped_attachment_count": 1, + "attachment_transfer": "uploaded_files", "required_sections": sections("prepared")}) + assert "图片附件1(图片):文件不存在,已跳过" in dialog._stream_bubble.transfer_status.text() + assert "文档附件1(文档):上传中" in dialog._stream_bubble.transfer_status.text() + # Older bulk status transitions may still apply a uniform status to sources. + bulk_items = [{**missing_image(), "status": "submitted"}, + {**available, "status": "submitted"}] + emit(dialog, {"event": "progress", "phase": "attachments", "status": "submitted", + "items": bulk_items, "attachment_count": 2, + "submitted_attachment_count": 1, "skipped_attachment_count": 1, + "required_sections": sections()}) + emit(dialog, {"event": "done", "answer": "合成可用资料回复", "transfer_manifest_final": True, + "items": bulk_items, "attachment_count": 2, + "submitted_attachment_count": 1, "skipped_attachment_count": 1, + "required_sections": sections()}) + text = dialog._stream_bubble.transfer_status.text() + assert "图片附件1(图片):文件不存在,已跳过" in text + assert "文档附件1(文档):已上传并提交" in text + assert "本请求附件通道:1 项" in text + assert all(f"{label}:" in text for _, label in REQUIRED) + assert "提交失败" not in text and "状态未回报" not in text + assert dialog._stream_meta["items"][0] == missing_image() + assert dialog._stream_meta["attachment_count"] == 2 + assert dialog._stream_meta["required_sections"] == sections() + assert dialog._stream_bubble._raw_payload == "合成可用资料回复" + assert dialog._stream_completed and dialog.send_button.isEnabled() + + +def test_all_missing_files_skip_upload_and_still_complete_text_reply(dialog): + items = [missing_image(), {"kind": "document", "label": "文档附件1", + "status": "skipped", "reason": "not_found"}] + emit(dialog, {"event": "progress", "phase": "attachments", "status": "submitted", + "items": items, "attachment_count": 2, "submitted_attachment_count": 0, + "skipped_attachment_count": 2, "required_sections": sections()}) + text = dialog._stream_bubble.transfer_status.text() + assert "资料正文已提交;不存在的附件已跳过,等待 AI 正文" in text + assert "本次附件未提交" not in text and "附件引用已提交" not in text + assert "本请求附件通道:0 项" in text + emit(dialog, {"event": "delta", "text": "合成正文"}) + emit(dialog, {"event": "done", "items": [], "attachment_count": 0, + "submitted_attachment_count": 2, "skipped_attachment_count": 0, + "required_sections": sections("prepared")}) + text = dialog._stream_bubble.transfer_status.text() + assert text.startswith("本次回复已完成。") + assert text.count("文件不存在,已跳过") == 2 + assert "本请求附件通道:0 项" in text + assert "提交失败" not in text + assert dialog._stream_meta["items"] == items + assert dialog._stream_meta["attachment_count"] == 2 + assert dialog._stream_meta["submitted_attachment_count"] == 0 + assert dialog._stream_meta["skipped_attachment_count"] == 2 + assert dialog._stream_meta["required_sections"] == sections() + assert dialog._stream_bubble._raw_payload == "合成正文" + assert dialog._stream_completed and dialog.send_button.isEnabled() + + +@pytest.mark.parametrize("manifest", [None, {}, {"items": [], "required_sections": []}]) +def test_terminal_submission_failure_never_converts_missing_file_to_failed(dialog, manifest): + items = [missing_image(), {"kind": "document", "label": "文档附件1", "status": "submitted"}] + emit(dialog, {"event": "progress", "phase": "attachments", "status": "submitted", + "items": items, "attachment_count": 2, "submitted_attachment_count": 1, + "skipped_attachment_count": 1, "required_sections": sections()}) + receipt = {"code": "REQUIRED_FILES_REJECTED"} + if manifest is None: + receipt.update({"transfer_manifest_final": True, + "items": [{**item, "status": "failed"} for item in items], + "attachment_count": 2, "submitted_attachment_count": 0, + "skipped_attachment_count": 1, "required_sections": sections("failed")}) + else: + receipt.update(manifest) + fail(dialog, receipt) + text = dialog._stream_bubble.transfer_status.text() + assert "图片附件1(图片):文件不存在,已跳过" in text + assert "文档附件1(文档):提交失败" in text + assert "基本信息:无(提交失败)" in text + assert dialog._stream_meta["items"][0] == missing_image() + assert not dialog._stream_completed and dialog.send_button.isEnabled() + + +def test_missing_file_in_error_receipt_is_not_hidden_by_prior_preparation(dialog): + emit(dialog, {"event": "progress", "phase": "attachments", "status": "uploading", + "items": [{**missing_image(), "status": "uploading"}], + "attachment_count": 1, "submitted_attachment_count": 1, + "skipped_attachment_count": 0, "required_sections": sections("prepared")}) + fail(dialog, {"code": "UPSTREAM_STREAM_INTERRUPTED", "items": [missing_image()], + "attachment_count": 1, "submitted_attachment_count": 0, + "skipped_attachment_count": 1, "required_sections": sections()}) + text = dialog._stream_bubble.transfer_status.text() + assert "图片附件1(图片):文件不存在,已跳过" in text + assert "本请求附件通道:0 项" in text + assert "生命体征:已提交" in text + assert dialog._stream_meta["submitted_attachment_count"] == 0 + assert dialog._stream_meta["skipped_attachment_count"] == 1 + + +@pytest.mark.parametrize("submitted_count", [None, True, -1, "0", 1.5]) +def test_skipped_file_count_fallback_excludes_missing_files(submitted_count): + text = module._transfer_description({ + "items": [missing_image(), {"kind": "document", "label": "文档附件1", "status": "submitted"}], + "attachment_count": 2, "submitted_attachment_count": submitted_count, + }, "合成状态") + assert "本请求附件通道:1 项" in text diff --git a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php index 5ac1fae35..183b8f8d4 100644 --- a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php +++ b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php @@ -739,7 +739,7 @@ class DiagnosisAiLogic extends BaseLogic : [], ]; if (($result['transfer_manifest_final'] ?? false) === true) { - foreach (['required_sections', 'items', 'attachment_count', 'attachment_transfer', 'transfer_manifest_final'] as $field) { + foreach (['required_sections', 'items', 'attachment_count', 'submitted_attachment_count', 'skipped_attachment_count', 'attachment_transfer', 'transfer_manifest_final'] as $field) { if (array_key_exists($field, $result)) $payload[$field] = $result[$field]; } } @@ -792,12 +792,18 @@ class DiagnosisAiLogic extends BaseLogic $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; + || !in_array($status, ['prepared', 'uploading', 'uploaded', 'submitting', 'submitted', 'failed', 'omitted', 'skipped'], true)) continue; $counts[$kind] = ($counts[$kind] ?? 0) + 1; $meta['items'][] = ['kind' => $kind, 'label' => $labels[$kind] . $counts[$kind], 'status' => $status]; + if ($status === 'skipped' && ($item['reason'] ?? '') === 'not_found') { + $meta['items'][array_key_last($meta['items'])]['reason'] = 'not_found'; + } } } if (is_int($result['attachment_count'] ?? null)) $meta['attachment_count'] = max(0, $result['attachment_count']); + foreach (['submitted_attachment_count', 'skipped_attachment_count'] as $field) { + if (is_int($result[$field] ?? null)) $meta[$field] = max(0, $result[$field]); + } if (in_array($result['attachment_transfer'] ?? '', ['remote_url_references', 'uploaded_files'], true)) { $meta['attachment_transfer'] = $result['attachment_transfer']; } diff --git a/server/app/common/service/DifyChatService.php b/server/app/common/service/DifyChatService.php index d2378789f..5dc5088a2 100644 --- a/server/app/common/service/DifyChatService.php +++ b/server/app/common/service/DifyChatService.php @@ -157,8 +157,8 @@ class DifyChatService 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', '必需资料提交失败:当前模型接口不支持原始文档等附件', $attempt['files'], $requestSpec['protocol']); + if ($strictFiles && !self::strictProtocolSupportsFiles($requestSpec['protocol'], self::availableSourceFiles($attempt['files'], $uploadCache))) { + if ($requiredFiles) return self::requiredFilesFailure($lastTransferOptions, 'REQUIRED_FILES_UNSUPPORTED', '必需资料提交失败:当前模型接口不支持原始文档等附件', $attempt['files'], $requestSpec['protocol']); return self::error('FILE_TYPE_UNSUPPORTED', '当前模型接口不支持此类原始附件'); } $elapsedSeconds = (int) floor(microtime(true) - $startedAt); @@ -172,7 +172,7 @@ class DifyChatService } $staged = self::prepareRequiredUpload($requestSpec, $attempt['files'], $user, $apiKey, - $remainingTimeout, $shouldAbort, $options, $config, $uploadCache); + $remainingTimeout, $shouldAbort, $options, $config, $uploadCache, $query); if (empty($staged['ok'])) return $staged; $requestSpec = $staged['request_spec']; $lastTransferOptions = $staged['options']; @@ -217,7 +217,7 @@ class DifyChatService if (!empty($formatted['ok'])) { $formatted['ancient_book_sources'] = $ancientBookSources; if ($strictFiles) { - $formatted['transmitted_file_count'] = count($attempt['files']); + $formatted['transmitted_file_count'] = count(self::availableSourceFiles($attempt['files'], $uploadCache)); $formatted['attachment_transport'] = (string) ($lastSpec['protocol'] ?? ''); } if ($requiredFiles) $formatted += self::finalTransferManifest($lastTransferOptions, $attempt['files'], (string) ($lastSpec['protocol'] ?? '')); @@ -346,8 +346,8 @@ class DifyChatService 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', '必需资料提交失败:当前模型接口不支持原始文档等附件', $attempt['files'], $requestSpec['protocol']); + if ($strictFiles && !self::strictProtocolSupportsFiles($requestSpec['protocol'], self::availableSourceFiles($attempt['files'], $uploadCache))) { + if ($requiredFiles) return self::requiredFilesFailure($lastTransferOptions, 'REQUIRED_FILES_UNSUPPORTED', '必需资料提交失败:当前模型接口不支持原始文档等附件', $attempt['files'], $requestSpec['protocol']); return self::error('FILE_TYPE_UNSUPPORTED', '当前模型接口不支持此类原始附件'); } $elapsedSeconds = (int) floor(microtime(true) - $startedAt); @@ -361,7 +361,7 @@ class DifyChatService } $staged = self::prepareRequiredUpload($requestSpec, $attempt['files'], $user, $apiKey, - $remainingTimeout, $shouldAbort, $options, $config, $uploadCache); + $remainingTimeout, $shouldAbort, $options, $config, $uploadCache, $query); if (empty($staged['ok'])) return $staged; $requestSpec = $staged['request_spec']; $lastTransferOptions = $staged['options']; @@ -410,7 +410,7 @@ class DifyChatService if (!empty($formatted['ok'])) { $formatted['ancient_book_sources'] = $ancientBookSources; if ($strictFiles) { - $formatted['transmitted_file_count'] = count($attempt['files']); + $formatted['transmitted_file_count'] = count(self::availableSourceFiles($attempt['files'], $uploadCache)); $formatted['attachment_transport'] = (string) ($lastSpec['protocol'] ?? ''); } if ($requiredFiles) $formatted += self::finalTransferManifest($lastTransferOptions, $attempt['files'], (string) ($lastSpec['protocol'] ?? '')); @@ -451,14 +451,29 @@ class DifyChatService && ($body['result'] ?? '') === 'success'; } - /** Stage each mandatory Dify file once; a failed upload cannot start generation. */ + /** Upload available files once; confirmed missing sources never block the clinical text. */ private static function prepareRequiredUpload(array $spec, array $files, string $user, string $apiKey, - int $timeout, ?callable $shouldAbort, array $options, array $config, array &$cache): array + int $timeout, ?callable $shouldAbort, array $options, array $config, array &$cache, string $sourceQuery): array { - if (empty($options['required_files']) || $files === [] || ($spec['protocol'] ?? '') !== 'dify' + if (empty($options['required_files']) || $files === [] || ($config['required_attachment_transport'] ?? '') !== 'upload') { return ['ok' => true, 'request_spec' => $spec, 'options' => $options]; } + $sourceKey = 'sources:' . hash('sha256', json_encode($files)); + if (($spec['protocol'] ?? '') !== 'dify') { + if (isset($cache[$sourceKey])) { + $receipt = $cache[$sourceKey]; + $options['attachment_items'] = $receipt['items']; + // Rebuild transport-only content from the original clinical query + // and available sources; never splice URLs out of clinical text. + $available = self::availableSourceFiles($files, $cache); + $spec['payload']['messages'] = [['role' => 'user', 'content' => self::buildOpenAiContent( + self::withAttachmentManifest($sourceQuery, self::nonImageFiles($available)), $available + )]]; + $spec = self::addMissingAttachmentNotice($spec, $receipt['items']); + } + 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])) { @@ -484,8 +499,11 @@ class DifyChatService $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; + if (!in_array($status, ['uploading', 'uploaded', 'failed', 'skipped'], true)) return; $items[$index - 1]['status'] = $status; + if ($status === 'skipped' && ($event['reason'] ?? '') === 'not_found') { + $items[$index - 1]['reason'] = 'not_found'; + } $sections = self::requiredSections($options, 'prepared'); $summary = is_array($options['source_summary'] ?? null) ? $options['source_summary'] : []; $summary['required_sections'] = $sections; @@ -496,11 +514,13 @@ class DifyChatService 'source_form' => $options['source_form'] ?? 'full_context', 'evidence_cache_hit' => !empty($options['evidence_cache_hit']), 'attachment_transfer' => 'uploaded_files', + 'skipped_attachment_count' => count(array_filter($items, static fn (array $item): bool => $item['status'] === 'skipped')), ]); }; $started = microtime(true); $uploaded = DifyRequiredAttachmentUpload::prepare($spec['url'], $apiKey, $user, $files, $timeout, $shouldAbort, $uploadOptions); + $options['attachment_items'] = $items; if (empty($uploaded['ok'])) { if (($uploaded['error_code'] ?? '') === 'AI_ASSISTANT_CANCELLED') { return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消', self::elapsedMilliseconds($started)); @@ -508,15 +528,76 @@ class DifyChatService 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)) { + $receipt = self::uploadedAttachmentReceipt($uploaded, $files); + if ($receipt === null) { return self::requiredFilesFailure($options, 'REQUIRED_FILES_UPLOAD_FAILED', '必需附件上传不完整,本次未提交分析', $files); } - $cache[$cacheKey] = $uploaded['files']; + $cache[$cacheKey] = $receipt; + $cache[$sourceKey] = $receipt; } - $spec['payload']['files'] = $cache[$cacheKey]; + $options['attachment_items'] = $cache[$cacheKey]['items']; + $spec['payload']['files'] = $cache[$cacheKey]['files']; + $spec = self::addMissingAttachmentNotice($spec, $cache[$cacheKey]['items']); return ['ok' => true, 'request_spec' => $spec, 'options' => $options]; } + /** Require an explicit, disjoint result for every original source; no silent omission. */ + private static function uploadedAttachmentReceipt(array $uploaded, array $files): ?array + { + if (!is_array($uploaded['files'] ?? null) || !array_is_list($uploaded['files'])) return null; + $skipped = $uploaded['skipped'] ?? []; + $indices = $uploaded['uploaded_indices'] ?? (count($uploaded['files']) === count($files) + ? ($files === [] ? [] : range(1, count($files))) : null); + if (!is_array($skipped) || !array_is_list($skipped) || !is_array($indices) + || !array_is_list($indices) || count($indices) !== count($uploaded['files'])) return null; + $items = self::attachmentItems($files, [], 'dify'); + $seen = []; + foreach ($skipped as $skip) { + $index = $skip['index'] ?? null; + if (!is_int($index) || $index < 1 || $index > count($files) || isset($seen[$index]) + || ($skip['reason'] ?? '') !== 'not_found' + || ($skip['kind'] ?? '') !== $files[$index - 1]['type']) return null; + $seen[$index] = true; + $items[$index - 1]['status'] = 'skipped'; + $items[$index - 1]['reason'] = 'not_found'; + } + $previous = 0; + foreach ($indices as $position => $index) { + $wire = $uploaded['files'][$position]; + if (!is_int($index) || $index <= $previous || $index > count($files) || isset($seen[$index]) + || ($wire['type'] ?? '') !== $files[$index - 1]['type'] + || ($wire['transfer_method'] ?? '') !== 'local_file' + || !is_string($wire['upload_file_id'] ?? null) || trim($wire['upload_file_id']) === '') return null; + $seen[$index] = true; + $previous = $index; + $items[$index - 1]['status'] = 'uploaded'; + } + if (count($seen) !== count($files)) return null; + return ['files' => $uploaded['files'], 'items' => $items, 'indices' => $indices, 'skipped' => $skipped]; + } + + private static function availableSourceFiles(array $files, array $cache): array + { + $receipt = $cache['sources:' . hash('sha256', json_encode($files))] ?? null; + return is_array($receipt) ? array_map(static fn (int $index): array => $files[$index - 1], $receipt['indices']) : $files; + } + + /** A trusted transport notice tells the model exactly which references were unavailable. */ + private static function addMissingAttachmentNotice(array $spec, array $items): array + { + $missing = array_values(array_filter($items, static fn (array $item): bool => ($item['status'] ?? '') === 'skipped')); + if ($missing === []) return $spec; + $notice = "\n\n【附件可用性说明】\n" . implode(';', array_map( + static fn (array $item): string => $item['label'] . '源文件不存在,已跳过,未提供给模型', $missing)) + . '。请继续依据已提供的病例正文及其余可用附件回答,并明确资料缺口;不得声称已查看这些缺失附件。'; + if (isset($spec['payload']['query'])) { + $spec['payload']['query'] .= $notice; + } else { + $spec['payload']['messages'][] = ['role' => 'user', 'content' => $notice]; + } + return $spec; + } + /** Mandatory clinical files have no lossy normalization or text-only fallback. */ private static function requiredFiles(array $files): array { @@ -577,12 +658,11 @@ class DifyChatService $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); + $items = self::transferManifestItems($options, $labels, $protocol, 'failed'); $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]; + 'attachment_transfer' => $options['attachment_transfer'] ?? 'remote_url_references', 'transfer_manifest_final' => true] + + self::attachmentOutcomeCounts($items); if (is_callable($options['on_progress'] ?? null)) ($options['on_progress'])([ 'phase' => 'attachments', 'status' => 'failed', 'message' => $message, ] + $manifest); @@ -594,12 +674,27 @@ class DifyChatService $sections = self::requiredSections($options, 'submitted'); $summary = is_array($options['source_summary'] ?? null) ? $options['source_summary'] : []; $summary['required_sections'] = $sections; - $items = self::attachmentItems($files, [], $protocol); - foreach ($items as &$item) $item['status'] = 'submitted'; - unset($item); + $items = self::transferManifestItems($options, $files, $protocol, 'submitted'); return ['required_sections' => $sections, 'source_summary' => $summary, 'items' => $items, 'attachment_count' => count($files), - 'attachment_transfer' => $options['attachment_transfer'] ?? 'remote_url_references', 'transfer_manifest_final' => true]; + 'attachment_transfer' => $options['attachment_transfer'] ?? 'remote_url_references', 'transfer_manifest_final' => true] + + self::attachmentOutcomeCounts($items); + } + + private static function transferManifestItems(array $options, array $files, string $protocol, string $status): array + { + $items = is_array($options['attachment_items'] ?? null) ? $options['attachment_items'] : self::attachmentItems($files, [], $protocol); + foreach ($items as &$item) { + if (!in_array($item['status'] ?? '', ['skipped', 'omitted'], true)) $item['status'] = $status; + } + unset($item); + return $items; + } + + private static function attachmentOutcomeCounts(array $items): array + { + return ['submitted_attachment_count' => count(array_filter($items, static fn (array $item): bool => ($item['status'] ?? '') === 'submitted')), + 'skipped_attachment_count' => count(array_filter($items, static fn (array $item): bool => ($item['status'] ?? '') === 'skipped'))]; } /** Safe labels only; submitted means request references sent, never remote file reading. */ @@ -1187,7 +1282,7 @@ class DifyChatService if (!is_callable($options['on_progress'] ?? null)) return; $items = $options['attachment_items'] ?? []; foreach ($items as &$item) { - if ($item['status'] !== 'omitted') $item['status'] = $status; + if (!in_array($item['status'], ['omitted', 'skipped'], true)) $item['status'] = $status; } unset($item); $sections = self::requiredSections($options, $status); @@ -1202,7 +1297,7 @@ class DifyChatService 'evidence_cache_hit' => !empty($options['evidence_cache_hit']), 'attachment_transfer' => $options['attachment_transfer'] ?? 'remote_url_references', 'attachment_count' => count(array_filter($items, static fn (array $item): bool => $item['status'] !== 'omitted')), - 'items' => $items]); + 'items' => $items] + self::attachmentOutcomeCounts($items)); if ($status === 'submitted') ($options['on_progress'])([ 'phase' => 'upstream', 'status' => 'waiting', 'message' => '资料请求已提交,正在等待模型正文', ]); diff --git a/server/app/common/service/DifyRequiredAttachmentUpload.php b/server/app/common/service/DifyRequiredAttachmentUpload.php index 5a350ffe8..ce575eac1 100644 --- a/server/app/common/service/DifyRequiredAttachmentUpload.php +++ b/server/app/common/service/DifyRequiredAttachmentUpload.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace app\common\service; /** - * Turn an already-authorized mandatory attachment list into Dify local_file references. + * Turn an already-authorized attachment list into Dify local_file references. * * Trust options are SERVER configuration, never request parameters. Local origins are * exact scheme/host/port aliases of this application's public/uploads directory; @@ -20,12 +20,17 @@ final class DifyRequiredAttachmentUpload /** * Options: local_origin/local_origins, storage_origins, public_root, temp_root, - * max_file_bytes, on_upload(array{index:int,total:int,status:string}), resolved_hosts + * max_file_bytes, ca_file (optional trusted CA bundle), + * on_upload(array{index:int,total:int,status:string,reason?:string}), resolved_hosts * (exact hostname => nonempty public IP list). Pins take precedence over the optional * server-owned resolve_host callable, then native DNS for unpinned hosts only. * No file ID cache survives this call, including between different Dify users. + * skip_missing defaults to true: only confirmed absent local files or completed + * HTTP 404/410 downloads are skipped. Other source/transport errors still fail. + * Successful files and uploaded_indices have matching order; skipped metadata + * contains only the original one-based input index, attachment kind and reason. * - * @return array{ok:bool,files?:array,error_code?:string,error?:string} + * @return array{ok:bool,files?:array,uploaded_indices?:array,skipped?:array,error_code?:string,error?:string} */ public static function prepare( string $chatEndpoint, @@ -37,6 +42,7 @@ final class DifyRequiredAttachmentUpload array $options = [] ): array { $temporary = []; + $skipped = []; $index = 0; $total = 0; $deadline = microtime(true) + max(0, $timeout); @@ -44,8 +50,8 @@ final class DifyRequiredAttachmentUpload self::check($deadline, $shouldAbort); $options = self::options($options); $plan = self::plan($files); - $total = count($plan); - if ($plan === []) return ['ok' => true, 'files' => [], 'attachment_transport' => 'uploaded_files']; + $total = count($files); + if ($plan === []) return ['ok' => true, 'files' => [], 'uploaded_indices' => [], 'skipped' => [], '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'); @@ -63,6 +69,7 @@ final class DifyRequiredAttachmentUpload // Validate the whole manifest before the first upload. Never truncate it. foreach ($plan as &$item) { + self::check($deadline, $shouldAbort); $origin = self::origin($item['parts']); if (isset($localOrigins[$origin])) { $item['path'] = self::localPath($publicRoot, $item['parts']['path'] ?? '/'); @@ -72,39 +79,58 @@ final class DifyRequiredAttachmentUpload } unset($item); $prepared = []; + $uploadedIndices = []; $cache = []; - foreach ($plan as $position => $item) { - $index = $position + 1; + foreach ($plan as $item) { + $index = $item['index']; self::check($deadline, $shouldAbort); - self::notify($options, $index, $total, 'uploading'); $cacheKey = hash('sha256', $item['type'] . "\0" . $item['url']); - if (!isset($cache[$cacheKey])) { + if (array_key_exists('path', $item) && $item['path'] === null) $cache[$cacheKey] = null; + if (!array_key_exists($cacheKey, $cache) || $cache[$cacheKey] !== null) { + self::notify($options, $index, $total, 'uploading'); + } + if (!array_key_exists($cacheKey, $cache)) { $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); + $exists = true; if (isset($item['path'])) { self::copyLocal($item['path'], $path, $maxBytes, $deadline, $shouldAbort); } else { - self::download($item['url'], $item['parts'], $path, $maxBytes, $deadline, $shouldAbort, $options); + $exists = 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'; + if (!$exists) { + $cache[$cacheKey] = null; + } else { + $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, $options['ca_file']); } - $cache[$cacheKey] = self::upload($endpoint, $apiKey, $user, $path, $mime, - 'attachment-' . $index . '.' . $extension, $deadline, $shouldAbort); + } + if ($cache[$cacheKey] === null) { + if (!$options['skip_missing']) self::fail(array_key_exists('path', $item) + ? 'REQUIRED_FILES_SOURCE_UNAVAILABLE' : 'REQUIRED_FILES_DOWNLOAD_FAILED'); + $skipped[] = ['index' => $index, 'kind' => $item['type'], 'reason' => 'not_found']; + self::notify($options, $index, $total, 'skipped', 'not_found'); + self::check($deadline, $shouldAbort); + continue; } $prepared[] = ['type' => $item['type'], 'transfer_method' => 'local_file', 'upload_file_id' => $cache[$cacheKey]]; + $uploadedIndices[] = $index; self::notify($options, $index, $total, 'uploaded'); } self::check($deadline, $shouldAbort); - return ['ok' => true, 'files' => $prepared, 'attachment_transport' => 'uploaded_files']; + return ['ok' => true, 'files' => $prepared, 'uploaded_indices' => $uploadedIndices, + 'skipped' => $skipped, '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'; @@ -122,11 +148,11 @@ final class DifyRequiredAttachmentUpload 'REQUIRED_FILES_PREPARE_FAILED' => '必需附件准备失败,本次未提交分析', ]; if (!isset($messages[$code])) $code = 'REQUIRED_FILES_PREPARE_FAILED'; - if ($index > 0 && $total > 0) { + if ($index > 0 && $total > 0 && !in_array($index, array_column($skipped, 'index'), true)) { 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]; + 'failed_index' => $index, 'attachment_count' => $total, 'skipped' => $skipped]; } finally { foreach ($temporary as $path) if (is_file($path)) @unlink($path); } @@ -140,6 +166,11 @@ final class DifyRequiredAttachmentUpload catch (\Throwable $ignored) { $configured = []; } } $options = array_replace(is_array($configured) ? $configured : [], $options); + if (!array_key_exists('skip_missing', $options)) $options['skip_missing'] = true; + if (!is_bool($options['skip_missing'])) self::fail('CONFIG_INVALID'); + $caFile = $options['ca_file'] ?? ''; + if (!is_string($caFile) || ($caFile !== '' && (!is_file($caFile) || !is_readable($caFile)))) self::fail('CONFIG_INVALID'); + $options['ca_file'] = $caFile; $options['resolved_hosts'] = self::resolvedHosts(array_key_exists('resolved_hosts', $options) ? $options['resolved_hosts'] : []); return $options; @@ -172,7 +203,9 @@ final class DifyRequiredAttachmentUpload private static function plan(array $files): array { $plan = []; + $index = 0; foreach ($files as $file) { + ++$index; 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'] ?? '')); @@ -182,7 +215,7 @@ final class DifyRequiredAttachmentUpload 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)]; + $plan[] = ['index' => $index, 'type' => $type, 'url' => $url, 'parts' => self::url($url)]; } return $plan; } @@ -239,20 +272,41 @@ final class DifyRequiredAttachmentUpload return str_starts_with($path, $root); } - private static function localPath(string $publicRoot, string $urlPath): string + 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 (!str_starts_with($decoded, '/uploads/') || preg_match('/[\x00-\x1f\x7f\\\\%:?#<>"|*]/', $decoded)) self::fail('REQUIRED_FILES_INVALID'); + $segments = explode('/', substr($decoded, 1)); + foreach ($segments as $segment) { if ($segment === '' || $segment === '.' || $segment === '..' || trim($segment) !== $segment - || str_ends_with($segment, '.')) self::fail('REQUIRED_FILES_INVALID'); + || str_ends_with($segment, '.') || preg_match('/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i', $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; + if ($uploads === false || !self::inside($publicRoot, $uploads) || !is_dir($uploads)) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE'); + $current = $uploads; + foreach (array_slice($segments, 1) as $position => $segment) { + $candidate = $current . DIRECTORY_SEPARATOR . $segment; + $real = realpath($candidate); + if ($real === false) { + // realpath(false) alone also means denied access or a broken symlink. + // A readable parent listing must confirm absence before skipping. + if (@lstat($candidate) !== false || is_link($candidate)) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE'); + $entries = @scandir($current); + if ($entries === false) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE'); + foreach ($entries as $entry) { + if ($entry === $segment || (PHP_OS_FAMILY === 'Windows' && strcasecmp($entry, $segment) === 0)) { + self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE'); + } + } + return null; + } + if (!self::inside($uploads, $real)) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE'); + $last = $position === count($segments) - 2; + if (!is_readable($real) || ($last ? !is_file($real) : !is_dir($real))) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE'); + $current = $real; + } + return $current; } private static function tempRoot(string $root, string $publicRoot): string @@ -286,7 +340,7 @@ final class DifyRequiredAttachmentUpload } } - private static function download(string $url, array $parts, string $path, int $maxBytes, float $deadline, ?callable $shouldAbort, array $options): void + private static function download(string $url, array $parts, string $path, int $maxBytes, float $deadline, ?callable $shouldAbort, array $options): bool { self::check($deadline, $shouldAbort); $host = trim($parts['host'], '[]'); @@ -320,7 +374,7 @@ final class DifyRequiredAttachmentUpload $bytes = 0; $writeError = ''; try { - $curlOptions = self::curlOptions($url, $deadline, $shouldAbort); + $curlOptions = self::curlOptions($url, $deadline, $shouldAbort, $options['ca_file']); $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) { @@ -340,7 +394,10 @@ final class DifyRequiredAttachmentUpload $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'); + if ($result['errno'] !== CURLE_OK) self::fail('REQUIRED_FILES_DOWNLOAD_FAILED'); + if (in_array($result['status'], [404, 410], true)) return false; + if ($result['status'] !== 200 || $bytes === 0) self::fail('REQUIRED_FILES_DOWNLOAD_FAILED'); + return true; } finally { curl_close($ch); fclose($output); } } @@ -369,7 +426,7 @@ final class DifyRequiredAttachmentUpload && ($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 + private static function upload(string $endpoint, string $apiKey, string $user, string $path, string $mime, string $name, float $deadline, ?callable $shouldAbort, string $caFile): string { self::check($deadline, $shouldAbort); $ch = curl_init(); @@ -377,7 +434,7 @@ final class DifyRequiredAttachmentUpload $body = ''; $tooLarge = false; try { - $options = self::curlOptions($endpoint, $deadline, $shouldAbort); + $options = self::curlOptions($endpoint, $deadline, $shouldAbort, $caFile); $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)]; @@ -397,7 +454,7 @@ final class DifyRequiredAttachmentUpload } finally { curl_close($ch); } } - private static function curlOptions(string $url, float $deadline, ?callable $shouldAbort): array + private static function curlOptions(string $url, float $deadline, ?callable $shouldAbort, string $caFile = ''): array { $remaining = max(1, (int) ceil(($deadline - microtime(true)) * 1000)); $options = [CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => false, @@ -406,6 +463,7 @@ final class DifyRequiredAttachmentUpload CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT_MS => $remaining, CURLOPT_CONNECTTIMEOUT_MS => min(10000, $remaining), CURLOPT_NOSIGNAL => true, CURLOPT_NOPROGRESS => false]; + if ($caFile !== '') $options[CURLOPT_CAINFO] = $caFile; foreach (['CURLOPT_XFERINFOFUNCTION', 'CURLOPT_PROGRESSFUNCTION'] as $name) { if (!defined($name)) continue; $options[constant($name)] = static function (...$unused) use ($deadline, $shouldAbort): int { @@ -447,9 +505,11 @@ final class DifyRequiredAttachmentUpload if (microtime(true) >= $deadline) self::fail('REQUIRED_FILES_TIMEOUT'); } - private static function notify(array $options, int $index, int $total, string $status): void + private static function notify(array $options, int $index, int $total, string $status, ?string $reason = null): void { - if (is_callable($options['on_upload'] ?? null)) ($options['on_upload'])(['index' => $index, 'total' => $total, 'status' => $status]); + $event = ['index' => $index, 'total' => $total, 'status' => $status]; + if ($reason !== null) $event['reason'] = $reason; + if (is_callable($options['on_upload'] ?? null)) ($options['on_upload'])($event); } 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 a70ff8b73..2903b1d3f 100644 --- a/server/config/prescription_ai.php +++ b/server/config/prescription_ai.php @@ -29,6 +29,9 @@ return [ '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), + // Optional trusted server CA bundle for PHP installations without a system + // cURL certificate store; peer and hostname verification remain enabled. + 'ca_file' => (string) env('prescription_ai.REQUIRED_ATTACHMENT_CA_FILE', ''), // Optional server-owned DNS pins for remote attachment storage only. Exact // hostname => nonempty public-IP array; origins/TLS checks still apply. // Keep invalid JSON/root types as null so the uploader fails closed. @@ -41,8 +44,8 @@ return [ 'context_compaction_cache_ttl' => (int) env('prescription_ai.CONTEXT_COMPACTION_CACHE_TTL', 300), /** * 仅供历史/非必需附件路径使用的单次附件数量上限(默认 3);这些路径可将 - * 超出附件改为清单。required_files 路径始终提交全部必需附件,上传、数量或 - * 类型被上游拒绝即整次失败,绝不靠减少附件或只发清单生成不完整报告。 + * 超出附件改为清单。required_files 路径提交全部可用附件;确认不存在的源文件 + * 明确标为已跳过,并继续发送完整正文。其他上传、数量或类型错误仍明确失败。 * 各应用可用 models 中的 max_files 覆盖非必需附件上限;上游实际数量限制 * 应只读核对 /parameters 的 file_upload.number_limits。 */ diff --git a/server/tests/DiagnosisAiAssistantErrorReceiptTest.php b/server/tests/DiagnosisAiAssistantErrorReceiptTest.php index 23fef1990..519cc7f9b 100644 --- a/server/tests/DiagnosisAiAssistantErrorReceiptTest.php +++ b/server/tests/DiagnosisAiAssistantErrorReceiptTest.php @@ -46,4 +46,27 @@ errorReceiptExpect(!str_contains(end($frames), 'required_sections') && str_conta 'cancel terminal does not reuse unrelated failed receipt'); $format->invoke(null, [], ['ok' => true, 'content' => '合成回复']); errorReceiptExpect(DiagnosisAiLogic::getAssistantErrorMeta() === [], 'success clears stale error metadata'); +$skippedResult = $result; +$skippedResult['items'] = [ + ['kind' => 'image', 'status' => 'skipped', 'reason' => 'not_found', 'url' => 'https://private.invalid/missing'], + ['kind' => 'image', 'status' => 'failed', 'reason' => 'private-error-body'], +]; +$skippedResult['attachment_count'] = 2; +$skippedResult['submitted_attachment_count'] = 0; +$skippedResult['skipped_attachment_count'] = 1; +$format->invoke(null, [], $skippedResult); +$safeSkipped = DiagnosisAiLogic::getAssistantErrorMeta(); +errorReceiptExpect($safeSkipped['items'][0]['status'] === 'skipped' && $safeSkipped['items'][0]['reason'] === 'not_found' + && $safeSkipped['items'][1]['status'] === 'failed' && !isset($safeSkipped['items'][1]['reason']) + && $safeSkipped['submitted_attachment_count'] === 0 && $safeSkipped['skipped_attachment_count'] === 1, + 'final errors preserve safe missing-source facts and separate counters'); +errorReceiptExpect(!str_contains(json_encode($safeSkipped), 'private.invalid') + && !str_contains(json_encode($safeSkipped), 'private-error-body'), 'skipped error metadata never includes resource URLs or private reasons'); +$success = $format->invoke(null, [], ['ok' => true, 'content' => '合成回复', 'transfer_manifest_final' => true, + 'required_sections' => AiRequiredSource::manifest([]), 'items' => [['kind' => 'image', 'status' => 'skipped', 'reason' => 'not_found']], + 'attachment_count' => 1, 'submitted_attachment_count' => 0, 'skipped_attachment_count' => 1, + 'attachment_transfer' => 'uploaded_files']); +errorReceiptExpect($success['answer'] === '合成回复' && $success['items'][0]['status'] === 'skipped' + && $success['submitted_attachment_count'] === 0 && $success['skipped_attachment_count'] === 1, + 'a text answer with all attachments missing keeps its successful final receipt'); echo "Assistant final error receipt / real controller SSE / private metadata filter / cancellation isolation: OK\n"; diff --git a/server/tests/DifyMissingAttachmentFallbackTest.php b/server/tests/DifyMissingAttachmentFallbackTest.php new file mode 100644 index 000000000..c225a04cc --- /dev/null +++ b/server/tests/DifyMissingAttachmentFallbackTest.php @@ -0,0 +1,94 @@ + $file) { + $missing = $GLOBALS['missingFallbackScenario'] === 'all-missing' || $position === 0; + if ($missing) { + $skips[] = ['index' => $position + 1, 'kind' => $file['type'], 'reason' => 'not_found']; + ($options['on_upload'])(['index' => $position + 1, 'status' => 'skipped', 'reason' => 'not_found']); + } else { + $wire[] = ['type' => $file['type'], 'transfer_method' => 'local_file', 'upload_file_id' => 'synthetic-id']; + $indices[] = $position + 1; + ($options['on_upload'])(['index' => $position + 1, 'status' => 'uploaded']); + } + } + return ['ok' => true, 'files' => $wire, 'uploaded_indices' => $indices, 'skipped' => $skips]; + } + } + function curl_init(): \stdClass { return new \stdClass(); } + function curl_setopt_array(\stdClass $handle, array $options): bool { + if (!str_starts_with($options[CURLOPT_URL], 'https://fixture.invalid/v1/')) throw new \RuntimeException('Fixture destinations only'); + $handle->options = $options; return true; + } + function curl_exec(\stdClass $handle): string|bool { + $payload = json_decode($handle->options[CURLOPT_POSTFIELDS], true); + $GLOBALS['missingFallbackRequests'][] = $payload; + $dify = str_ends_with($handle->options[CURLOPT_URL], '/chat-messages'); + $handle->code = $dify ? 404 : 200; + ($handle->options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n"); + if (isset($handle->options[CURLOPT_WRITEFUNCTION])) { + $write = $handle->options[CURLOPT_WRITEFUNCTION]; + if ($dify) $write($handle, '{"code":"not_found"}'); + else { + $write($handle, 'data: ' . json_encode(['choices' => [['delta' => ['content' => 'synthetic answer']]]]) . "\n\n"); + $write($handle, "data: [DONE]\n\n"); + } + return true; + } + return $dify ? '{"code":"not_found"}' : '{"choices":[{"message":{"content":"synthetic answer"}}]}'; + } + 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; + function missingFallbackExpect(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); } + $missingFallbackConfig = ['enable' => true, 'base_url' => 'https://fixture.invalid/v1', + 'timeout' => 5, 'required_attachment_transport' => 'upload', + 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'fixture-only']]]; + foreach ([false, true] as $stream) foreach (['all-missing', 'missing-image-available-document', + 'missing-image-available-image', 'missing-document-available-image'] as $missingFallbackScenario) { + $missingFallbackRequests = []; $events = []; + $files = $missingFallbackScenario === 'all-missing' + ? [['type' => 'document', 'url' => 'https://fixture.invalid/missing.pdf'], ['type' => 'audio', 'url' => 'https://fixture.invalid/missing.wav']] + : [['type' => $missingFallbackScenario === 'missing-document-available-image' ? 'document' : 'image', 'url' => 'https://fixture.invalid/missing'], + ['type' => $missingFallbackScenario === 'missing-image-available-document' ? 'document' : 'image', 'url' => 'https://fixture.invalid/available']]; + $options = ['required_files' => true, 'knowledge_mode' => 'evidence', + 'on_progress' => static function (array $event) use (&$events): void { $events[] = $event; }]; + $result = $stream ? DifyChatService::streamChat('qwen', [], 'complete synthetic clinical text', 'synthetic-user', + static fn (string $text): bool => true, null, $files, $options) + : DifyChatService::chat('qwen', [], 'complete synthetic clinical text', 'synthetic-user', $files, $options); + $expectedMissing = $missingFallbackScenario === 'all-missing' ? 2 : 1; + missingFallbackExpect($result['skipped_attachment_count'] === $expectedMissing && $result['attachment_count'] === 2, + 'protocol fallback keeps original source and confirmed missing counts'); + missingFallbackExpect($result['items'][0]['status'] === 'skipped' && $result['items'][0]['reason'] === 'not_found', + 'known missing item survives every fallback outcome'); + if ($missingFallbackScenario === 'missing-image-available-document') { + missingFallbackExpect(empty($result['ok']) && $result['error_code'] === 'REQUIRED_FILES_UNSUPPORTED' + && count($missingFallbackRequests) === 1 && $result['submitted_attachment_count'] === 0 + && $result['attachment_transfer'] === 'uploaded_files', 'unsupported fallback retains the prior staged receipt'); + continue; + } + missingFallbackExpect(!empty($result['ok']) && count($missingFallbackRequests) === 2 + && $result['submitted_attachment_count'] === 2 - $expectedMissing, 'text or available images complete through protocol fallback'); + $wire = json_encode($missingFallbackRequests[1], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + missingFallbackExpect(!str_contains($wire, 'https://fixture.invalid/missing') + && !str_contains($wire, 'ATTACHMENTS_NOT_INLINE') && str_contains($wire, 'complete synthetic clinical text') + && substr_count($wire, '【附件可用性说明】') === 1, 'fallback builds from available sources without resurrecting dead references'); + missingFallbackExpect(($missingFallbackScenario === 'all-missing') === !str_contains($wire, 'https://fixture.invalid/available'), + 'available images are preserved while all-missing requests send only text'); + } + echo "Missing attachment protocol fallback / truthful receipts / no revived references / blocking and stream: OK\n"; +} diff --git a/server/tests/DifyRequiredAttachmentUploadTest.php b/server/tests/DifyRequiredAttachmentUploadTest.php index 072f7f03c..671c3ebfa 100644 --- a/server/tests/DifyRequiredAttachmentUploadTest.php +++ b/server/tests/DifyRequiredAttachmentUploadTest.php @@ -3,6 +3,13 @@ declare(strict_types=1); namespace app\common\service { + /** Simulate access denied deterministically on Windows without changing ACLs. */ + function scandir(string $directory) + { + if (($GLOBALS['attachmentDeniedDirectory'] ?? null) === $directory) return false; + return \scandir($directory); + } + /** Never query real DNS: reproduce the unsafe proxy answer deterministically. */ function dns_get_record(string $host, int $type): array { @@ -53,6 +60,7 @@ namespace { 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, '/missing-upload/')) { http_response_code(404); 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'); @@ -67,6 +75,10 @@ namespace { } $append(['kind' => 'download', 'path' => $path, 'authorization' => $_SERVER['HTTP_AUTHORIZATION'] ?? '']); if ($path === '/missing-object') { http_response_code(404); echo 'fixture-private-path-url-key-body'; return; } + if ($path === '/gone-object') { http_response_code(410); return; } + if ($path === '/denied-object') { http_response_code(403); echo 'fixture-private-path-url-key-body'; return; } + if ($path === '/broken-object') { http_response_code(500); echo 'fixture-private-path-url-key-body'; return; } + if ($path === '/stalled-missing-object') { http_response_code(404); echo ''; flush(); usleep(1200000); echo ''; return; } 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; } @@ -174,11 +186,12 @@ namespace { 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($result['uploaded_indices'] === [1, 2, 3] && $result['skipped'] === [], 'unchanged uploads report original indices and no skipped attachments'); 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 ($events as $event) attachmentExpect(array_keys($event) === ['index', 'total', 'status'] && $event['total'] === 6, 'progress contains safe original 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'); @@ -220,6 +233,11 @@ namespace { '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/nested/missing/../../private.txt', + 'https://site.synthetic.test/uploads/nested/missing/%2e%2e/private.txt', + 'https://site.synthetic.test/uploads/nested/missing/con.txt', + 'https://site.synthetic.test/uploads/nested/missing/%3Cinvalid%3E.png', + 'https://site.synthetic.test/uploads/nested/missing/file%2A.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', @@ -227,15 +245,81 @@ namespace { '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'); + $localMissingEvents = []; + $localMissing = $run($source('https://site.synthetic.test/uploads/missing.png'), + ['on_upload' => static function (array $event) use (&$localMissingEvents): void { $localMissingEvents[] = $event; }]); + attachmentExpect(!empty($localMissing['ok']) && $localMissing['files'] === [] && $localMissing['uploaded_indices'] === [] + && $localMissing['skipped'] === [['index' => 1, 'kind' => 'document', 'reason' => 'not_found']], 'confirmed missing local file is skipped for a text-only response'); + attachmentExpect($localMissingEvents === [['index' => 1, 'total' => 1, 'status' => 'skipped', 'reason' => 'not_found']], 'local preflight absence emits only safe skipped progress'); + $missingParent = $run($source('https://site.synthetic.test/uploads/missing-parent/deeper/file.png')); + attachmentExpect(!empty($missingParent['ok']) && count($missingParent['skipped']) === 1, 'a safely absent parent establishes local absence without a download fallback'); + attachmentError($run($source('https://site.synthetic.test/uploads/missing.png'), ['skip_missing' => false]), + 'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'server can explicitly require every local source'); + attachmentError($run($source('https://site.synthetic.test/uploads/nested')), 'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'existing directories remain unreadable attachments rather than missing'); + attachmentError($run($source('https://site.synthetic.test/uploads/first.png/missing.png')), + 'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'non-directory parent cannot establish missing-file status'); + $attachmentDeniedDirectory = realpath($root . '/public/uploads'); + attachmentError($run($source('https://site.synthetic.test/uploads/inaccessible.png')), + 'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'inaccessible directory listing cannot prove a local source is missing'); + $attachmentDeniedDirectory = null; 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'); } + if (@symlink($root . '/private', $root . '/public/uploads/outside-parent')) { + attachmentError($run($source('https://site.synthetic.test/uploads/outside-parent/missing.png')), + 'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'missing file below an escaping symlink remains unsafe'); + } + if (@symlink($root . '/private/nonexistent.txt', $root . '/public/uploads/broken-link.txt')) { + attachmentError($run($source('https://site.synthetic.test/uploads/broken-link.txt')), + 'REQUIRED_FILES_SOURCE_UNAVAILABLE', 'broken symlink is not a confirmed absent local file'); + } $clean(); + $clear(); + $localMixed = $run([$source('https://site.synthetic.test/uploads/missing.png')[0], $files[0], + $source('https://site.synthetic.test/uploads/nested/missing.png')[0], $files[2]]); + attachmentExpect(!empty($localMixed['ok']) && $localMixed['uploaded_indices'] === [2, 4] + && array_column($localMixed['skipped'], 'index') === [1, 3] + && array_column($requests(), 'kind') === ['upload', 'upload'], 'local missing-first and missing-middle sources preserve every remaining source without downloads'); + $clean(); + $clear(); + $allLocalMissing = $run([$source('https://site.synthetic.test/uploads/missing.png')[0], + $source('https://site.synthetic.test/uploads/nested/missing.png')[0]]); + attachmentExpect(!empty($allLocalMissing['ok']) && $allLocalMissing['files'] === [] + && $allLocalMissing['uploaded_indices'] === [] && array_column($allLocalMissing['skipped'], 'index') === [1, 2] + && $requests() === [], 'all missing local files return a safe empty upload list for text-only chat'); + $videoEvents = []; + $videoMixed = $run([['type' => 'video', 'url' => 'https://untrusted.invalid/record.mp4'], + $source('https://site.synthetic.test/uploads/missing.png')[0], $files[0]], + ['on_upload' => static function (array $event) use (&$videoEvents): void { $videoEvents[] = $event; }]); + attachmentExpect(!empty($videoMixed['ok']) && $videoMixed['uploaded_indices'] === [3] + && array_column($videoMixed['skipped'], 'index') === [2] + && array_column($videoEvents, 'index') === [2, 3, 3] && array_unique(array_column($videoEvents, 'total')) === [3], + 'original input positions and total remain accurate when videos are excluded'); + $clean(); + $clear(); + foreach ([['max_file_bytes' => 0], ['temp_root' => $root . '/public/unsafe-temp'], ['skip_missing' => 'true'], + ['ca_file' => []], ['ca_file' => $root . '/missing-ca.pem'], + ['resolved_hosts' => ['storage.synthetic.test' => ['127.0.0.1']]]] as $invalidOptions) { + attachmentError($run($source('https://site.synthetic.test/uploads/missing.png'), $invalidOptions), + 'CONFIG_INVALID', 'all-missing local input cannot bypass invalid server configuration'); + } + attachmentError($run([$source('https://site.synthetic.test/uploads/missing.png')[0], + $source('https://site.synthetic.test/uploads/missing-parent/../private.txt')[0]]), + 'REQUIRED_FILES_INVALID', 'earlier confirmed absence cannot bypass unsafe later path validation'); + attachmentExpect($requests() === [], 'invalid configuration and unsafe paths open no transport even with missing sources'); + $clean(); $clear(); $remote = [['type' => 'image', 'url' => 'https://storage.synthetic.test/download.png']]; + $withCa = $run($remote, ['ca_file' => $root . '/public/uploads/report.pdf']); + attachmentExpect(!empty($withCa['ok']) && count($attachmentCurlOptions) === 2 + && $attachmentCurlOptions[0][CURLOPT_CAINFO] === $root . '/public/uploads/report.pdf' + && $attachmentCurlOptions[1][CURLOPT_CAINFO] === $root . '/public/uploads/report.pdf' + && $attachmentCurlOptions[0][CURLOPT_SSL_VERIFYPEER] === true + && $attachmentCurlOptions[1][CURLOPT_SSL_VERIFYHOST] === 2, + 'explicit server CA bundle applies to download and upload while certificate verification stays enabled'); + $clear(); $clean(); $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'); @@ -266,13 +350,60 @@ namespace { $missingPinnedSource = $run(array_merge($remote, [['type' => 'image', 'url' => 'https://storage.synthetic.test/missing-object']]), ['resolved_hosts' => ['storage.synthetic.test' => ['8.8.8.8']], 'resolve_host' => null]); - attachmentError($missingPinnedSource, 'REQUIRED_FILES_DOWNLOAD_FAILED', 'pinned missing source fails rather than dropping a mandatory file'); + attachmentExpect(!empty($missingPinnedSource['ok']) && count($missingPinnedSource['files']) === 1 + && $missingPinnedSource['uploaded_indices'] === [1] + && $missingPinnedSource['skipped'] === [['index' => 2, 'kind' => 'image', 'reason' => 'not_found']], + 'pinned confirmed missing source preserves the first existing upload'); attachmentExpect(count($requests()) === 3 && array_column($requests(), 'kind') === ['download', 'upload', 'download'] - && $missingPinnedSource['failed_index'] === 2 && $missingPinnedSource['attachment_count'] === 2, - 'a second-file 404 returns no partial files after the first upload'); + && count($missingPinnedSource['files']) === 1, 'a second-file 404 makes no upload for the missing bytes'); attachmentExpect($attachmentNativeDnsCalls === [], 'pinned 404 never retries through native DNS'); $clean(); + $missingRemote = ['type' => 'image', 'url' => 'https://storage.synthetic.test/missing-object']; + $goneRemote = ['type' => 'document', 'url' => 'https://storage.synthetic.test/gone-object']; + foreach ([[[$missingRemote, $files[0], $files[2]], [2, 3], [1]], + [[$files[0], $missingRemote, $files[2]], [1, 3], [2]], + [[$missingRemote, $goneRemote], [], [1, 2]]] as [$input, $uploadedIndices, $skippedIndices]) { + $clear(); $missingEvents = []; + $mixedMissing = $run($input, ['on_upload' => static function (array $event) use (&$missingEvents): void { $missingEvents[] = $event; }]); + attachmentExpect(!empty($mixedMissing['ok']) && $mixedMissing['uploaded_indices'] === $uploadedIndices + && count($mixedMissing['files']) === count($uploadedIndices) + && array_column($mixedMissing['skipped'], 'index') === $skippedIndices, + 'missing-first, missing-middle and all-missing preserve original upload mapping'); + foreach ($mixedMissing['skipped'] as $skip) attachmentExpect(array_keys($skip) === ['index', 'kind', 'reason'] + && $skip['kind'] === $input[$skip['index'] - 1]['type'] && $skip['reason'] === 'not_found', 'skip metadata contains safe index/kind/reason only'); + foreach ($missingEvents as $event) attachmentExpect($event['total'] === count($input) + && array_keys($event) === ($event['status'] === 'skipped' ? ['index', 'total', 'status', 'reason'] : ['index', 'total', 'status']), + 'mixed missing progress preserves manifest total and exposes no source details'); + $uploadedRequests = array_values(array_filter($requests(), static fn (array $entry): bool => $entry['kind'] === 'upload')); + foreach ($uploadedIndices as $position => $originalIndex) { + attachmentExpect($mixedMissing['files'][$position]['upload_file_id'] === $uploadedRequests[$position]['id'] + && $mixedMissing['files'][$position]['type'] === $input[$originalIndex - 1]['type'], 'remaining references match the correct original attachment bytes and type'); + } + $clean(); + } + $clear(); + $duplicatesWithMissing = $run([$missingRemote, $files[0], $missingRemote, $files[0], $goneRemote]); + attachmentExpect(!empty($duplicatesWithMissing['ok']) && $duplicatesWithMissing['uploaded_indices'] === [2, 4] + && array_column($duplicatesWithMissing['skipped'], 'index') === [1, 3, 5] + && $duplicatesWithMissing['files'][0]['upload_file_id'] === $duplicatesWithMissing['files'][1]['upload_file_id'] + && array_column($requests(), 'kind') === ['download', 'upload', 'download'], 'duplicate missing sources are fetched once and every original position is retained deterministically'); + $clean(); + $clear(); + attachmentError($run([$missingRemote], ['skip_missing' => false]), 'REQUIRED_FILES_DOWNLOAD_FAILED', 'server can explicitly retain strict remote missing-file policy'); + foreach (['/denied-object', '/broken-object'] as $path) { + $clear(); + attachmentError($run([['type' => 'image', 'url' => 'https://storage.synthetic.test' . $path]]), + 'REQUIRED_FILES_DOWNLOAD_FAILED', '403 and 500 cannot be skipped as absent attachments'); + attachmentExpect(array_column($requests(), 'kind') === ['download'], 'non-missing remote error is never uploaded'); + $clean(); + } + $clear(); $start = microtime(true); + attachmentError($run([['type' => 'image', 'url' => 'https://storage.synthetic.test/stalled-missing-object']], [], null, + 'synthetic-user', null, 1), 'REQUIRED_FILES_TIMEOUT', '404 headers followed by a stalled transfer remain a timeout'); + attachmentExpect(microtime(true) - $start < 1.4 && array_column($requests(), 'kind') === ['download'], 'incomplete 404 response is never accepted as confirmed missing'); + $clean(); usleep(300000); + $clear(); $pinnedV6 = $run($remote, ['resolved_hosts' => ['storage.synthetic.test' => ['2606:4700:4700::1111']], 'resolve_host' => null]); attachmentExpect(!empty($pinnedV6['ok']) && $attachmentNativeDnsCalls === [] @@ -381,7 +512,7 @@ namespace { attachmentExpect($requests() === [], 'oversize local file never uploads'); $clean(); - foreach (['fail', 'bad-id', 'large-response'] as $mode) { + foreach (['fail', 'missing-upload', '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'); @@ -395,6 +526,14 @@ namespace { 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(); + $failedAfterSkip = $run([$missingRemote, $files[0], $files[2]], [], null, + 'synthetic-user', 'http://127.0.0.1:' . $port . '/fail/v1/chat-messages'); + attachmentError($failedAfterSkip, 'REQUIRED_FILES_UPLOAD_FAILED', 'upload error after a confirmed absence still fails without partial files'); + attachmentExpect($failedAfterSkip['skipped'] === [['index' => 1, 'kind' => 'image', 'reason' => 'not_found']] + && $failedAfterSkip['failed_index'] === 2 && $failedAfterSkip['attachment_count'] === 3, + 'fatal response preserves safe skipped metadata and the actual failed index'); + $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; @@ -402,6 +541,17 @@ namespace { 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(); + $clear(); $cancelAfterSkip = false; $cancelledSkipEvents = []; + $cancelledAfterSkip = $run([$missingRemote, $files[0]], ['on_upload' => static function (array $event) use (&$cancelAfterSkip, &$cancelledSkipEvents): void { + $cancelledSkipEvents[] = $event; + if ($event['status'] === 'skipped') $cancelAfterSkip = true; + }], static function () use (&$cancelAfterSkip): bool { return $cancelAfterSkip; }); + attachmentError($cancelledAfterSkip, 'AI_ASSISTANT_CANCELLED', 'cancellation immediately after skipped progress cannot continue into remaining uploads'); + attachmentExpect(array_column($requests(), 'kind') === ['download'] + && $cancelledAfterSkip['skipped'] === [['index' => 1, 'kind' => 'image', 'reason' => 'not_found']] + && array_column($cancelledSkipEvents, 'status') === ['uploading', 'skipped'], + 'cancelled request retains known skipped source and opens no subsequent upload'); + $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', @@ -431,7 +581,7 @@ namespace { 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"; + echo "Dify required attachment upload: binary multipart, confirmed-missing skips and original indices, 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/DifyRequiredUploadTransportTest.php b/server/tests/DifyRequiredUploadTransportTest.php index 8a672a9aa..631568ba2 100644 --- a/server/tests/DifyRequiredUploadTransportTest.php +++ b/server/tests/DifyRequiredUploadTransportTest.php @@ -14,13 +14,26 @@ namespace app\common\service { if ($GLOBALS['uploadTransportScenario'] === 'cancel') return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED', 'error' => '合成取消']; if ($GLOBALS['uploadTransportScenario'] === 'upload-failed') return ['ok' => false, 'error_code' => 'REQUIRED_FILES_UPLOAD_FAILED', 'error' => '合成上传失败']; $result = []; + $indices = []; $skipped = []; foreach ($files as $index => $file) { + $scenario = $GLOBALS['uploadTransportScenario']; + $missing = $scenario === 'all-missing' || (in_array($scenario, ['missing-first', 'missing-inputs', 'failed-after-skip', 'invalid-skip-receipt'], true) && $index === 0) + || ($scenario === 'missing-middle' && $index === 1); + if ($missing) { + $skipped[] = ['index' => $index + 1, 'kind' => $file['type'], 'reason' => 'not_found']; + if (is_callable($options['on_upload'] ?? null)) ($options['on_upload'])(['index' => $index + 1, + 'total' => count($files), 'status' => 'skipped', 'reason' => 'not_found']); + continue; + } + if ($scenario === 'failed-after-skip') return ['ok' => false, 'error_code' => 'REQUIRED_FILES_UPLOAD_FAILED', 'error' => '合成上传失败', 'skipped' => $skipped]; 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)]; + $indices[] = $index + 1; } - return ['ok' => true, 'files' => $result, 'attachment_transport' => 'uploaded_files']; + if ($GLOBALS['uploadTransportScenario'] === 'invalid-skip-receipt') $indices[0] = 1; + return ['ok' => true, 'files' => $result, 'uploaded_indices' => $indices, 'skipped' => $skipped, 'attachment_transport' => 'uploaded_files']; } } function curl_init(): \stdClass { return new \stdClass(); } @@ -29,7 +42,7 @@ namespace app\common\service { $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->code = in_array($GLOBALS['uploadTransportScenario'], ['inputs', 'missing-inputs'], true) && !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']); @@ -58,7 +71,8 @@ namespace { ['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) { + foreach ([false, true] as $stream) foreach (['success', 'inputs', 'upload-failed', 'cancel', + 'missing-first', 'missing-middle', 'all-missing', 'missing-inputs', 'failed-after-skip', 'invalid-skip-receipt'] 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', @@ -67,18 +81,38 @@ namespace { 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)) { + if (in_array($scenario, ['upload-failed', 'cancel', 'failed-after-skip', 'invalid-skip-receipt'], 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'); + if ($scenario === 'failed-after-skip') uploadTransportExpect($result['items'][0]['status'] === 'skipped' + && $result['items'][0]['reason'] === 'not_found' && $result['items'][1]['status'] === 'failed', + 'a later real failure does not relabel a known nonexistent source'); 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'); + $missingCount = $scenario === 'all-missing' ? 3 : (in_array($scenario, ['missing-first', 'missing-middle', 'missing-inputs'], true) ? 1 : 0); + uploadTransportExpect(count($uploadTransportRequests) === (in_array($scenario, ['inputs', 'missing-inputs'], true) ? 2 : 1), 'bounded input compatibility remains intact'); + uploadTransportExpect($result['submitted_attachment_count'] === 3 - $missingCount && $result['skipped_attachment_count'] === $missingCount + && $result['attachment_count'] === 3 && count($result['items']) === 3 + && $result['transmitted_file_count'] === 3 - $missingCount, 'final manifest distinguishes original, submitted and skipped counts'); + uploadTransportExpect(count($result['required_sections']) === 13 && array_unique(array_column($result['required_sections'], 'status')) === ['submitted'], + 'missing attachments do not block any of the thirteen clinical text sections'); + foreach ($result['items'] as $item) { + uploadTransportExpect(in_array($item['status'], ['submitted', 'skipped'], true), 'no false failed item on a completed response'); + if ($item['status'] === 'skipped') uploadTransportExpect($item['reason'] === 'not_found', 'skip reason is explicit'); + } 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($payload['user'] === 'fixture-same-user' && count($payload['files']) === 3 - $missingCount, 'every generation attempt retains all available uploaded files'); + uploadTransportExpect(array_column($payload['files'], 'transfer_method') === array_fill(0, 3 - $missingCount, 'local_file'), 'Dify gets upload IDs, not resource URLs'); + foreach ($payload['files'] as $file) uploadTransportExpect(!array_key_exists('url', $file), 'original URLs never leak into uploaded wire files'); + if ($missingCount > 0) uploadTransportExpect(substr_count($payload['query'], '【附件可用性说明】') === 1 + && str_contains($payload['query'], '源文件不存在') && str_contains($payload['query'], '继续依据') + && !str_contains($payload['query'], 'synthetic.invalid/'), 'model receives one safe missing-source notice and can continue'); + } + foreach ($events as $event) if (in_array($event['status'] ?? '', ['submitting', 'submitted'], true)) { + uploadTransportExpect(count(array_filter($event['items'], static fn (array $item): bool => $item['status'] === 'skipped')) === $missingCount, + 'transport notifications preserve every missing item across input retries'); } uploadTransportExpect(count(array_filter($events, static fn (array $event): bool => ($event['status'] ?? '') === 'uploading')) >= 3, 'safe per-file upload progress reaches the desktop');