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