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');