更新
This commit is contained in:
@@ -51,8 +51,8 @@ class AiChatService
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(8, max(3, (int) ceil($timeout / 3))));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, max(5, $timeout));
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
@@ -152,8 +152,8 @@ class AiChatService
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, max(10, $timeout));
|
||||
curl_setopt($ch, CURLOPT_TCP_NODELAY, true);
|
||||
curl_setopt($ch, CURLOPT_BUFFERSIZE, 128);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 处方/诊单 AI 上游客户端。
|
||||
*
|
||||
@@ -19,11 +21,25 @@ class DifyChatService
|
||||
|
||||
private const MAX_TIMEOUT = 300;
|
||||
|
||||
private const DEFAULT_MAX_FILES = 3;
|
||||
|
||||
/**
|
||||
* 上游明确以“这批附件我处理不了”拒绝整次请求时使用的状态码。
|
||||
* 命中后会去掉附件重试一次,避免一张舌象图让整份病历分析失败。
|
||||
*/
|
||||
private const FILE_REJECTION_CODES = [400, 413, 415, 422];
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $inputs
|
||||
* @return array{ok:bool,content?:string,message_id?:string,latency_ms?:int,error_code?:string,error?:string}
|
||||
*/
|
||||
public static function chat(string $profile, array $inputs, string $query, string $user): array
|
||||
public static function chat(
|
||||
string $profile,
|
||||
array $inputs,
|
||||
string $query,
|
||||
string $user,
|
||||
array $files = []
|
||||
): array
|
||||
{
|
||||
$config = config('prescription_ai') ?: [];
|
||||
if (empty($config['enable'])) {
|
||||
@@ -58,44 +74,66 @@ class DifyChatService
|
||||
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
|
||||
}
|
||||
|
||||
$requestSpecs = self::buildRequestSpecs($baseUrl, $model, $inputs, $query, $user);
|
||||
$normalized = self::normalizeFiles($files, self::maxFiles($config));
|
||||
$startedAt = microtime(true);
|
||||
$lastResponse = null;
|
||||
$formatted = null;
|
||||
|
||||
foreach ($requestSpecs as $index => $requestSpec) {
|
||||
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
|
||||
$remainingTimeout = $timeout - $elapsedSeconds;
|
||||
if ($remainingTimeout < self::MIN_TIMEOUT) {
|
||||
return self::error(
|
||||
'UPSTREAM_TIMEOUT',
|
||||
'模型响应超时,请稍后重试',
|
||||
self::elapsedMilliseconds($startedAt)
|
||||
);
|
||||
}
|
||||
|
||||
$response = self::sendRequest(
|
||||
$requestSpec['url'],
|
||||
$requestSpec['payload'],
|
||||
$apiKey,
|
||||
$remainingTimeout
|
||||
foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) {
|
||||
$requestSpecs = self::buildRequestSpecs(
|
||||
$baseUrl,
|
||||
$model,
|
||||
$inputs,
|
||||
$query,
|
||||
$user,
|
||||
false,
|
||||
$attempt['files'],
|
||||
$attempt['omitted']
|
||||
);
|
||||
$lastResponse = $response;
|
||||
$lastResponse = null;
|
||||
$lastSpec = [];
|
||||
|
||||
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
|
||||
// 避免因业务参数错误而重复提交同一份临床数据。
|
||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||||
if ($hasFallback && in_array($response['http_code'], [404, 405], true)) {
|
||||
continue;
|
||||
foreach ($requestSpecs as $index => $requestSpec) {
|
||||
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
|
||||
$remainingTimeout = $timeout - $elapsedSeconds;
|
||||
if ($remainingTimeout < self::MIN_TIMEOUT) {
|
||||
return self::error(
|
||||
'UPSTREAM_TIMEOUT',
|
||||
'模型响应超时,请稍后重试',
|
||||
self::elapsedMilliseconds($startedAt)
|
||||
);
|
||||
}
|
||||
|
||||
$response = self::sendRequest(
|
||||
$requestSpec['url'],
|
||||
$requestSpec['payload'],
|
||||
$apiKey,
|
||||
$remainingTimeout
|
||||
);
|
||||
$lastResponse = $response;
|
||||
$lastSpec = $requestSpec;
|
||||
|
||||
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
|
||||
// 避免因业务参数错误而重复提交同一份临床数据。
|
||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||||
if ($hasFallback && in_array($response['http_code'], [404, 405, 501], true)) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return self::formatResponse($response, $startedAt);
|
||||
$lastResponse = $lastResponse ?? ['body' => '', 'errno' => 0, 'http_code' => 0];
|
||||
$formatted = self::formatResponse($lastResponse, $startedAt);
|
||||
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
|
||||
if (!empty($formatted['ok'])) {
|
||||
return $formatted;
|
||||
}
|
||||
// 附件整体被拒时退回纯文本重试,附件清单已在下一轮尝试中补齐。
|
||||
if (!self::shouldRetryWithoutFiles((int) $lastResponse['http_code'], $attempt['files'])) {
|
||||
return $formatted;
|
||||
}
|
||||
}
|
||||
|
||||
return self::formatResponse($lastResponse ?? [
|
||||
'body' => '',
|
||||
'errno' => 0,
|
||||
'http_code' => 0,
|
||||
], $startedAt);
|
||||
return $formatted ?? self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', self::elapsedMilliseconds($startedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +150,8 @@ class DifyChatService
|
||||
string $query,
|
||||
string $user,
|
||||
callable $onDelta,
|
||||
?callable $shouldAbort = null
|
||||
?callable $shouldAbort = null,
|
||||
array $files = []
|
||||
): array {
|
||||
$config = config('prescription_ai') ?: [];
|
||||
if (empty($config['enable'])) {
|
||||
@@ -147,63 +186,75 @@ class DifyChatService
|
||||
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
|
||||
}
|
||||
|
||||
$requestSpecs = self::buildRequestSpecs(
|
||||
$baseUrl,
|
||||
$model,
|
||||
$inputs,
|
||||
$query,
|
||||
$user,
|
||||
true
|
||||
);
|
||||
$normalized = self::normalizeFiles($files, self::maxFiles($config));
|
||||
$startedAt = microtime(true);
|
||||
$lastResponse = null;
|
||||
$formatted = null;
|
||||
|
||||
foreach ($requestSpecs as $index => $requestSpec) {
|
||||
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
|
||||
$remainingTimeout = $timeout - $elapsedSeconds;
|
||||
if ($remainingTimeout < self::MIN_TIMEOUT) {
|
||||
return self::error(
|
||||
'UPSTREAM_TIMEOUT',
|
||||
'模型响应超时,请稍后重试',
|
||||
self::elapsedMilliseconds($startedAt)
|
||||
);
|
||||
}
|
||||
|
||||
$response = self::sendStreamRequest(
|
||||
$requestSpec['protocol'],
|
||||
$requestSpec['url'],
|
||||
$requestSpec['payload'],
|
||||
$apiKey,
|
||||
$remainingTimeout,
|
||||
$onDelta,
|
||||
$shouldAbort
|
||||
foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) {
|
||||
$requestSpecs = self::buildRequestSpecs(
|
||||
$baseUrl,
|
||||
$model,
|
||||
$inputs,
|
||||
$query,
|
||||
$user,
|
||||
true,
|
||||
$attempt['files'],
|
||||
$attempt['omitted']
|
||||
);
|
||||
$lastResponse = $response;
|
||||
$lastResponse = null;
|
||||
$lastSpec = [];
|
||||
|
||||
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
|
||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||||
if (
|
||||
$hasFallback
|
||||
&& empty($response['emitted'])
|
||||
&& in_array($response['http_code'], [404, 405], true)
|
||||
) {
|
||||
continue;
|
||||
foreach ($requestSpecs as $index => $requestSpec) {
|
||||
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
|
||||
$remainingTimeout = $timeout - $elapsedSeconds;
|
||||
if ($remainingTimeout < self::MIN_TIMEOUT) {
|
||||
return self::error(
|
||||
'UPSTREAM_TIMEOUT',
|
||||
'模型响应超时,请稍后重试',
|
||||
self::elapsedMilliseconds($startedAt)
|
||||
);
|
||||
}
|
||||
|
||||
$response = self::sendStreamRequest(
|
||||
$requestSpec['protocol'],
|
||||
$requestSpec['url'],
|
||||
$requestSpec['payload'],
|
||||
$apiKey,
|
||||
$remainingTimeout,
|
||||
$onDelta,
|
||||
$shouldAbort
|
||||
);
|
||||
$lastResponse = $response;
|
||||
$lastSpec = $requestSpec;
|
||||
|
||||
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
|
||||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||||
if (
|
||||
$hasFallback
|
||||
&& empty($response['emitted'])
|
||||
&& in_array($response['http_code'], [404, 405, 501], true)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return self::formatStreamResponse($response, $startedAt);
|
||||
$lastResponse = $lastResponse ?? self::emptyStreamResponse(0);
|
||||
$formatted = self::formatStreamResponse($lastResponse, $startedAt);
|
||||
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
|
||||
if (!empty($formatted['ok'])) {
|
||||
return $formatted;
|
||||
}
|
||||
// 已经推给医生的文本不能重复输出,因此只在一个字都没发出去时才降级重试。
|
||||
// 附件不可达时 Dify 会在 200 流里发 event:error,同样按附件问题降级。
|
||||
$fileRejected = self::shouldRetryWithoutFiles((int) $lastResponse['http_code'], $attempt['files'])
|
||||
|| (!empty($lastResponse['upstream_error']) && $attempt['files'] !== []);
|
||||
if (!empty($lastResponse['emitted']) || !$fileRejected) {
|
||||
return $formatted;
|
||||
}
|
||||
}
|
||||
|
||||
return self::formatStreamResponse($lastResponse ?? [
|
||||
'errno' => 0,
|
||||
'http_code' => 0,
|
||||
'content' => '',
|
||||
'message_id' => '',
|
||||
'emitted' => false,
|
||||
'upstream_error' => false,
|
||||
'client_aborted' => false,
|
||||
'callback_error' => false,
|
||||
'finished' => false,
|
||||
], $startedAt);
|
||||
return $formatted ?? self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', self::elapsedMilliseconds($startedAt));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,6 +272,8 @@ class DifyChatService
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $inputs
|
||||
* @param array<int,array<string,string>> $files 随请求送达的附件
|
||||
* @param array<int,array<string,string>> $omitted 超出上游数量上限、只能写进清单的附件
|
||||
* @return array<int,array{protocol:string,url:string,payload:array<string,mixed>}>
|
||||
*/
|
||||
private static function buildRequestSpecs(
|
||||
@@ -229,28 +282,45 @@ class DifyChatService
|
||||
array $inputs,
|
||||
string $query,
|
||||
string $user,
|
||||
bool $streaming = false
|
||||
bool $streaming = false,
|
||||
array $files = [],
|
||||
array $omitted = []
|
||||
): array {
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
|
||||
|
||||
// Dify 能承载全部附件类型,只需补上被数量上限截断的清单。
|
||||
// inputs 必须是 JSON 对象:空数组会被 json_encode 成 [],Dify 直接
|
||||
// 以 invalid_param 拒绝整单,因此这里强制对象语义。
|
||||
$difySpec = [
|
||||
'protocol' => 'dify',
|
||||
'url' => self::buildEndpoint($baseUrl, 'chat-messages'),
|
||||
'payload' => [
|
||||
'inputs' => $inputs,
|
||||
'query' => $query,
|
||||
'inputs' => (object) $inputs,
|
||||
'query' => self::withAttachmentManifest($query, $omitted),
|
||||
'response_mode' => $streaming ? 'streaming' : 'blocking',
|
||||
'user' => $user,
|
||||
],
|
||||
];
|
||||
if ($files !== []) {
|
||||
$difySpec['payload']['files'] = $files;
|
||||
}
|
||||
|
||||
// Chat Completions 只能内联图片,非图片附件与被截断的附件一并进清单。
|
||||
$openAiContent = self::buildOpenAiContent(
|
||||
self::withAttachmentManifest(
|
||||
$query,
|
||||
array_merge(self::nonImageFiles($files), $omitted)
|
||||
),
|
||||
$files
|
||||
);
|
||||
$openAiSpec = [
|
||||
'protocol' => 'openai',
|
||||
'url' => self::buildEndpoint($baseUrl, 'chat/completions'),
|
||||
'payload' => [
|
||||
'model' => $model,
|
||||
'messages' => [
|
||||
['role' => 'user', 'content' => $query],
|
||||
['role' => 'user', 'content' => $openAiContent],
|
||||
],
|
||||
'stream' => $streaming,
|
||||
],
|
||||
@@ -268,9 +338,164 @@ class DifyChatService
|
||||
}
|
||||
|
||||
// 保持既有 /v1 Dify 配置优先,同时让 OpenAI-compatible 服务在 404/405 后透明回退。
|
||||
// 早期实现只在“无附件或全是图片”时提供回退,患者带检查报告/录像附件时
|
||||
// Dify 路径 404 会直接变成“模型未能处理本次请求”,因此这里始终保留回退,
|
||||
// 非图片附件改为在正文中以清单形式随请求送达,绝不静默丢弃。
|
||||
return [$difySpec, $openAiSpec];
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 OpenAI-compatible 正文。图片走多模态 image_url;非图片附件已由调用方
|
||||
* 写进 $query 末尾的清单,这里只负责内联图片。
|
||||
*
|
||||
* @param array<int,array<string,string>> $files
|
||||
* @return string|array<int,array<string,mixed>>
|
||||
*/
|
||||
private static function buildOpenAiContent(string $query, array $files)
|
||||
{
|
||||
$content = [['type' => 'text', 'text' => $query]];
|
||||
foreach ($files as $file) {
|
||||
$url = (string) ($file['url'] ?? '');
|
||||
if ($url === '' || ($file['type'] ?? '') !== 'image') {
|
||||
continue;
|
||||
}
|
||||
$content[] = [
|
||||
'type' => 'image_url',
|
||||
'image_url' => ['url' => $url],
|
||||
];
|
||||
}
|
||||
return count($content) === 1 ? $query : $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,string>> $files
|
||||
* @return array<int,array<string,string>>
|
||||
*/
|
||||
private static function nonImageFiles(array $files): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$files,
|
||||
static fn (array $file): bool => ($file['type'] ?? '') !== 'image'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗附件,并按上游应用允许的数量截断。
|
||||
*
|
||||
* Dify 用 file_upload.number_limits 校验单次请求的附件总数,超出即返回
|
||||
* 400 invalid_param 拒绝整单。患者纵向资料的附件数量不可控(舌象、报告、
|
||||
* 录像可能几十份),因此这里必须主动截断;被截断的附件不会被悄悄丢弃,
|
||||
* 而是以清单形式随提示词送达,让模型知道存在哪些它读不到的资料。
|
||||
* 保持调用方给定的顺序,由调用方决定哪些附件最值得送上去。
|
||||
*
|
||||
* @param array<int,mixed> $files
|
||||
* @return array{
|
||||
* kept:array<int,array{type:string,transfer_method:string,url:string}>,
|
||||
* dropped:array<int,array{type:string,transfer_method:string,url:string}>
|
||||
* }
|
||||
*/
|
||||
private static function normalizeFiles(array $files, int $maxFiles): array
|
||||
{
|
||||
$maxFiles = max(0, $maxFiles);
|
||||
$kept = [];
|
||||
$dropped = [];
|
||||
$seen = [];
|
||||
foreach ($files as $file) {
|
||||
if (!is_array($file)) {
|
||||
continue;
|
||||
}
|
||||
$type = strtolower(trim((string) ($file['type'] ?? '')));
|
||||
$url = trim((string) ($file['url'] ?? ''));
|
||||
if (!in_array($type, ['image', 'document', 'audio', 'video', 'custom'], true)
|
||||
|| !self::isValidRemoteFileUrl($url)
|
||||
|| isset($seen[$url])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$url] = true;
|
||||
$normalized = [
|
||||
'type' => $type,
|
||||
'transfer_method' => 'remote_url',
|
||||
'url' => $url,
|
||||
];
|
||||
if (count($kept) >= $maxFiles) {
|
||||
$dropped[] = $normalized;
|
||||
continue;
|
||||
}
|
||||
$kept[] = $normalized;
|
||||
}
|
||||
return ['kept' => $kept, 'dropped' => $dropped];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $config */
|
||||
private static function maxFiles(array $config): int
|
||||
{
|
||||
$configured = (int) ($config['max_files'] ?? self::DEFAULT_MAX_FILES);
|
||||
return $configured >= 0 ? $configured : self::DEFAULT_MAX_FILES;
|
||||
}
|
||||
|
||||
/**
|
||||
* 排出两轮尝试:先带附件,附件被上游整体拒绝时再只发文本。
|
||||
* 第二轮把全部附件写进清单,保证降级后模型仍知道资料缺口。
|
||||
*
|
||||
* @param array<int,array<string,string>> $files
|
||||
* @param array<int,array<string,string>> $dropped
|
||||
* @return array<int,array{files:array<int,array<string,string>>,omitted:array<int,array<string,string>>}>
|
||||
*/
|
||||
private static function buildAttemptPlan(array $files, array $dropped): array
|
||||
{
|
||||
$attempts = [['files' => $files, 'omitted' => $dropped]];
|
||||
if ($files !== []) {
|
||||
$attempts[] = ['files' => [], 'omitted' => array_merge($files, $dropped)];
|
||||
}
|
||||
return $attempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,string>> $files
|
||||
*/
|
||||
private static function shouldRetryWithoutFiles(int $httpCode, array $files): bool
|
||||
{
|
||||
return $files !== [] && in_array($httpCode, self::FILE_REJECTION_CODES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把无法随请求送达的附件写成显式清单。模型必须知道这些资料存在但读不到,
|
||||
* 才不会把“没看到”当成“没有”。
|
||||
*
|
||||
* @param array<int,array<string,string>> $omitted
|
||||
*/
|
||||
private static function withAttachmentManifest(string $query, array $omitted): string
|
||||
{
|
||||
$lines = [];
|
||||
foreach ($omitted as $file) {
|
||||
$url = (string) ($file['url'] ?? '');
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
$lines[] = strtoupper((string) ($file['type'] ?? 'file')) . ' ' . $url;
|
||||
}
|
||||
if ($lines === []) {
|
||||
return $query;
|
||||
}
|
||||
return $query . "\n\n<ATTACHMENTS_NOT_INLINE>\n"
|
||||
. "以下附件无法随本次请求送达,只提供来源地址;无法读取的附件必须在结论中明确标注为信息缺口。\n"
|
||||
. implode("\n", $lines)
|
||||
. "\n</ATTACHMENTS_NOT_INLINE>";
|
||||
}
|
||||
|
||||
private static function isValidRemoteFileUrl(string $url): bool
|
||||
{
|
||||
if ($url === '' || preg_match('/[\x00-\x20\x7f]/', $url)) {
|
||||
return false;
|
||||
}
|
||||
$parts = parse_url($url);
|
||||
return is_array($parts)
|
||||
&& in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)
|
||||
&& trim((string) ($parts['host'] ?? '')) !== ''
|
||||
&& !isset($parts['user'])
|
||||
&& !isset($parts['pass']);
|
||||
}
|
||||
|
||||
private static function buildEndpoint(string $baseUrl, string $endpoint): string
|
||||
{
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
@@ -455,6 +680,7 @@ class DifyChatService
|
||||
'message_id' => $state['message_id'],
|
||||
'emitted' => $state['emitted'],
|
||||
'upstream_error' => $state['upstream_error'],
|
||||
'upstream_code' => $state['upstream_code'],
|
||||
'client_aborted' => $state['client_aborted'],
|
||||
'callback_error' => $state['callback_error'],
|
||||
'finished' => $state['finished'],
|
||||
@@ -474,12 +700,27 @@ class DifyChatService
|
||||
'message_id' => '',
|
||||
'emitted' => false,
|
||||
'upstream_error' => false,
|
||||
'upstream_code' => '',
|
||||
'client_aborted' => false,
|
||||
'callback_error' => false,
|
||||
'finished' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上游错误码只保留可枚举的短标识(如 invalid_param),杜绝把上游文案或
|
||||
* 患者资源地址带进日志。
|
||||
*
|
||||
* @param mixed $code
|
||||
*/
|
||||
private static function cleanUpstreamCode($code): string
|
||||
{
|
||||
if (!is_string($code)) {
|
||||
return '';
|
||||
}
|
||||
return preg_match('/^[a-z0-9_.-]{1,64}$/i', $code) === 1 ? $code : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 SSE 空行分帧;仅在完整 data frame 后 json_decode,因此可安全接收任意字节边界。
|
||||
*
|
||||
@@ -555,6 +796,8 @@ class DifyChatService
|
||||
}
|
||||
if ($event === 'error') {
|
||||
$state['upstream_error'] = true;
|
||||
// 只留可枚举的错误码用于排障;message 可能含患者资源地址,不落日志。
|
||||
$state['upstream_code'] = self::cleanUpstreamCode($decoded['code'] ?? '');
|
||||
return;
|
||||
}
|
||||
if (!in_array($event, ['message', 'agent_message'], true)) {
|
||||
@@ -645,6 +888,7 @@ class DifyChatService
|
||||
'message_id' => '',
|
||||
'emitted' => false,
|
||||
'upstream_error' => false,
|
||||
'upstream_code' => '',
|
||||
'client_aborted' => false,
|
||||
'callback_error' => false,
|
||||
'finished' => false,
|
||||
@@ -780,6 +1024,47 @@ class DifyChatService
|
||||
return (int) round((microtime(true) - $startedAt) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录上游失败的结构化定位信息。按项目约定,绝不写入凭据、上游主机名或
|
||||
* 响应正文,只保留可用于排障的协议、路径、状态码和请求规模。
|
||||
*
|
||||
* @param array<string,mixed> $requestSpec
|
||||
* @param array<string,mixed> $response
|
||||
* @param array<int,array<string,string>> $files
|
||||
* @param array<string,mixed> $formatted
|
||||
*/
|
||||
private static function logUpstreamFailure(
|
||||
array $requestSpec,
|
||||
array $response,
|
||||
string $query,
|
||||
array $files,
|
||||
array $formatted
|
||||
): void {
|
||||
if (!empty($formatted['ok'])) {
|
||||
return;
|
||||
}
|
||||
$url = (string) ($requestSpec['url'] ?? '');
|
||||
$upstreamCode = (string) ($response['upstream_code'] ?? '');
|
||||
if ($upstreamCode === '' && isset($response['body'])) {
|
||||
$decoded = json_decode((string) $response['body'], true);
|
||||
$upstreamCode = is_array($decoded)
|
||||
? self::cleanUpstreamCode($decoded['code'] ?? '')
|
||||
: '';
|
||||
}
|
||||
Log::warning('prescription ai upstream request failed', [
|
||||
'protocol' => (string) ($requestSpec['protocol'] ?? ''),
|
||||
'endpoint_path' => (string) (parse_url($url, PHP_URL_PATH) ?? ''),
|
||||
'http_code' => (int) ($response['http_code'] ?? 0),
|
||||
'curl_errno' => (int) ($response['errno'] ?? 0),
|
||||
// 上游自有错误码(如 invalid_param),用于区分附件超限、鉴权、模型故障。
|
||||
'upstream_code' => $upstreamCode,
|
||||
'query_bytes' => strlen($query),
|
||||
'file_count' => count($files),
|
||||
'error_code' => (string) ($formatted['error_code'] ?? 'UNKNOWN'),
|
||||
'latency_ms' => (int) ($formatted['latency_ms'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:false,error_code:string,error:string,latency_ms:int}
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user