feat: add scoped read-only audio preview and isolated Dify transport

This commit is contained in:
2026-10-09 16:10:59 +08:00
parent 27772bec61
commit 4d0af7f3f4
36 changed files with 1517 additions and 68 deletions
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioPipeline as Pipeline;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioStreamTransport as Stream;
use app\common\service\followupaudio\FollowupAudioException as Error;
$dir = sys_get_temp_dir() . '/fa-dify-stage-' . bin2hex(random_bytes(6)); mkdir($dir, 0700);
new think\App(); $db = new PDO('sqlite:' . $dir . '/dictionary.sqlite');
$db->exec('CREATE TABLE zyt_dict_data(id INTEGER PRIMARY KEY,type_value TEXT,status INTEGER,sort INTEGER,name TEXT,value TEXT)');
$manager = new think\DbManager(); $manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => $dir . '/dictionary.sqlite', 'prefix' => 'zyt_']]]);
think\Container::getInstance()->instance('think\DbManager', $manager);
$checks = 0; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
$reject = static function (callable $call, string $code) use ($expect): void { try { $call(); } catch (Error $e) { $expect($e->errorCode === $code, 'expected ' . $code . ', got ' . $e->errorCode); return; } throw new RuntimeException('Expected ' . $code); };
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); $port = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); fclose($socket);
$server = proc_open([PHP_BINARY, '-n', '-S', '127.0.0.1:' . $port, __DIR__ . '/fixtures/followup_audio/dify_pipeline_router.php'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/server.stdout', 'a'], 2 => ['file', $dir . '/server.stderr', 'a']], $pipes, null,
array_merge(getenv(), ['FOLLOWUP_AUDIO_DIFY_MOCK_DIR' => $dir])); fclose($pipes[0]);
$bytes = str_repeat(pack('v', 1000), 16000); $wave = $dir . '/source.wav';
file_put_contents($wave, 'RIFF' . pack('V', 36 + strlen($bytes)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', strlen($bytes)) . $bytes); chmod($wave, 0600);
$settings = ['request_timeout' => 5, 'providers' => ['qwen' => ['driver' => 'asr_then_llm', 'label' => 'Synthetic', 'allow_loopback_tunnel' => true,
'asr' => ['protocol' => 'dify', 'binding_revision' => 'synthetic-asr-v1', 'base_url' => 'http://127.0.0.1:' . $port . '/success/v1', 'api_key' => 'synthetic-dify-key', 'model' => 'expected-asr'],
'extraction' => ['protocol' => 'dify_chat', 'binding_revision' => 'synthetic-chat-v1', 'base_url' => 'http://127.0.0.1:' . $port . '/success/v1', 'api_key' => 'synthetic-dify-key', 'model' => 'expected-llm', 'response_format' => 'prompt_json']]]];
$state = []; $fields = [];
$heartbeat = static function (array $value) use (&$state, &$fields): bool { $fields[] = $value; if (isset($value['pipeline_checkpoint'])) { $state = $value['pipeline_checkpoint']['state']; } return true; };
$taskFor = static fn (array $p): array => ['id' => 1, 'model_key' => 'qwen', 'sha256' => hash_file('sha256', $wave), 'duration_seconds' => 1,
'recorded_at' => '2026-09-29 10:00:00', 'upstream_started_at' => 0, 'upstream_ids_json' => json_encode(['provider_fingerprint' => $p['fingerprint']])];
$tls = null;
try {
for ($i = 0; $i < 100; $i++) { $socket = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $error, 0.1); if ($socket) { fclose($socket); break; } usleep(20000); }
foreach (['curl', 'openssl_stream'] as $transport) {
$options = $settings; $options['http_transport'] = $transport; $provider = Provider::resolve('qwen', $options, []); $task = $taskFor($provider); $state = []; $fields = [];
$result = (new Pipeline($options, $provider))->run($wave, $task, $heartbeat);
$expect($result['transcript'] === '今天早晨空腹血糖六点一。' && count($result['items']) === 1, $transport . ' actual Dify stage pipeline');
$expect($state['extraction']['upstream_ids']['message_id'] === 'dify-message-success' && $state['extraction']['upstream_ids']['conversation_id'] === 'dify-conversation-never-reused', 'valid upstream IDs retained');
$rows = array_map(static fn ($line) => json_decode($line, true), file($dir . '/requests.jsonl', FILE_IGNORE_NEW_LINES));
$expect(!in_array(false, array_column($rows, 'valid'), true), 'actual HTTP has Dify-only shape, no local model/generation/conversation leakage');
$before = count($rows); (new Pipeline($options, $provider))->run($wave, $task, $heartbeat, $state);
$expect(count(file($dir . '/requests.jsonl')) === $before, 'known completed Dify results are not resent');
foreach (['reject' => 'ASR_REJECTED', 'unknown' => 'UPSTREAM_UNCERTAIN', 'redirect' => 'UPSTREAM_UNCERTAIN', 'oversize' => 'UPSTREAM_UNCERTAIN'] as $scenario => $code) {
$bad = $options; $bad['providers']['qwen']['asr']['base_url'] = 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1';
if ($scenario === 'oversize') { $bad['max_response_bytes'] = 1024; }
$p = Provider::resolve('qwen', $bad, []); $t = $taskFor($p); $state = []; $fields = [];
$before = count(file($dir . '/requests.jsonl'));
$reject(static fn () => (new Pipeline($bad, $p))->run($wave, $t, $heartbeat), $code);
$expect(count(file($dir . '/requests.jsonl')) === $before + 1, 'no HTTP redirect/protocol/transport fallback on ' . $scenario);
$expect($state['chunks'][0]['state'] === ($scenario === 'reject' ? 'rejected' : 'intent'), 'known rejection vs unresolved intent preserved');
if ($scenario === 'unknown') {
$expect((bool) array_filter($fields, static fn ($value) => isset($value['upstream_run_id']) && $value['upstream_run_id'] === 'unknown-observed-id'), 'unknown response ID retained before error');
$reject(static fn () => (new Pipeline($bad, $p))->run($wave, $t, $heartbeat, $state), 'RECONCILIATION_REQUIRED');
$expect(count(file($dir . '/requests.jsonl')) === $before + 1, 'unknown result is not retried');
}
}
$length = $options; $length['providers']['qwen']['extraction']['base_url'] = 'http://127.0.0.1:' . $port . '/length/v1';
$p = Provider::resolve('qwen', $length, []); $t = $taskFor($p); $state = [];
$reject(static fn () => (new Pipeline($length, $p))->run($wave, $t, $heartbeat), 'UPSTREAM_SCHEMA_INVALID');
$expect($state['extraction']['state'] === 'complete' && $state['extraction']['answer'] === '', 'Dify explicit length finish stays a known completed failure');
}
foreach (['asr', 'extraction'] as $stage) { $bad = $settings; $bad['providers']['qwen'][$stage]['binding_revision'] = ''; $reject(static fn () => Provider::resolve('qwen', $bad, []), 'CONFIG_INVALID'); }
$bad = $settings; $bad['providers']['qwen']['extraction']['response_format'] = 'json_schema'; $reject(static fn () => Provider::resolve('qwen', $bad, []), 'CONFIG_INVALID');
$bad = $settings; $bad['providers']['qwen']['extraction']['enable_thinking'] = true; $reject(static fn () => Provider::resolve('qwen', $bad, []), 'CONFIG_INVALID');
$a = Provider::resolve('qwen', $settings, []); $bad = $settings; $bad['providers']['qwen']['asr']['binding_revision'] = 'synthetic-asr-v2';
$expect(Provider::resolve('qwen', $bad, [])['fingerprint'] !== $a['fingerprint'], 'Dify revision changes identity');
$bad = $settings; $bad['http_transport'] = 'openssl_stream'; $expect(Provider::resolve('qwen', $bad, [])['fingerprint'] !== $a['fingerprint'], 'explicit TLS transport changes identity');
$expect(!isset($a['extraction']['max_tokens'], $a['extraction']['enable_thinking']), 'Dify generation owned by App, not claimed as local effective knobs');
// Verified TLS rejects an untrusted local certificate; no trust store change or verify=false workaround.
$cert = proc_open(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', $dir . '/key.pem', '-out', $dir . '/cert.pem', '-days', '1', '-subj', '/CN=localhost'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/cert.stdout', 'a'], 2 => ['file', $dir . '/cert.stderr', 'a']], $p); fclose($p[0]); $expect(proc_close($cert) === 0, 'local test certificate generated');
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); $tlsPort = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); fclose($socket);
$tls = proc_open(['openssl', 's_server', '-accept', '127.0.0.1:' . $tlsPort, '-cert', $dir . '/cert.pem', '-key', $dir . '/key.pem', '-quiet', '-www'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/tls.stdout', 'a'], 2 => ['file', $dir . '/tls.stderr', 'a']], $p); fclose($p[0]); usleep(200000);
$spec = ['url' => 'https://127.0.0.1:' . $tlsPort . '/v1/chat-messages', 'api_key' => 'synthetic-dify-key', 'timeout' => 3, 'stage' => 'extraction', 'json' => ['inputs' => new stdClass(), 'query' => 'synthetic']];
$reply = Stream::request($spec, static fn (): bool => true, 1024, false);
$expect($reply['errno'] !== 0 && $reply['http_code'] === 0, 'untrusted TLS fails closed');
$spec['url'] = 'http://127.0.0.1:' . $port . '/slow/v1/chat-messages'; $spec['json'] = (new Pipeline($settings, $a))->extractRequest('synthetic', [['id' => 's', 'start_ms' => 0, 'end_ms' => 1000, 'text' => 'synthetic']], '2026-09-29 10:00:00', ['blood' => [['key' => 'systolic_pressure', 'type' => 'number']]]);
$spec['timeout'] = 1; $start = microtime(true); $reply = Stream::request($spec, static fn (): bool => true, 1024, true);
$expect($reply['errno'] === 28 && microtime(true) - $start < 3, 'blocked response headers remain wall-clock bounded');
$spec['timeout'] = 10; $beats = 0; $start = microtime(true);
$reply = Stream::request($spec, static function () use (&$beats): bool { $beats++; return $beats < 2; }, 1024, true);
$expect($reply['errno'] === 42 && $beats === 2 && microtime(true) - $start < 7, 'parent rechecks authorization after5s while child waits for headers');
echo 'FOLLOWUP_AUDIO_DIFY_PIPELINE assertions=' . $checks . ' PASS native_audio_to_text=1 blocking_chat=1 no_fake_generation_args=1 revision_bound=1 curl_and_openssl=1 verified_tls=1 parent_heartbeat=1 body_timeout_limits=1 no_fallback=1 unknown_fenced=1' . PHP_EOL;
} finally {
if (is_resource($tls)) { proc_terminate($tls, 9); proc_close($tls); }
proc_terminate($server, 9); proc_close($server);
foreach (glob($dir . '/*') as $file) { unlink($file); } rmdir($dir);
}
@@ -9,6 +9,8 @@ $access = file_get_contents($root . '/app/common/service/followupaudio/FollowupA
$middleware = file_get_contents($root . '/app/adminapi/http/middleware/AuthMiddleware.php');
$stream = file_get_contents($root . '/app/common/service/followupaudio/FollowupAudioStream.php');
$console = file_get_contents($root . '/config/console.php');
$apply = file_get_contents($root . '/app/common/service/followupaudio/FollowupAudioApply.php');
$gate = file_get_contents($root . '/app/common/service/followupaudio/FollowupAudioGate.php');
$checks = 0;
function expectEndpoint(bool $condition, string $message): void
{
@@ -26,8 +28,13 @@ expectEndpoint(str_contains($controller, 'array_diff(array_keys($params), $allow
expectEndpoint(str_contains($controller, 'count($items) > 500'), 'bounded review items');
expectEndpoint(str_contains($controller, "'code' => 'FOLLOWUP_AUDIO_STALE_REVIEW'"), 'typed stale review response');
expectEndpoint(substr_count($controller, 'Logic::requireEnabled(true)') >= 3, 'upload capability gate');
expectEndpoint(str_contains($logic, "Store::verified(\$p['model_key'])"), 'per-model audio capability gate');
expectEndpoint(str_contains($logic, "'models' => ProviderConfig::publicModels()"), 'only fingerprint-verified server model labels advertised');
expectEndpoint(str_contains($logic, "Store::ready(\$p['model_key'])"), 'per-model mode-aware readiness gate');
expectEndpoint(str_contains($logic, "'models' => \$enabled ? ProviderConfig::readyModels() : []"), 'only scoped fingerprint-ready server labels advertised');
expectEndpoint(str_contains($logic, '$verified = Store::verified()') && str_contains($logic, "'audio_verified' => \$verified"), 'full accuracy verification remains separate');
expectEndpoint(str_contains($logic, "'preview_only' => \$preview") && str_contains($logic, "'can_review' => \$enabled && \$ready"), 'explicit preview and review capabilities');
expectEndpoint(strpos($apply, 'FollowupAudioGate::assertMayApply();') < strpos($apply, '$connection = Db::connect()'), 'preview hard denial before any apply DB access');
expectEndpoint(strpos($apply, 'FollowupAudioGate::assertMayApply($task);') < strpos($apply, "if (\$task['status'] === 'applied')"), 'persistent preview origin denied before idempotent applied path');
expectEndpoint(str_contains($gate, 'FOLLOWUP_AUDIO_PREVIEW_ONLY') && str_contains($gate, 'FOLLOWUP_AUDIO_PREVIEW_SCOPE_DENIED'), 'stable fail-closed preview errors');
expectEndpoint(!str_contains($logic, 'Dify 音频能力') && !str_contains($logic, 'api_key') && !str_contains($logic, 'base_url'),
'provider-neutral public capabilities never expose credentials or addresses');
expectEndpoint(str_contains($logic, "(int) \$upload['diagnosis_id'] !== \$p['diagnosis_id']"), 'upload/diagnosis binding');
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
require __DIR__ . '/fixtures/followup_audio/database.php';
use app\common\service\followupaudio\FollowupAudioGate as Gate;
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
use app\common\service\followupaudio\FollowupAudioWorker as Worker;
use app\common\service\followupaudio\FollowupAudioDify as Dify;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioPipelineCheckpoint as Checkpoint;
use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
use app\adminapi\logic\tcm\FollowupAudioLogic as Logic;
use think\facade\Db;
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23319) { throw new RuntimeException('Preview tests require dedicated disposable23319'); }
$provider = ['driver' => 'asr_then_llm', 'label' => 'Synthetic preview',
'asr' => ['protocol' => 'dify', 'binding_revision' => 'test-asr-v1', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key', 'model' => 'expected-asr'],
'extraction' => ['protocol' => 'dify_chat', 'binding_revision' => 'test-chat-v1', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key', 'model' => 'expected-llm', 'response_format' => 'prompt_json']];
$f = followupAudioTestDatabase(['preview_only' => true, 'audio_verified' => false, 'verified_profiles' => [], 'test_diagnosis_ids' => '', 'providers' => ['qwen' => $provider]]);
$checks = 0; $calls = ['asr' => 0, 'extraction' => 0]; $active = 0; $mode = 'success';
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
$reject = static function (callable $call, string $code) use ($expect): void { try { $call(); } catch (Throwable $e) { $expect(str_contains($e->getMessage(), $code) || ($e->errorCode ?? '') === $code, 'expected ' . $code . ', got ' . $e->getMessage()); return; } throw new RuntimeException('Expected ' . $code); };
$set = static function (array $fields) use ($f): void { $f['config']->set($fields, 'followup_audio'); };
$businessTables = ['zyt_tcm_diagnosis','zyt_tcm_prescription_order','zyt_tcm_blood_record','zyt_patient_diet_record','zyt_patient_exercise_record','zyt_tracking_note','zyt_followup_audio_audit'];
$snapshot = static function () use ($f, $businessTables): string { $rows = []; foreach ($businessTables as $table) { $rows[$table] = $f['pdo']->query('SELECT * FROM `' . $table . '` ORDER BY id')->fetchAll(PDO::FETCH_ASSOC); } return hash('sha256', json_encode($rows)); };
$beforeBusiness = $snapshot();
$connection = Db::connect()->getConfig(); $connection['trigger_sql'] = true;
Db::setConfig(['default' => 'mysql', 'connections' => ['mysql' => $connection]]); Db::connect('mysql', true);
$businessWrites = 0; $featureWrites = 0; $sqlEvents = 0;
Db::listen(static function (string $sql) use (&$businessWrites, &$featureWrites, &$sqlEvents, $businessTables): void {
$sqlEvents++;
if (preg_match('/\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+`?(zyt_[a-z0-9_]+)/i', $sql, $match)) {
if (in_array(strtolower($match[1]), $businessTables, true)) { $businessWrites++; }
elseif (str_starts_with(strtolower($match[1]), 'zyt_followup_audio_')) { $featureWrites++; }
}
});
$make = static function () use ($f, &$active): int {
static $index = 0; $index++; $data = str_repeat(pack('vv', 1000 + $index, 2000 + $index), 16000);
$path = $f['private'] . '/preview-' . $index . '.wav';
file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($data)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 2, 16000, 64000, 4, 16) . 'data' . pack('V', strlen($data)) . $data); chmod($path, 0600);
$upload = followupAudioTestUpload($f, $path, 'synthetic.wav');
$task = Logic::create(['diagnosis_id' => 1, 'upload_id' => $upload['id'], 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen'], 1, $f['actor']);
$active = $task['id']; return $active;
};
$transport = static function (array $spec) use (&$active, &$calls, &$mode, $set, $expect): array {
$calls[$spec['stage']]++;
$row = Store::task($active); $state = Store::open($active, 'pipeline', $row['pipeline_cipher']);
$expect(Checkpoint::hasIntent($state) && Gate::taskPreview($row), 'preview origin and intent durable before upstream');
if ($spec['stage'] === 'asr') {
$expect(str_ends_with($spec['url'], '/audio-to-text') && !isset($spec['multipart']['model']) && isset($spec['multipart']['user']), 'actual preview Dify ASR contract');
if ($mode === 'revoke_scope') { $set(['test_diagnosis_ids' => '']); }
if ($mode === 'revoke_admin') { $set(['test_admin_ids' => '2']); }
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['text' => '合成:今天早晨空腹血糖六点一。'], JSON_UNESCAPED_UNICODE)];
}
$expect(str_ends_with($spec['url'], '/chat-messages') && !isset($spec['json']['max_tokens'], $spec['json']['model'], $spec['json']['conversation_id']), 'actual preview Dify extraction contract');
$citation = Prompt::citations(Checkpoint::segments($state))[0];
$answer = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成预览', 'uncertainties' => [], 'items' => [[
'kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.1], 'record_date' => '2026-09-29', 'record_time' => '08:00',
'date_text' => '今天', 'time_text' => '早晨', 'time_period' => null, 'time_estimated' => false, 'needs_review' => true, 'evidence_ids' => [$citation['id']],
]]];
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['message_id' => 'preview-dify-message', 'answer' => json_encode($answer, JSON_UNESCAPED_UNICODE)])];
};
$run = static fn (): bool => (new Worker(new Dify($transport)))->runOnce();
try {
foreach (['', 'name-only', '1,,2', '0', '-1', ['1', null], ['id' => 1]] as $ids) {
$set(['test_diagnosis_ids' => $ids]); $cap = Logic::capabilities(1, 1, $f['actor']);
$expect(!$cap['enabled'] && !$cap['test_scope_allowed'] && $cap['models'] === [] && !$cap['can_apply'], 'empty/malformed scope fails closed without breaking capabilities');
$reject(fn () => Upload::createSession(1, 'synthetic.wav', 44, 1, $f['actor']), 'PREVIEW_SCOPE_DENIED');
}
$set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']);
$expect(!Store::ready() && !Store::verified(), 'neither preview nor full gate fabricated');
$set(['audio_verified' => true, 'verified_profiles' => ['qwen']]);
$expect(Store::verified() && !Store::ready(), 'full gate cannot substitute missing preview fingerprint');
$set(['audio_verified' => false, 'verified_profiles' => []]);
$slots = config('followup_audio.providers'); $slots['qwen']['preview_verified_fingerprint'] = Provider::resolve('qwen')['fingerprint']; $set(['providers' => $slots]);
$cap = Logic::capabilities(1, 1, $f['actor']);
$expect($cap['enabled'] && $cap['preview_only'] && $cap['preview_ready'] && $cap['test_scope_allowed'] && $cap['can_upload'] && $cap['can_review'] && !$cap['can_apply'] && !$cap['audio_verified'], 'preview upload/review separately ready, clinical apply always false');
$outside = Logic::capabilities(2, 1, $f['actor']);
$expect(!$outside['enabled'] && !$outside['test_scope_allowed'] && $outside['models'] === [] && $outside['fields'] === [], 'other diagnosis normal editor receives disabled audio capabilities');
$reject(fn () => Upload::createSession(2, 'synthetic.wav', 44, 1, $f['actor']), 'PREVIEW_SCOPE_DENIED');
$reject(fn () => Upload::createSession(1, 'synthetic.wav', 44, 2, []), 'PREVIEW_SCOPE_DENIED');
$set(['test_admin_ids' => '']);
$reject(fn () => Upload::createSession(1, 'synthetic.wav', 44, 3, ['root' => 1]), '诊单不存在或无权操作');
$set(['test_admin_ids' => '1']);
$id = $make(); $claim = Store::claim();
$reject(fn () => Store::checkpoint($id, $claim['lease_token'], ['upstream_ids_json' => ['preview_only' => false]]), 'CHECKPOINT_INVALID');
$expect(Gate::taskPreview(Store::task($id)), 'preview-origin snapshot cannot be cleared by callback');
Store::fail($id, $claim['lease_token'], 'SYNTHETIC_LOCAL_CHECK', ''); Store::retry($id);
$expect($run(), 'actual Worker processes ready preview while full verified false');
$detail = Logic::detail($id, 1, $f['actor']);
$expect($detail['status'] === 'review' && $detail['preview_only'] && $detail['channel_roles'] === 'unconfirmed' && $calls === ['asr' => 2, 'extraction' => 1], 'allowed preview reaches transcript/review only');
$items = $detail['items']; $items[0]['selected'] = true; $items[0]['needs_review'] = false;
$saved = Logic::saveDraft($id, $detail['version'], $items, 1, $f['actor'], 'left_service');
$expect($saved['channel_roles'] === 'left_service' && !$saved['items'][0]['selected'], 'preview role and draft remain usable');
$items = $saved['items']; $items[0]['selected'] = true; $items[0]['needs_review'] = false;
$saved = Logic::saveDraft($id, $saved['version'], $items, 1, $f['actor']);
$reject(fn () => Logic::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
$reject(fn () => Apply::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
$set(['audio_verified' => true, 'verified_profiles' => ['qwen']]);
$reject(fn () => Apply::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
$set(['preview_only' => false]);
$expect(Store::verified() && Store::detail($id)['preview_only'], 'origin survives later normal/full-verified configuration');
$reject(fn () => Apply::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
Db::name('followup_audio_task')->where('id', $id)->update(['status' => 'applied', 'stage' => 'applied', 'applied_cipher' => Store::seal($id, 'applied', ['items' => []])]);
$reject(fn () => Apply::apply($id, $saved['version'], [], 1, $f['actor']), 'PREVIEW_ONLY');
$set(['preview_only' => true, 'audio_verified' => false, 'verified_profiles' => []]);
$reject(fn () => Apply::apply($id, 999, [], 1, $f['actor']), 'PREVIEW_ONLY');
foreach (['revoke_scope', 'revoke_admin'] as $mode) {
$set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']); $unknownId = $make(); $beforeCalls = $calls; $run();
$row = Store::task($unknownId);
$expect($row['status'] === 'needs_reconciliation' && $calls['asr'] === $beforeCalls['asr'] + 1 && $calls['extraction'] === $beforeCalls['extraction'], 'scope/admin revoked after first request stops remaining upstream work');
$state = Store::open($unknownId, 'pipeline', $row['pipeline_cipher']); $expect(Checkpoint::hasIntent($state), 'unknown in-flight outcome retained');
$reject(fn () => Logic::detail($unknownId, 1, $f['actor']), 'PREVIEW_SCOPE_DENIED');
$set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']);
$reject(fn () => Store::retry($unknownId), 'RETRY_NOT_SAFE');
$expect(Store::task($unknownId)['pipeline_cipher'] === $row['pipeline_cipher'], 'restoring allowlist does not clear unknown checkpoint');
}
$mode = 'success'; $set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']); $queued = $make(); $set(['test_diagnosis_ids' => '']); $beforeCalls = $calls;
$expect(!$run() && Store::task($queued)['status'] === 'queued' && $calls === $beforeCalls, 'revoked queued diagnosis not claimed or sent');
$expect($snapshot() === $beforeBusiness && $businessWrites === 0 && $featureWrites > 0 && $sqlEvents > 0, 'actual SQL listener: feature writes allowed, all clinical/audit tables zero writes and unchanged');
echo 'FOLLOWUP_AUDIO_PREVIEW assertions=' . $checks . ' PASS mysql23319=1 full_audio_verified_not_faked=1 allowlist_fail_closed=1 rbac=1 preview_review=1 clinical_writes=0 apply_root_direct_applied_denied=1 origin_permanent=1 revoke_stops_upstream=1 unknown_preserved=1' . PHP_EOL;
} finally { followupAudioTestDatabaseCleanup($f); }
@@ -27,7 +27,7 @@ namespace {
return $value;
}
$root = dirname(__DIR__) . '/app/common/service/followupaudio/';
foreach (['Exception', 'ProviderConfig', 'Store'] as $class) { require $root . 'FollowupAudio' . $class . '.php'; }
foreach (['Exception', 'ProviderConfig', 'Gate', 'Store'] as $class) { require $root . 'FollowupAudio' . $class . '.php'; }
require dirname(__DIR__) . '/app/adminapi/logic/tcm/FollowupAudioLogic.php';
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Providers;
@@ -11,7 +11,7 @@ use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use think\facade\Db;
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23317) { throw new RuntimeException('Stereo test requires its separate disposable23317'); }
if (!in_array((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT'), [23317, 23319], true)) { throw new RuntimeException('Stereo test requires a dedicated disposable test port'); }
$f = followupAudioTestDatabase(['asr_stereo_chunk_seconds' => 2]);
$checks = 0; $calls = ['asr' => 0, 'extraction' => 0]; $active = 0; $mode = 'success';
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
@@ -117,5 +117,5 @@ try {
$legacyAppliedId = $make(1); $oldAppliedCipher = Store::seal($legacyAppliedId, 'applied', ['items' => [['kind' => 'blood', 'record_id' => 999]]]);
Db::name('followup_audio_task')->where('id', $legacyAppliedId)->update(['status' => 'applied', 'stage' => 'applied', 'applied_cipher' => $oldAppliedCipher]);
$expect(Store::detail($legacyAppliedId)['applied_items'] === [['kind' => 'blood', 'record_id' => 999]] && Store::task($legacyAppliedId)['applied_cipher'] === $oldAppliedCipher, 'old applied results unchanged');
echo 'FOLLOWUP_AUDIO_STEREO_DATABASE assertions=' . $checks . ' PASS mysql23317=1 actual_worker=1 both_channels=1 role_scope_version=1 role_change_clears=1 apply_role_required=1 immutable_channels=1 audit_annotation=1 unknown_fenced=1 local_silence=1 legacy_unchanged=1' . PHP_EOL;
echo 'FOLLOWUP_AUDIO_STEREO_DATABASE assertions=' . $checks . ' PASS mysql' . (int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') . '=1 actual_worker=1 both_channels=1 role_scope_version=1 role_change_clears=1 apply_role_required=1 immutable_channels=1 audit_annotation=1 unknown_fenced=1 local_silence=1 legacy_unchanged=1' . PHP_EOL;
} finally { followupAudioTestDatabaseCleanup($f); }
+1
View File
@@ -17,6 +17,7 @@ namespace app\common\service\followupaudio {
public static bool $providerMatches = true;
public static function enabled(): bool { return self::$enabled; }
public static function verified(?string $profile = null): bool { return self::$verified; }
public static function ready(?string $profile = null): bool { return self::$verified; }
public static function claim(): ?array { self::$events[] = 'claim'; return self::$verified ? ['id' => 1, 'actor_id' => 1, 'diagnosis_id' => 1, 'lease_token' => 'test', 'model_key' => 'qwen'] : null; }
public static function assertTaskProvider(array $task): void { if (!self::$providerMatches) { throw new FollowupAudioException('PROVIDER_CONFIGURATION_CHANGED'); } }
public static function heartbeat(int $id, string $token): bool { self::$events[] = 'heartbeat'; return self::$lease; }
@@ -0,0 +1,34 @@
<?php
$directory = getenv('FOLLOWUP_AUDIO_DIFY_MOCK_DIR');
$uri = $_SERVER['REQUEST_URI'];
$audio = str_ends_with($uri, '/audio-to-text'); $chat = str_ends_with($uri, '/chat-messages');
$valid = ($_SERVER['HTTP_AUTHORIZATION'] ?? '') === 'Bearer synthetic-dify-key';
if ($audio) {
$valid = $valid && array_keys($_POST) === ['user'] && preg_match('/^fa-opaque-[a-f0-9]{48}$/D', $_POST['user'] ?? '')
&& isset($_FILES['file']) && ($_FILES['file']['type'] ?? '') === 'audio/wav' && is_file($_FILES['file']['tmp_name']);
$meta = ['stage' => 'asr', 'valid' => (bool) $valid, 'uri' => $uri, 'bytes' => isset($_FILES['file']) ? filesize($_FILES['file']['tmp_name']) : 0];
} elseif ($chat) {
$raw = file_get_contents('php://input'); $object = json_decode($raw); $body = json_decode($raw, true);
$valid = $valid && is_object($object->inputs ?? null) && (array) $object->inputs === [] && is_string($body['query'] ?? null)
&& ($body['response_mode'] ?? '') === 'blocking' && ($body['auto_generate_name'] ?? null) === false
&& preg_match('/^fa-opaque-[a-f0-9]{48}$/D', $body['user'] ?? '')
&& array_diff(array_keys($body), ['inputs', 'query', 'response_mode', 'user', 'auto_generate_name']) === [];
$meta = ['stage' => 'extraction', 'valid' => (bool) $valid, 'uri' => $uri];
} else { http_response_code(404); echo '{}'; return; }
file_put_contents($directory . '/requests.jsonl', json_encode($meta) . "\n", FILE_APPEND);
header('Content-Type: application/json');
if (!$valid) { http_response_code(400); echo '{"code":"bad_contract"}'; return; }
if (str_contains($uri, '/slow/')) { sleep(12); }
if (str_contains($uri, '/redirect/')) { http_response_code(302); header('Location: /success/v1/audio-to-text'); echo '{}'; return; }
if (str_contains($uri, '/reject/')) { http_response_code(429); echo '{"message_id":"known-reject-id"}'; return; }
if (str_contains($uri, '/unknown/')) { http_response_code(500); echo '{"message_id":"unknown-observed-id"}'; return; }
if (str_contains($uri, '/oversize/')) { echo json_encode(['text' => str_repeat('x', 200000)]); return; }
if ($audio) { echo json_encode(['text' => '今天早晨空腹血糖六点一。'], JSON_UNESCAPED_UNICODE); return; }
preg_match('/c_[a-f0-9]{16,64}/', $body['query'], $match);
$answer = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成摘要', 'uncertainties' => [], 'items' => [[
'kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.1], 'record_date' => '2026-09-29', 'record_time' => null,
'date_text' => '今天', 'time_text' => '早晨', 'time_period' => '早晨', 'time_estimated' => true, 'needs_review' => true,
'evidence_ids' => [$match[0]],
]]];
echo json_encode(['event' => 'message', 'message_id' => 'dify-message-success', 'task_id' => 'dify-task-success', 'conversation_id' => 'dify-conversation-never-reused',
'answer' => json_encode($answer, JSON_UNESCAPED_UNICODE), 'metadata' => ['finish_reason' => str_contains($uri, '/length/') ? 'length' : 'stop']]);