- DifyRequiredAttachmentUpload:配置的 ca_file 不存在或不可读时记 warning 并改用系统证书库,仍校验对端证书与主机名 - DifyWorkflowRunStatus:同样处理,不再返回 RUN_STATUS_CONFIG_INVALID Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
10 KiB
PHP
209 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service;
|
|
|
|
/**
|
|
* Private, read-only adapter for AssistantRequestControl::reconcileTerminal().
|
|
* The caller supplies an original captured identity, never a URL or credential.
|
|
* No route, retry, stop request, generation, logging or control mutation lives here.
|
|
*/
|
|
final class DifyWorkflowRunStatus
|
|
{
|
|
private const MAX_TIMEOUT_MS = 1000;
|
|
private const MAX_BODY_BYTES = 1048576;
|
|
private const STATUSES = ['scheduled', 'running', 'succeeded', 'failed', 'stopped', 'partial-succeeded', 'paused'];
|
|
|
|
/**
|
|
* Only a trusted, already authorized control may consume this private result.
|
|
* Nonterminal statuses (and partial-succeeded) do not confirm the control's
|
|
* three terminal enums. Failures contain only a static code, never a run ID.
|
|
* @return array{ok:bool,workflow_run_id?:string,status?:string,error_code?:string}
|
|
*/
|
|
public static function lookup(array $identity, int $timeoutMs): array
|
|
{
|
|
$started = hrtime(true);
|
|
if (!self::validIdentity($identity)) return self::error('RUN_STATUS_IDENTITY_INVALID');
|
|
if ($timeoutMs < 1) return self::error('RUN_STATUS_TIMEOUT');
|
|
$deadline = $started + min(self::MAX_TIMEOUT_MS, $timeoutMs) * 1000000;
|
|
try {
|
|
$config = config('prescription_ai');
|
|
} catch (\Throwable $ignored) {
|
|
return self::error('RUN_STATUS_CONFIG_UNAVAILABLE');
|
|
}
|
|
if (!is_array($config)) return self::error('RUN_STATUS_CONFIG_INVALID');
|
|
if (($config['enable'] ?? false) !== true) return self::error('RUN_STATUS_CONFIG_DISABLED');
|
|
$models = $config['models'] ?? null;
|
|
$model = is_array($models) ? ($models[$identity['profile']] ?? null) : null;
|
|
$baseUrl = $config['base_url'] ?? null;
|
|
$apiKey = is_array($model) ? ($model['api_key'] ?? null) : null;
|
|
$upload = array_key_exists('required_attachment_upload', $config) ? $config['required_attachment_upload'] : [];
|
|
$caFile = is_array($upload) ? (array_key_exists('ca_file', $upload) ? $upload['ca_file'] : '') : null;
|
|
$root = is_string($baseUrl) ? self::apiRoot($baseUrl) : null;
|
|
// 可选 CA 证书包路径失效时退回系统证书库(仍校验证书),与附件上传保持一致。
|
|
if (is_string($caFile) && $caFile !== '' && (!@is_file($caFile) || !@is_readable($caFile))) $caFile = '';
|
|
if ($root === null || !is_string($apiKey) || $apiKey === '' || strlen($apiKey) > 4096
|
|
|| preg_match('/[^\x21-\x7e]/', $apiKey) !== 0
|
|
|| !is_string($caFile) || preg_match('/[\x00-\x1f\x7f]/', $caFile) !== 0) {
|
|
return self::error('RUN_STATUS_CONFIG_INVALID');
|
|
}
|
|
unset($config, $models, $model, $upload, $baseUrl);
|
|
if (!function_exists('curl_init') && !function_exists(__NAMESPACE__ . '\\curl_init')) {
|
|
return self::error('RUN_STATUS_UNAVAILABLE');
|
|
}
|
|
|
|
$result = self::request($identity, $root, $apiKey, $caFile, $deadline);
|
|
unset($apiKey, $root, $caFile);
|
|
return hrtime(true) >= $deadline ? self::error('RUN_STATUS_TIMEOUT') : $result;
|
|
}
|
|
|
|
private static function request(array $identity, string $root, string $apiKey, string $caFile, int $deadline): array
|
|
{
|
|
$handle = null;
|
|
$body = '';
|
|
$decoded = null;
|
|
$tooLarge = false;
|
|
$expired = false;
|
|
try {
|
|
if (hrtime(true) >= $deadline) return self::error('RUN_STATUS_TIMEOUT');
|
|
$handle = @curl_init();
|
|
if ($handle === false) return self::error('RUN_STATUS_TRANSPORT_ERROR');
|
|
// Include config/setup time in the caller's original budget. Never
|
|
// round a subsecond remainder up to another second or retry it.
|
|
$remainingMs = (int) floor(($deadline - hrtime(true)) / 1000000);
|
|
if ($remainingMs < 1) return self::error('RUN_STATUS_TIMEOUT');
|
|
$protocol = str_starts_with(strtolower($root), 'https:') ? CURLPROTO_HTTPS : CURLPROTO_HTTP;
|
|
$options = [
|
|
CURLOPT_URL => $root . '/workflows/run/' . $identity['workflow_run_id'],
|
|
CURLOPT_HTTPGET => true,
|
|
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $apiKey, 'Accept: application/json'],
|
|
CURLOPT_RETURNTRANSFER => false,
|
|
CURLOPT_HEADER => false,
|
|
CURLOPT_VERBOSE => false,
|
|
CURLOPT_FOLLOWLOCATION => false,
|
|
CURLOPT_MAXREDIRS => 0,
|
|
CURLOPT_PROXY => '',
|
|
CURLOPT_NOPROXY => '*',
|
|
CURLOPT_PROTOCOLS => $protocol,
|
|
CURLOPT_REDIR_PROTOCOLS => $protocol,
|
|
CURLOPT_NETRC => CURL_NETRC_IGNORED,
|
|
CURLOPT_UNRESTRICTED_AUTH => false,
|
|
CURLOPT_SSL_VERIFYPEER => true,
|
|
CURLOPT_SSL_VERIFYHOST => 2,
|
|
CURLOPT_TIMEOUT_MS => $remainingMs,
|
|
CURLOPT_CONNECTTIMEOUT_MS => $remainingMs,
|
|
CURLOPT_NOSIGNAL => true,
|
|
CURLOPT_NOPROGRESS => false,
|
|
CURLOPT_WRITEFUNCTION => static function ($unused, string $chunk) use (&$body, &$tooLarge, &$expired, $deadline): int {
|
|
if (hrtime(true) >= $deadline) {
|
|
$expired = true;
|
|
return 0;
|
|
}
|
|
$length = strlen($chunk);
|
|
if ($length > self::MAX_BODY_BYTES - strlen($body)) {
|
|
$tooLarge = true;
|
|
$body = '';
|
|
return 0;
|
|
}
|
|
$body .= $chunk;
|
|
return $length;
|
|
},
|
|
];
|
|
if ($caFile !== '') $options[CURLOPT_CAINFO] = $caFile;
|
|
foreach (['CURLOPT_XFERINFOFUNCTION', 'CURLOPT_PROGRESSFUNCTION'] as $name) {
|
|
if (!defined($name)) continue;
|
|
$options[constant($name)] = static function (...$unused) use (&$expired, $deadline): int {
|
|
if (hrtime(true) < $deadline) return 0;
|
|
$expired = true;
|
|
return 1;
|
|
};
|
|
break;
|
|
}
|
|
if (!@curl_setopt_array($handle, $options)) return self::error('RUN_STATUS_TRANSPORT_ERROR');
|
|
unset($apiKey, $options, $root, $caFile);
|
|
if (hrtime(true) >= $deadline) return self::error('RUN_STATUS_TIMEOUT');
|
|
$executed = @curl_exec($handle);
|
|
$errno = @curl_errno($handle);
|
|
$httpCode = @curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
|
if ($expired || hrtime(true) >= $deadline || $errno === CURLE_OPERATION_TIMEDOUT) {
|
|
return self::error('RUN_STATUS_TIMEOUT');
|
|
}
|
|
if ($tooLarge) return self::error('RUN_STATUS_RESPONSE_TOO_LARGE');
|
|
if ($executed !== true || $errno !== 0) return self::error('RUN_STATUS_TRANSPORT_ERROR');
|
|
if ($httpCode !== 200) return self::error('RUN_STATUS_HTTP_ERROR');
|
|
try {
|
|
// This official response includes sensitive inputs/outputs. Parse
|
|
// only bounded memory, select two fields, then discard everything.
|
|
$decoded = json_decode($body, true, 64, JSON_THROW_ON_ERROR);
|
|
} catch (\Throwable $ignored) {
|
|
return self::error('RUN_STATUS_RESPONSE_INVALID');
|
|
}
|
|
$body = '';
|
|
if (hrtime(true) >= $deadline) return self::error('RUN_STATUS_TIMEOUT');
|
|
if (!is_array($decoded) || !is_string($decoded['id'] ?? null)
|
|
|| $decoded['id'] !== $identity['workflow_run_id']) {
|
|
return self::error('RUN_STATUS_IDENTITY_MISMATCH');
|
|
}
|
|
// Optional identity fields must agree if a deployment supplies them.
|
|
foreach (['task_id', 'user', 'profile', 'workflow_run_id'] as $field) {
|
|
if (array_key_exists($field, $decoded) && $decoded[$field] !== $identity[$field]) {
|
|
return self::error('RUN_STATUS_IDENTITY_MISMATCH');
|
|
}
|
|
}
|
|
if (!in_array($decoded['status'] ?? null, self::STATUSES, true)) {
|
|
return self::error('RUN_STATUS_STATUS_UNCONFIRMED');
|
|
}
|
|
return ['ok' => true, 'workflow_run_id' => $identity['workflow_run_id'], 'status' => $decoded['status']];
|
|
} catch (\Throwable $ignored) {
|
|
return self::error('RUN_STATUS_TRANSPORT_ERROR');
|
|
} finally {
|
|
$body = '';
|
|
$decoded = null;
|
|
unset($apiKey, $options, $root, $caFile);
|
|
if ($handle !== null && $handle !== false) {
|
|
try { @curl_close($handle); } catch (\Throwable $ignored) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static function validIdentity(array $identity): bool
|
|
{
|
|
return in_array($identity['profile'] ?? null, ['qwen', 'openai'], true)
|
|
&& is_string($identity['task_id'] ?? null) && self::validId($identity['task_id'])
|
|
&& is_string($identity['workflow_run_id'] ?? null) && self::validId($identity['workflow_run_id'])
|
|
&& is_string($identity['user'] ?? null) && strlen($identity['user']) <= 128
|
|
&& preg_match('/[\x00-\x1f\x7f]/', $identity['user']) === 0
|
|
&& is_int($identity['generation'] ?? null) && $identity['generation'] > 0;
|
|
}
|
|
|
|
private static function validId(string $id): bool
|
|
{
|
|
return preg_match('/\A[A-Za-z0-9_-]{1,128}\z/', $id) === 1;
|
|
}
|
|
|
|
/** Derive one configured API root; never probe endpoints or switch protocol. */
|
|
private static function apiRoot(string $base): ?string
|
|
{
|
|
if ($base === '' || strlen($base) > 2048 || preg_match('/[\x00-\x20\x7f\\\\]/', $base) !== 0
|
|
|| filter_var($base, FILTER_VALIDATE_URL) === false) return null;
|
|
$parts = parse_url($base);
|
|
if (!is_array($parts) || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
|
|
|| empty($parts['host'])) return null;
|
|
foreach (['user', 'pass', 'query', 'fragment'] as $field) {
|
|
if (array_key_exists($field, $parts)) return null;
|
|
}
|
|
$path = $parts['path'] ?? '';
|
|
if (preg_match('~\A(?:/[A-Za-z0-9_-]+)*/?\z~', $path) !== 1
|
|
|| preg_match('~/chat/completions(?:/|$)~i', $path) === 1) return null;
|
|
$base = rtrim($base, '/');
|
|
if (str_ends_with($base, '/chat-messages')) $base = substr($base, 0, -strlen('/chat-messages'));
|
|
return $base;
|
|
}
|
|
|
|
private static function error(string $code): array
|
|
{
|
|
return ['ok' => false, 'error_code' => $code];
|
|
}
|
|
}
|