379 lines
28 KiB
PHP
379 lines
28 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
// These doubles are installed before autoload. No DB, HTTP socket, .env or patient image is used.
|
|
namespace app\common\service {
|
|
function config($key) { return $GLOBALS['tongueConfig']; }
|
|
function is_readable($path): bool {
|
|
// Windows sandbox ACL probes can report false for readable fixtures.
|
|
return $path === ($GLOBALS['tongueReadableCa'] ?? null) || \is_readable($path);
|
|
}
|
|
function microtime($asFloat = false) { return $GLOBALS['tongueClock']; }
|
|
function curl_init() { return (object) ['options' => [], 'status' => 0, 'errno' => 0]; }
|
|
function curl_setopt_array($handle, array $options): bool { $handle->options = $options; return true; }
|
|
function curl_exec($handle) {
|
|
$GLOBALS['tongueCalls'][] = $handle->options;
|
|
if ($GLOBALS['tongueReplies'] === []) throw new \RuntimeException('Unexpected upstream call');
|
|
$reply = array_shift($GLOBALS['tongueReplies']);
|
|
$handle->status = $reply['status'] ?? 200;
|
|
$handle->errno = $reply['errno'] ?? 0;
|
|
$GLOBALS['tongueClock'] += $reply['seconds'] ?? 0;
|
|
$body = $reply['body'] ?? '{}';
|
|
$written = ($handle->options[CURLOPT_WRITEFUNCTION])($handle, $body);
|
|
return $written === strlen($body) && $handle->errno === 0;
|
|
}
|
|
function curl_getinfo($handle, $option) { return $handle->status; }
|
|
function curl_errno($handle) { return $handle->errno; }
|
|
function curl_close($handle): void { ++$GLOBALS['tongueClosed']; }
|
|
}
|
|
|
|
namespace {
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
|
|
|
use app\adminapi\controller\tcm\DiagnosisController;
|
|
use app\adminapi\http\middleware\AuthMiddleware;
|
|
use app\adminapi\logic\tcm\TongueScreenshotLogic;
|
|
use app\common\service\DifyTongueScreenshotService;
|
|
use app\common\service\TongueScreenshotImage;
|
|
use think\file\UploadedFile;
|
|
|
|
final class TongueAuthDouble {
|
|
public static array $uris = [];
|
|
public function __construct($adminId = 0) {}
|
|
public function getAdminUri(): array { return self::$uris; }
|
|
public function getAllUri(): array { throw new RuntimeException('Screenshot must not use the unregistered-route bypass'); }
|
|
}
|
|
final class TongueScopeDouble {
|
|
public static array $seen = [];
|
|
public static function canAccessDiagnosis(int $id, int $adminId, array $adminInfo): bool {
|
|
self::$seen[] = [$id, $adminId]; return $id === 41 && $adminId === 7;
|
|
}
|
|
}
|
|
final class TongueLoginDouble {
|
|
public static function adminMustBindWorkWechat(array $info): bool { return false; }
|
|
}
|
|
class_alias(TongueAuthDouble::class, 'app\\common\\cache\\AdminAuthCache');
|
|
class_alias(TongueScopeDouble::class, 'app\\adminapi\\logic\\firstvisit\\MyPatientLogic');
|
|
class_alias(TongueLoginDouble::class, 'app\\adminapi\\logic\\LoginLogic');
|
|
|
|
final class TongueRequest extends \app\Request {
|
|
public array $uploads = [];
|
|
public function file(string $name = '') { return $name === '' ? $this->uploads : ($this->uploads[$name] ?? null); }
|
|
}
|
|
$request = (new TongueRequest())->withServer(['REQUEST_METHOD' => 'POST', 'REMOTE_ADDR' => '127.0.0.1']);
|
|
$container = \think\Container::getInstance();
|
|
$container->instance('request', $request);
|
|
$container->instance(\think\Request::class, $request);
|
|
$container->instance(\think\Cookie::class, new \think\Cookie($request));
|
|
|
|
$checks = 0;
|
|
$expect = static function (bool $ok, string $label) use (&$checks): void {
|
|
if (!$ok) throw new RuntimeException('FAIL: ' . $label);
|
|
++$checks;
|
|
};
|
|
$config = ['enable' => true, 'base_url' => 'https://dify.fixture.test/v1',
|
|
'models' => ['qwen' => ['api_key' => 'fixture-only-secret', 'label' => '千问'],
|
|
'openai' => ['api_key' => 'fixture-only-openai-secret', 'label' => 'OpenAI']],
|
|
'required_attachment_upload' => ['ca_file' => '']];
|
|
$valid = ['image_quality' => 'limited', 'quality_note' => '舌头可见,舌根被遮挡',
|
|
'features' => [['name' => '舌苔颜色', 'observation' => '可见部分偏白']], 'limitations' => ['舌根无法观察'],
|
|
'tcm_diagnosis' => ['status' => 'possible_patterns', 'summary' => '仅供参考,可考虑寒湿方向,需结合问诊和脉象复核',
|
|
'candidates' => [['name' => '寒湿倾向', 'basis' => ['舌苔颜色'], 'verify' => ['需询问有无畏寒并检查脉象']]],
|
|
'missing_information' => ['缺少症状问诊和脉象检查']]];
|
|
$reset = static function (array $replies = []) use ($config): void {
|
|
$GLOBALS['tongueConfig'] = $config; $GLOBALS['tongueCalls'] = [];
|
|
$GLOBALS['tongueReplies'] = $replies; $GLOBALS['tongueClock'] = 1000.0; $GLOBALS['tongueClosed'] = 0;
|
|
$GLOBALS['tongueReadableCa'] = null;
|
|
};
|
|
$successReplies = static fn (): array => [['body' => '{"id":"synthetic-file-123"}', 'seconds' => 2],
|
|
['body' => json_encode(['answer' => json_encode($valid, JSON_UNESCAPED_UNICODE)], JSON_UNESCAPED_UNICODE)]];
|
|
$answerReply = static fn (string $answer, int $seconds = 0): array => [
|
|
'body' => json_encode(['answer' => $answer], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'seconds' => $seconds];
|
|
$paths = [];
|
|
$upload = static function () use (&$paths): UploadedFile {
|
|
$path = tempnam(sys_get_temp_dir(), 'tongue-contract-'); $paths[] = $path;
|
|
$canvas = imagecreatetruecolor(128, 128);
|
|
imagefill($canvas, 0, 0, imagecolorallocate($canvas, 170, 95, 95));
|
|
imagejpeg($canvas, $path); imagedestroy($canvas);
|
|
return new UploadedFile($path, 'patient-secret-name.jpg', 'image/jpeg', UPLOAD_ERR_OK, true);
|
|
};
|
|
$body = ['id' => '41', 'request_id' => '12345678-1234-4234-8234-123456789abc'];
|
|
$controller = static function (array $params, array $files, string $method = 'POST', int $actor = 7, array $info = ['root' => 0]): array {
|
|
$req = (new TongueRequest())->withServer(['REQUEST_METHOD' => $method])->withPost($params);
|
|
$req->uploads = $files;
|
|
$controller = (new ReflectionClass(DiagnosisController::class))->newInstanceWithoutConstructor();
|
|
foreach (['request' => $req, 'adminId' => $actor, 'adminInfo' => $info] as $name => $value) {
|
|
$prop = new ReflectionProperty($controller, $name); $prop->setAccessible(true); $prop->setValue($controller, $value);
|
|
}
|
|
return $controller->aiTongueScreenshot()->getData();
|
|
};
|
|
try {
|
|
// Exercise the real controller, logic, image validation, payload, transport and parser together.
|
|
TongueAuthDouble::$uris = ['tcm.diagnosis/aiAssistant']; $reset($successReplies());
|
|
$file = $upload(); $digest = hash_file('sha256', $file->getPathname());
|
|
$result = $controller($body, ['file' => $file]);
|
|
$expect($result['code'] === 1 && $result['show'] === 0, 'standard success envelope');
|
|
$expect($result['data'] === ['request_id' => $body['request_id'], 'diagnosis_id' => 41,
|
|
'image_sha256' => $digest, 'analysis' => $valid, 'model' => 'qwen', 'model_label' => '千问'], 'exact request/image/diagnosis response contract and optional model defaults to qwen');
|
|
$expect(!is_file($file->getPathname()), 'temporary JPEG removed after success');
|
|
$expect(TongueScopeDouble::$seen === [[41, 7]], 'only requested diagnosis authorization queried');
|
|
$expect(count($GLOBALS['tongueCalls']) === 2 && $GLOBALS['tongueClosed'] === 2, 'one upload and one model call with closed handles');
|
|
[$up, $chat] = $GLOBALS['tongueCalls'];
|
|
$expect($up[CURLOPT_URL] === $config['base_url'] . '/files/upload', 'private upload uses configured Dify endpoint');
|
|
$expect($up[CURLOPT_POSTFIELDS]['file'] instanceof CURLFile
|
|
&& $up[CURLOPT_POSTFIELDS]['file']->getPostFilename() === 'video-screenshot.jpg'
|
|
&& $up[CURLOPT_POSTFIELDS]['file']->getMimeType() === 'image/jpeg', 'real JPEG bytes use neutral upload filename');
|
|
$payload = json_decode($chat[CURLOPT_POSTFIELDS], true);
|
|
$expect($chat[CURLOPT_URL] === $config['base_url'] . '/chat-messages'
|
|
&& $payload['files'] === [['type' => 'image', 'transfer_method' => 'local_file', 'upload_file_id' => 'synthetic-file-123']], 'model must receive private uploaded image');
|
|
$expect($payload['response_mode'] === 'blocking' && $payload['auto_generate_name'] === false
|
|
&& !isset($payload['conversation_id']) && json_decode($chat[CURLOPT_POSTFIELDS])->inputs instanceof stdClass, 'no title task, conversation history or chart inputs');
|
|
$expect($payload['user'] === $up[CURLOPT_POSTFIELDS]['user'] && preg_match('/\Atongue-screenshot-[a-f0-9]{32}\z/', $payload['user']) === 1,
|
|
'request-local opaque Dify user links only this upload and analysis');
|
|
$expect(!str_contains($chat[CURLOPT_POSTFIELDS], 'patient-secret') && !str_contains($chat[CURLOPT_POSTFIELDS], $body['request_id'])
|
|
&& !str_contains($chat[CURLOPT_POSTFIELDS], 'fixture-only-secret'), 'no patient filename, native identity or secret in model body');
|
|
foreach ([$up, $chat] as $options) {
|
|
$expect(in_array('Authorization: Bearer fixture-only-secret', $options[CURLOPT_HTTPHEADER], true), 'default qwen profile owns upload and chat');
|
|
$expect($options[CURLOPT_FOLLOWLOCATION] === false && $options[CURLOPT_PROXY] === ''
|
|
&& $options[CURLOPT_SSL_VERIFYPEER] === true && $options[CURLOPT_SSL_VERIFYHOST] === 2, 'no redirect/proxy credential leak and TLS remains verified');
|
|
$expect($options[CURLOPT_TIMEOUT_MS] > 0 && $options[CURLOPT_TIMEOUT_MS] <= 50000
|
|
&& $options[CURLOPT_CONNECTTIMEOUT_MS] <= 8000, 'finite request and connect budgets');
|
|
}
|
|
$expect($chat[CURLOPT_TIMEOUT_MS] === 48000, 'upload time deducted from single total deadline');
|
|
|
|
foreach (['qwen' => '千问', 'openai' => 'GPT'] as $model => $label) {
|
|
$uploadId = 'synthetic-' . $model . '-file';
|
|
$replies = $successReplies(); $replies[0]['body'] = json_encode(['id' => $uploadId]);
|
|
$reset($replies); $file = $upload(); $filePath = $file->getRealPath();
|
|
$digest = hash_file('sha256', $filePath);
|
|
$result = $controller(array_replace($body, ['model' => $model]), ['file' => $file]);
|
|
$expect($result['code'] === 1 && $result['data']['model'] === $model
|
|
&& $result['data']['model_label'] === $label && $result['data']['image_sha256'] === $digest
|
|
&& $result['data']['analysis'] === $valid, 'explicit model returns its identity and image-bound tentative TCM response');
|
|
$expect(count($GLOBALS['tongueCalls']) === 2 && !is_file($filePath), 'each model request performs only one upload and chat, with temporary JPEG cleanup');
|
|
[$modelUpload, $modelChat] = $GLOBALS['tongueCalls'];
|
|
foreach ([$modelUpload, $modelChat] as $options) {
|
|
$expect(in_array('Authorization: Bearer ' . $config['models'][$model]['api_key'], $options[CURLOPT_HTTPHEADER], true),
|
|
'selected model credentials used for both private upload and analysis');
|
|
}
|
|
$modelPayload = json_decode($modelChat[CURLOPT_POSTFIELDS], true);
|
|
$expect($modelUpload[CURLOPT_POSTFIELDS]['file']->getFilename() === $filePath
|
|
&& $modelPayload['files'] === [['type' => 'image', 'transfer_method' => 'local_file', 'upload_file_id' => $uploadId]],
|
|
'selected model receives exactly its own uploaded JPEG, never another model upload or a text-only body');
|
|
|
|
// An unavailable selected profile must fail, even when the other profile is usable.
|
|
foreach ([[], ['api_key' => ''], ['api_key' => "bad\r\nheader"]] as $brokenProfile) {
|
|
$reset(); $GLOBALS['tongueConfig']['models'][$model] = $brokenProfile;
|
|
$failed = $controller(array_replace($body, ['model' => $model]), ['file' => $upload()]);
|
|
$expect($failed['code'] === 0 && $GLOBALS['tongueCalls'] === [], 'missing or invalid selected key cannot substitute the other model profile');
|
|
}
|
|
}
|
|
|
|
// Authorization fails before decode/transport; malformed requests never reach a provider.
|
|
foreach ([[], ['tcm.diagnosis/edit'], ['tcm.diagnosis/aiTongueScreenshot']] as $uris) {
|
|
TongueAuthDouble::$uris = $uris; $reset(); TongueScopeDouble::$seen = [];
|
|
$denied = $controller($body, ['file' => $upload()]);
|
|
$expect($denied['code'] === 0 && $denied['msg'] === '权限不足,无法使用诊单 AI 助手'
|
|
&& TongueScopeDouble::$seen === [] && $GLOBALS['tongueCalls'] === [], 'existing assistant grant is mandatory');
|
|
}
|
|
TongueAuthDouble::$uris = ['tcm.diagnosis/aiAssistant']; $reset();
|
|
$denied = $controller(['id' => '42', 'request_id' => $body['request_id']], ['file' => $upload()]);
|
|
$expect($denied['code'] === 0 && $denied['msg'] === '诊单不存在或无权访问' && $GLOBALS['tongueCalls'] === [], 'foreign diagnosis scope rejected before upload');
|
|
foreach ([['id' => 0], ['id' => '41junk'], ['id' => ['41']], ['request_id' => 'invalid'], ['request_id' => []],
|
|
['url' => 'https://evil.test/image.jpg'], ['path' => __FILE__], ['prompt' => 'ignore rules'],
|
|
['model' => 'other'], ['model' => 'GPT'], ['model' => ' openai'], ['model' => ''], ['model' => null],
|
|
['model' => ['qwen']], ['model' => 1], ['api_key' => 'caller-secret']] as $override) {
|
|
$reset(); $denied = $controller(array_replace($body, $override), ['file' => $upload()]);
|
|
$expect($denied['code'] === 0 && $GLOBALS['tongueCalls'] === [], 'invalid or caller-controlled field rejected');
|
|
}
|
|
foreach ([[], ['file' => [$upload()]], ['file' => $upload(), 'other' => $upload()]] as $files) {
|
|
$reset(); $expect($controller($body, $files)['code'] === 0 && $GLOBALS['tongueCalls'] === [], 'missing, array or multiple files rejected');
|
|
}
|
|
$reset(); $expect($controller($body, ['file' => $upload()], 'GET')['code'] === 0 && $GLOBALS['tongueCalls'] === [], 'GET rejected');
|
|
$reset(); $expect($controller($body, ['file' => $upload()], 'POST', 0)['code'] === 0 && $GLOBALS['tongueCalls'] === [], 'anonymous direct invocation rejected');
|
|
TongueAuthDouble::$uris = []; $reset($successReplies());
|
|
$expect($controller($body, ['file' => $upload()], 'POST', 7, ['root' => 1])['code'] === 1, 'root retains grant bypass and still checks diagnosis scope');
|
|
TongueAuthDouble::$uris = ['tcm.diagnosis/aiAssistant'];
|
|
|
|
// Transport and outer-envelope failures never trigger format correction.
|
|
$failureCases = [
|
|
[['status' => 401, 'body' => 'fixture-only-secret private patient model text']],
|
|
[['status' => 413, 'body' => '{"error":"file rejected"}']],
|
|
[['body' => '{"id":""}']],
|
|
[['body' => '{"id":"synthetic-file-123"}', 'seconds' => 51]],
|
|
[['body' => '{"id":"synthetic-file-123"}'], ['errno' => CURLE_OPERATION_TIMEDOUT]],
|
|
[['body' => '{"id":"synthetic-file-123"}'], ['status' => 422, 'body' => 'invalid inputs: fixture-only-secret']],
|
|
[['body' => '{"id":"synthetic-file-123"}'], ['body' => str_repeat('x', 65537)]],
|
|
[['body' => '{"id":"synthetic-file-123"}'], ['status' => 401, 'body' => 'fixture-only-secret']],
|
|
[['body' => '{"id":"synthetic-file-123"}'], ['body' => 'not-json']],
|
|
[['body' => '{"id":"synthetic-file-123"}'], ['body' => 'null']],
|
|
[['body' => '{"id":"synthetic-file-123"}'], ['body' => '{}']],
|
|
[['body' => '{"id":"synthetic-file-123"}'], ['body' => '{"answer":42}']],
|
|
[['body' => '{"id":"synthetic-file-123"}'], $answerReply(str_repeat('x', 32769))],
|
|
];
|
|
foreach ($failureCases as $replies) {
|
|
$reset($replies); $file = $upload(); $failed = $controller($body, ['file' => $file]);
|
|
$expect($failed['code'] === 0 && !str_contains(json_encode($failed), 'fixture-only-secret')
|
|
&& !str_contains(json_encode($failed), 'private patient') && !is_file($file->getPathname()), 'safe failure envelope and local cleanup');
|
|
$expect(count($GLOBALS['tongueCalls']) <= count($replies) && count($GLOBALS['tongueCalls']) <= 2, 'transport or envelope failure never starts correction or image-free fallback');
|
|
}
|
|
foreach (['qwen' => '千问', 'openai' => 'GPT'] as $profile => $label) {
|
|
$reset([['body' => '{"id":"synthetic-file-123"}'], ['status' => 400, 'body' => json_encode([
|
|
'code' => 'invalid_param', 'message' => 'Run failed: [models] Error: API request failed with status code 404: 404 page not found fixture-only-secret'])]]);
|
|
$failed = $controller(array_replace($body, ['model' => $profile]), ['file' => $upload()]);
|
|
$expect($failed['code'] === 0 && $failed['msg'] === $label . ' 模型接口配置异常,请管理员检查 Dify 中的模型 API 地址。'
|
|
&& count($GLOBALS['tongueCalls']) === 2, 'known upstream model 404 receives actionable safe configuration message without provider detail or retry');
|
|
}
|
|
$absent = ['image_quality' => 'unusable', 'quality_note' => '主画面未见舌头', 'features' => [], 'limitations' => ['只显示脸部'],
|
|
'tcm_diagnosis' => ['status' => 'insufficient', 'summary' => '图片依据不足,无法提出辨证方向',
|
|
'candidates' => [], 'missing_information' => ['缺少清晰舌象、症状问诊和脉象检查']]];
|
|
$reset([['body' => '{"id":"synthetic-file-123"}'], ['body' => json_encode(['answer' => json_encode($absent)])]]);
|
|
$expect($controller($body, ['file' => $upload()])['data']['analysis'] === $absent, 'no tongue remains valid empty observation, not a fabricated finding');
|
|
$unusableCandidate = $absent; $unusableCandidate['tcm_diagnosis'] = $valid['tcm_diagnosis'];
|
|
$unsupportedBasis = $valid; $unsupportedBasis['tcm_diagnosis']['candidates'][0]['basis'] = ['舌体颜色'];
|
|
$certainDiagnosis = $valid; $certainDiagnosis['tcm_diagnosis']['summary'] = '确诊为寒湿证';
|
|
$inventedPulse = $valid; $inventedPulse['tcm_diagnosis']['summary'] = '患者伴有乏力,脉象为弦滑';
|
|
$normalCandidate = $valid; $normalCandidate['tcm_diagnosis']['candidates'][0]['name'] = '正常';
|
|
$clinicalLimitation = $valid; $clinicalLimitation['limitations'] = ['缺少脉象资料'];
|
|
$invalidAnswers = ['诊断:湿热,建议用药'];
|
|
foreach ([$unusableCandidate, $unsupportedBasis, $certainDiagnosis, $inventedPulse, $normalCandidate, $clinicalLimitation] as $invalid) {
|
|
$invalidAnswers[] = json_encode($invalid, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
|
}
|
|
foreach ($invalidAnswers as $invalidAnswer) {
|
|
$reset([['body' => '{"id":"synthetic-file-123"}'], $answerReply($invalidAnswer), $answerReply($invalidAnswer)]);
|
|
$file = $upload(); $failed = $controller(array_replace($body, ['model' => 'openai']), ['file' => $file]);
|
|
$expect($failed['code'] === 0 && $failed['msg'] === '截图分析结果格式无效,请重新截图后重试'
|
|
&& count($GLOBALS['tongueCalls']) === 3 && $GLOBALS['tongueClosed'] === 3 && !is_file($file->getPathname()),
|
|
'invalid answer and invalid correction fail safely after exactly two chats, without fallback or retained image');
|
|
}
|
|
|
|
// Parser rejection permits one same-image regeneration, without replaying model content.
|
|
$rejectedMarker = 'fixture-only-rejected-answer-never-replay';
|
|
$rejectedData = $normalCandidate; $rejectedData['quality_note'] = '舌头可见,' . $rejectedMarker;
|
|
$rejectedAnswer = json_encode($rejectedData, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
|
$correctedAnswer = json_encode($valid, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
|
foreach (['qwen' => '千问', 'openai' => 'GPT'] as $model => $label) {
|
|
$reset([['body' => '{"id":"synthetic-file-123"}', 'seconds' => 2],
|
|
$answerReply($rejectedAnswer, 6), $answerReply($correctedAnswer)]);
|
|
$file = $upload(); $digest = hash_file('sha256', $file->getPathname());
|
|
$result = $controller(array_replace($body, ['model' => $model]), ['file' => $file]);
|
|
$expect($result['code'] === 1 && $result['data'] === ['request_id' => $body['request_id'], 'diagnosis_id' => 41,
|
|
'image_sha256' => $digest, 'analysis' => $valid, 'model' => $model, 'model_label' => $label],
|
|
'valid correction preserves the exact image, request, model and result contract');
|
|
$expect(count($GLOBALS['tongueCalls']) === 3 && $GLOBALS['tongueClosed'] === 3 && !is_file($file->getPathname()),
|
|
'valid correction performs one upload and two closed model calls then removes the temporary image');
|
|
[$up, $firstChat, $correctionChat] = $GLOBALS['tongueCalls'];
|
|
$firstPayload = json_decode($firstChat[CURLOPT_POSTFIELDS], true);
|
|
$correctionPayload = json_decode($correctionChat[CURLOPT_POSTFIELDS], true);
|
|
$expect($correctionChat[CURLOPT_URL] === $firstChat[CURLOPT_URL]
|
|
&& $firstPayload['files'] === $correctionPayload['files']
|
|
&& $correctionPayload['user'] === $firstPayload['user']
|
|
&& $correctionPayload['user'] === $up[CURLOPT_POSTFIELDS]['user']
|
|
&& json_decode($correctionChat[CURLOPT_POSTFIELDS])->inputs instanceof stdClass
|
|
&& !isset($correctionPayload['conversation_id']), 'correction reuses the same private upload and anonymous user without history');
|
|
$expect(array_diff_key($correctionPayload, $firstPayload) === []
|
|
&& array_diff_key($firstPayload, $correctionPayload) === []
|
|
&& array_replace($correctionPayload, ['query' => $firstPayload['query']]) === $firstPayload,
|
|
'only the fixed query changes between analysis and correction');
|
|
$expect(str_starts_with($correctionPayload['query'], $firstPayload['query'] . "\n")
|
|
&& str_contains($correctionPayload['query'], '唯一一次格式纠正')
|
|
&& str_contains($correctionPayload['query'], 'limitations 只写')
|
|
&& str_contains($correctionPayload['query'], 'tcm_diagnosis.missing_information')
|
|
&& str_contains($correctionPayload['query'], '正常、健康、无异常')
|
|
&& str_contains($correctionPayload['query'], '保留原图中实际可见的 features')
|
|
&& !str_contains($correctionChat[CURLOPT_POSTFIELDS], $rejectedMarker),
|
|
'fixed correction rules preserve clinical boundaries and never contain the rejected answer');
|
|
$expect($firstChat[CURLOPT_TIMEOUT_MS] === 48000 && $correctionChat[CURLOPT_TIMEOUT_MS] === 42000,
|
|
'both upload and initial analysis time are deducted from the unchanged total deadline');
|
|
foreach ([$up, $firstChat, $correctionChat] as $options) {
|
|
$expect(in_array('Authorization: Bearer ' . $config['models'][$model]['api_key'], $options[CURLOPT_HTTPHEADER], true)
|
|
&& $options[CURLOPT_FOLLOWLOCATION] === false && $options[CURLOPT_PROXY] === ''
|
|
&& $options[CURLOPT_SSL_VERIFYPEER] === true && $options[CURLOPT_SSL_VERIFYHOST] === 2,
|
|
'correction keeps the selected profile credentials and transport protections');
|
|
}
|
|
}
|
|
|
|
foreach ([46, 48] as $analysisSeconds) {
|
|
$reset([['body' => '{"id":"synthetic-file-123"}', 'seconds' => 2], $answerReply($rejectedMarker, $analysisSeconds)]);
|
|
$file = $upload(); $failed = $controller($body, ['file' => $file]);
|
|
$expect($failed['code'] === 0 && $failed['msg'] === '截图分析结果格式无效,请重新截图后重试'
|
|
&& count($GLOBALS['tongueCalls']) === 2 && $GLOBALS['tongueClosed'] === 2 && !is_file($file->getPathname()),
|
|
'less than three seconds remaining preserves the original parser error without another call');
|
|
}
|
|
$reset([['body' => '{"id":"synthetic-file-123"}', 'seconds' => 2],
|
|
$answerReply($rejectedMarker, 45), $answerReply($correctedAnswer)]);
|
|
$expect($controller($body, ['file' => $upload()])['code'] === 1
|
|
&& count($GLOBALS['tongueCalls']) === 3 && $GLOBALS['tongueCalls'][2][CURLOPT_TIMEOUT_MS] === 3000,
|
|
'exactly three seconds remaining permits a correction without extending the deadline');
|
|
|
|
foreach ([['status' => 401, 'body' => 'fixture-only-secret private patient model text'],
|
|
['status' => 400, 'body' => json_encode(['code' => 'invalid_param',
|
|
'message' => 'Run failed: [models] Error: status code 404 fixture-only-secret'])],
|
|
['errno' => CURLE_OPERATION_TIMEDOUT], ['body' => 'not-json'], ['body' => '{"answer":null}'],
|
|
$answerReply(str_repeat('x', 32769))] as $correctionFailure) {
|
|
$reset([['body' => '{"id":"synthetic-file-123"}'], $answerReply($rejectedMarker), $correctionFailure]);
|
|
$file = $upload(); $failed = $controller($body, ['file' => $file]);
|
|
$expect($failed['code'] === 0 && count($GLOBALS['tongueCalls']) === 3 && $GLOBALS['tongueClosed'] === 3
|
|
&& !is_file($file->getPathname()) && !str_contains(json_encode($failed), 'fixture-only-secret')
|
|
&& !str_contains(json_encode($failed), 'private patient') && !str_contains(json_encode($failed), $rejectedMarker),
|
|
'correction transport or envelope failure ends safely without a third chat or retained image');
|
|
}
|
|
|
|
foreach ([['enable' => false], ['base_url' => 'https://evil.test/v1?leak=yes'],
|
|
['base_url' => 'https://user:password@evil.test'], ['base_url' => 'file:///private'],
|
|
['base_url' => 'https://example.test/v1/chat/completions'],
|
|
['models' => ['qwen' => ['api_key' => "key\r\nX-Injection: yes"]]]] as $override) {
|
|
$reset(); $GLOBALS['tongueConfig'] = array_replace($config, $override);
|
|
$expect($controller($body, ['file' => $upload()])['code'] === 0 && $GLOBALS['tongueCalls'] === [], 'invalid service configuration fails before network');
|
|
}
|
|
$reset($successReplies());
|
|
$GLOBALS['tongueConfig']['required_attachment_upload']['ca_file'] = __DIR__ . '/missing-optional-trust-store.pem';
|
|
$expect($controller($body, ['file' => $upload()])['code'] === 1, 'stale optional CA path uses system trust like the original transport');
|
|
foreach ($GLOBALS['tongueCalls'] as $call) {
|
|
$expect(!isset($call[CURLOPT_CAINFO]) && $call[CURLOPT_SSL_VERIFYPEER] === true && $call[CURLOPT_SSL_VERIFYHOST] === 2,
|
|
'system CA fallback preserves peer and hostname verification for upload and analysis');
|
|
}
|
|
$reset($successReplies());
|
|
$GLOBALS['tongueReadableCa'] = __FILE__;
|
|
$GLOBALS['tongueConfig']['required_attachment_upload']['ca_file'] = __FILE__;
|
|
$expect($controller($body, ['file' => $upload()])['code'] === 1
|
|
&& $GLOBALS['tongueCalls'][0][CURLOPT_CAINFO] === __FILE__ && $GLOBALS['tongueCalls'][1][CURLOPT_CAINFO] === __FILE__,
|
|
'existing readable configured trust bundle is kept for both requests');
|
|
foreach (['https://dify.fixture.test', 'https://dify.fixture.test/v1/chat-messages'] as $base) {
|
|
$reset($successReplies()); $GLOBALS['tongueConfig']['base_url'] = $base;
|
|
$expect($controller($body, ['file' => $upload()])['code'] === 1
|
|
&& $GLOBALS['tongueCalls'][0][CURLOPT_URL] === 'https://dify.fixture.test/v1/files/upload', 'existing Dify base URL forms supported');
|
|
}
|
|
|
|
// Real auth middleware, with menu lookup deliberately explosive: exact shared grant only.
|
|
$route = new class {
|
|
public array $adminInfo = ['admin_id' => 7, 'root' => 0, 'login_ip' => '127.0.0.1'];
|
|
public object $controllerObject;
|
|
public function __construct() { $this->controllerObject = new class { public function isNotNeedLogin(): bool { return false; } }; }
|
|
public function controller(): string { return 'tcm.Diagnosis'; }
|
|
public function action(): string { return 'aiTongueScreenshot'; }
|
|
public function param($name, $default = null) { return $default; }
|
|
};
|
|
$middleware = new AuthMiddleware();
|
|
foreach ([[], ['tcm.diagnosis/edit'], ['tcm.diagnosis/aiTongueScreenshot'], ['tcm.diagnosis/aiAssistant']] as $uris) {
|
|
TongueAuthDouble::$uris = $uris;
|
|
$result = $middleware->handle($route, static fn () => 'ALLOWED');
|
|
$expect(($result === 'ALLOWED') === in_array('tcm.diagnosis/aiAssistant', $uris, true), 'middleware shares existing permission without menu-registration bypass');
|
|
}
|
|
$route->adminInfo['root'] = 1; TongueAuthDouble::$uris = [];
|
|
$expect($middleware->handle($route, static fn () => 'ALLOWED') === 'ALLOWED', 'middleware root behavior preserved');
|
|
|
|
// If operation audit logging is re-enabled, this endpoint must still not persist model text or submitted parameters.
|
|
$logRequest = (new \app\Request())->setController('tcm.Diagnosis')->setAction('aiTongueScreenshot');
|
|
$container->instance('request', $logRequest);
|
|
$expect((new \app\adminapi\listener\OperationLog())->handle(new stdClass()) === null, 'generic audit listener skips temporary screenshot analysis before reading any data');
|
|
$logicSource = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/tcm/TongueScreenshotLogic.php');
|
|
$expect(!preg_match('/DiagnosisLogic::detail|::save|->save|::insert|->insert|::update|->update|Log::|base64/', $logicSource), 'screenshot logic contains no chart aggregation, mutation or payload logging');
|
|
} finally {
|
|
foreach ($paths as $path) if (is_file($path)) unlink($path);
|
|
}
|
|
echo "Diagnosis tongue screenshot integration (no network): {$checks} checks OK\n";
|
|
}
|