} */ function parseAssistantSse(string $frame): array { $lines = preg_split('/\r\n|\r|\n/', trim($frame)) ?: []; $event = ''; $data = ''; foreach ($lines as $line) { if (str_starts_with($line, 'event: ')) { $event = substr($line, 7); } elseif (str_starts_with($line, 'data: ')) { $data .= substr($line, 6); } } $decoded = json_decode($data, true); assistantStreamExpect($event !== '' && is_array($decoded), 'SSE frame is parseable'); return ['event' => $event, 'data' => $decoded]; } $protocol = new AssistantSseProtocol(); assistantStreamExpect($protocol->encode('delta', ['text' => 'early']) === null, 'delta cannot precede start'); $start = parseAssistantSse((string) $protocol->encode('start', ['message' => 'ready'])); assistantStreamExpect($start['event'] === 'start' && $start['data']['seq'] === 1, 'start is the first event with seq 1'); assistantStreamExpect($protocol->encode('start', []) === null, 'start can only be emitted once'); $deltaOne = parseAssistantSse((string) $protocol->encode('delta', ['text' => '你'])); $deltaTwo = parseAssistantSse((string) $protocol->encode('delta', ['text' => '好'])); assistantStreamExpect($deltaOne['data']['seq'] === 2 && $deltaTwo['data']['seq'] === 3, 'delta seq is strictly monotonic'); $done = parseAssistantSse((string) $protocol->encode('done', ['answer' => '你好'])); assistantStreamExpect($done['event'] === 'done' && $done['data']['seq'] === 4, 'done is the terminal event'); assistantStreamExpect($protocol->encode('error', ['message' => 'late']) === null, 'a second terminal event is rejected'); assistantStreamExpect($protocol->encode('delta', ['text' => 'late']) === null, 'delta after terminal is rejected'); $errorProtocol = new AssistantSseProtocol(); $errorProtocol->encode('start', []); $error = parseAssistantSse((string) $errorProtocol->encode('error', [ 'code' => 'AI_ASSISTANT_FAILED', 'message' => 'AI 助手暂时不可用,请稍后重试', ])); assistantStreamExpect($error['data']['seq'] === 2 && $errorProtocol->isTerminal(), 'error is the unique alternative terminal event'); $controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php'); $logic = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/tcm/DiagnosisAiLogic.php'); $validate = file_get_contents(dirname(__DIR__) . '/app/adminapi/validate/tcm/DiagnosisValidate.php'); $auth = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php'); assistantStreamExpect(is_string($controller) && is_string($logic) && is_string($validate) && is_string($auth), 'stream implementation sources are readable'); assistantStreamExpect( str_contains($controller, 'diagnosis ai assistant sse failed'), 'unexpected SSE failures retain a privacy-safe server log entry' ); assistantStreamExpect( !str_contains($controller, "'exception_message' => \$e->getMessage()"), 'unexpected SSE failures never log raw exception messages' ); $actionStart = strpos($controller, 'public function aiAssistantStream()'); $checkAt = strpos($controller, "goCheck('aiAssistant')", $actionStart); $prepareAt = strpos($controller, 'DiagnosisAiLogic::prepareAssistant(', $actionStart); $runAt = strpos($controller, '$this->runAssistantSse($prepared)', $actionStart); $headerAt = strpos($controller, "header('Content-Type: text/event-stream; charset=utf-8')", $actionStart); assistantStreamExpect( $actionStart !== false && $checkAt > $actionStart && $prepareAt > $checkAt && $runAt > $prepareAt && $headerAt > $runAt, 'request validation and authorized preparation occur before every SSE header' ); assistantStreamExpect( str_contains($validate, "return \$this->only(['id', 'task', 'prompt']);"), 'stream reuses the strict id/task/prompt assistant scene' ); assistantStreamExpect( str_contains($logic, 'self::PERMISSION_ASSISTANT') && str_contains($logic, 'MyPatientLogic::canAccessDiagnosis') && str_contains($logic, 'streamPreparedAssistant'), 'stream preparation reuses assistant permission and canonical diagnosis row authorization' ); assistantStreamExpect( str_contains($auth, "\$accessUri === 'tcm.diagnosis/aiassistantstream'") && str_contains($auth, "'tcm.diagnosis/aiassistant', \$adminUris"), 'middleware maps stream access to the old registered assistant permission' ); $matchPermissionAlias = (new ReflectionClass(AuthMiddleware::class))->getMethod('matchPermissionAlias'); $authMiddleware = new AuthMiddleware(); assistantStreamExpect( $matchPermissionAlias->invoke( $authMiddleware, 'tcm.diagnosis/aiassistantstream', ['tcm.diagnosis/aiassistant'] ) === true, 'stream permission alias accepts the old assistant grant' ); assistantStreamExpect( $matchPermissionAlias->invoke($authMiddleware, 'tcm.diagnosis/aiassistantstream', []) === false, 'stream permission alias rejects an administrator without the old assistant grant' ); assistantStreamExpect( str_contains($controller, "'text' => \$delta") && str_contains($controller, 'DiagnosisAiLogic::getAssistantErrorCode()') && str_contains($controller, 'DiagnosisAiLogic::getError()') && str_contains($controller, 'ignore_user_abort(true)') && str_contains($controller, 'connection_aborted() === 1') && str_contains($logic, '!$deliveredDelta') && str_contains($logic, "['UPSTREAM_REJECTED', 'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE']") && str_contains($logic, 'DifyChatService::chat('), 'delta carries text, disconnects abort upstream, and pre-delta stream failures safely fall back once' ); assistantStreamExpect( str_contains($logic, 'DifyChatService::chat(') && str_contains($logic, 'DifyChatService::streamChat('), 'legacy blocking and new streaming paths coexist' ); $sensitiveNeedles = ['api_key', 'base_url', 'query', 'inputs', 'user']; foreach ($sensitiveNeedles as $needle) { assistantStreamExpect(!array_key_exists($needle, $done['data']), "done event excludes internal {$needle}"); assistantStreamExpect(!array_key_exists($needle, $error['data']), "error event excludes internal {$needle}"); } echo "Diagnosis AI assistant stream contract: OK\n";