Files
zyt/server/app/common/service/DifyWorkflowRunStatus.php
grandClaude Opus 5.5 c3c37e1650 CA证书包路径失效时退回系统证书库,修复附件上传 CONFIG_INVALID
- DifyRequiredAttachmentUpload:配置的 ca_file 不存在或不可读时记 warning 并改用系统证书库,仍校验对端证书与主机名
- DifyWorkflowRunStatus:同样处理,不再返回 RUN_STATUS_CONFIG_INVALID

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:03:47 +08:00

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];
}
}