feat: configure audio providers and add local sample acceptance

This commit is contained in:
2026-10-07 18:15:32 +08:00
parent 70be2fc70f
commit fea33285e8
21 changed files with 1103 additions and 131 deletions
+22 -7
View File
@@ -12,15 +12,29 @@ namespace app\common\service\followupaudio {
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
$app = new \think\App(dirname(__DIR__) . '/'); $app->initialize();
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
// Explicit disposable local database only; no application initialization or .env loading.
$port = (int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT');
if ($port <= 0 || getenv('FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE') !== '1') {
throw new \RuntimeException('Explicit local disposable MySQL port and FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE=1 required');
}
$password = (string) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PASSWORD');
$database = 'fa_access_' . bin2hex(random_bytes(6));
$control = new \PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', $password,
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
$control->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
new \think\App();
set_exception_handler(static function (\Throwable $e): void {
fwrite(STDERR, $e->getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL); exit(1);
});
$app->config->set(['default' => 'mysql', 'connections' => ['mysql' => [
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => 23316,
'database' => 'followup_audio_test', 'username' => 'root', 'password' => 'followup_audio_isolated_test',
$manager = new \think\DbManager();
$manager->setConfig(['default' => 'mysql', 'connections' => ['mysql' => [
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
'database' => $database, 'username' => 'root', 'password' => $password,
'charset' => 'utf8mb4', 'prefix' => 'faa_', 'debug' => false,
]]], 'database');
]]]);
\think\Container::getInstance()->instance('think\DbManager', $manager);
\think\Container::getInstance()->instance('config', new \think\Config());
$db = \think\facade\Db::class;
$tables = [
'admin' => 'id BIGINT PRIMARY KEY, name VARCHAR(30), root INT, disable INT, delete_time BIGINT NULL',
@@ -52,7 +66,7 @@ namespace {
$deny = function (callable $f, string $label) use ($ok): void {
try { $f(); } catch (\DomainException $e) { $ok(true, $label); return; } $ok(false, $label);
};
$other = new \PDO('mysql:host=127.0.0.1;port=23316;dbname=followup_audio_test;charset=utf8mb4', 'root', 'followup_audio_isolated_test', [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
$other = new \PDO("mysql:host=127.0.0.1;port={$port};dbname={$database};charset=utf8mb4", 'root', $password, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
try {
$ok((int) $a::diagnosis(91, 8, ['root' => 1])['id'] === 91, 'own patient');
$deny(fn () => $a::diagnosis(92, 8, ['root' => 1]), 'forged cached root must fail');
@@ -80,6 +94,7 @@ namespace {
echo "Followup audio real authorization: {$checks} checks passed (isolated MySQL; current-read revocation/reassignment)\n";
} finally {
try { $db::rollback(); } catch (\Throwable $e) {}
foreach (array_keys($tables) as $name) { $db::execute("DROP TABLE IF EXISTS faa_{$name}"); }
$manager->connect()->close();
$control->exec("DROP DATABASE IF EXISTS `{$database}`");
}
}
+76
View File
@@ -9,6 +9,7 @@ require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\service\followupaudio\FollowupAudioFields as Fields;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use app\common\service\followupaudio\FollowupAudioProviderConfig as ProviderConfig;
use app\common\service\followupaudio\FollowupAudioStore as Store;
use think\Container;
use think\facade\Db;
@@ -37,6 +38,17 @@ $private = $child ? (string) getenv('FOLLOWUP_AUDIO_TEST_PRIVATE') : '/private/t
$config->set(['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'encryption_key' => str_repeat('synthetic-test-key-', 4),
'lease_seconds' => 60, 'concurrency' => 1, 'retention_days' => 90, 'private_dir' => $private], 'followup_audio');
Container::getInstance()->instance('config', $config);
// Explicit synthetic provider identity: no live credentials, application .env or external requests.
$testProviders = [];
foreach (['qwen', 'openai'] as $profile) {
$testProviders[$profile] = ['driver' => 'dify', 'base_url' => 'https://synthetic.invalid/v1',
'api_key' => 'synthetic-test-key', 'model' => 'synthetic-audio', 'label' => 'Synthetic ' . $profile];
}
$config->set(['providers' => $testProviders], 'followup_audio');
foreach (array_keys($testProviders) as $profile) {
$testProviders[$profile]['verified_fingerprint'] = ProviderConfig::resolve($profile)['fingerprint'];
}
$config->set(['providers' => $testProviders], 'followup_audio');
$root = ['root' => 1, 'admin_id' => 1, 'id' => 1, 'name' => 'Synthetic'];
if ($mode === '--claim') { echo json_encode(['id' => Store::claim()['id'] ?? null]) . "\n"; exit(0); }
if ($mode === '--create') {
@@ -182,9 +194,16 @@ try {
$expect(!Store::verified() && !Store::verified('qwen'), 'No profile is implicitly verified');
$config->set(['verified_profiles' => ['qwen']], 'followup_audio');
$expect(Store::verified('qwen') && !Store::verified('openai'), 'Verification is profile-specific');
$unboundProviders = $testProviders; $unboundProviders['qwen']['verified_fingerprint'] = '';
$config->set(['providers' => $unboundProviders], 'followup_audio');
$expect(!Store::verified('qwen'), 'Flags alone cannot verify an unbound provider');
$config->set(['providers' => $testProviders], 'followup_audio');
$reject(fn () => Store::create($firstUpload, $recordedAt, 'openai', 1, $root), 'DISABLED_OR_UNVERIFIED');
$config->set(['verified_profiles' => ['qwen', 'openai']], 'followup_audio');
$a = Store::create($firstUpload, $recordedAt, 'qwen', 1, $root);
$binding = json_decode(Store::task($a['task_id'])['upstream_ids_json'], true, 16, JSON_THROW_ON_ERROR);
$expect($binding === ['provider_fingerprint' => ProviderConfig::resolve('qwen')['fingerprint']],
'New task persists only immutable provider fingerprint, not credentials or endpoint');
$expect(Store::create($firstUpload, $recordedAt, 'qwen', 1, $root)['task_id'] === $a['task_id'], 'Repeated create does not enqueue duplicate upstream work');
$reject(fn () => Store::create($firstUpload, $recordedAt, 'openai', 1, $root), 'UPLOAD_ALREADY_USED');
$differentUpload = $upload(1, 1, $firstUpload);
@@ -203,6 +222,18 @@ try {
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['stage' => 'uploading', 'upstream_started_at' => time(),
'upstream_ids_json' => ['request_id' => 'opaque-test-request']]), 'Upstream started checkpoint persisted');
$reject(fn () => Store::checkpoint((int) $running['id'], $running['lease_token'], ['api_key' => 'forbidden']), 'CHECKPOINT_INVALID');
$savedIds = json_decode(Store::task((int) $running['id'])['upstream_ids_json'], true, 16, JSON_THROW_ON_ERROR);
$expect($savedIds['provider_fingerprint'] === $binding['provider_fingerprint'] && $savedIds['request_id'] === 'opaque-test-request',
'Normal upstream checkpoints preserve immutable fingerprint');
foreach ([str_repeat('0', 64), '', null] as $replacement) {
$reject(fn () => Store::checkpoint((int) $running['id'], $running['lease_token'],
['upstream_ids_json' => ['provider_fingerprint' => $replacement]]), 'CHECKPOINT_INVALID');
}
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['upstream_ids_json' => '{}'])
&& json_decode(Store::task((int) $running['id'])['upstream_ids_json'], true)['provider_fingerprint'] === $binding['provider_fingerprint'],
'An empty checkpoint cannot delete task binding');
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['upstream_ids_json' => $binding]),
'Transport may echo exactly the original immutable fingerprint');
Db::name('followup_audio_task')->where('id', $running['id'])->update(['lease_until' => time() - 1]);
$other = Store::claim();
$expect(Store::task((int) $running['id'])['status'] === 'needs_reconciliation', 'Expired attempted request cannot be resubmitted');
@@ -223,6 +254,51 @@ try {
$parallelClaim = Store::claim();
$expect((int) $parallelClaim['id'] === $parallelCreated[0]['id'], 'Concurrent dedupe leaves exactly one queued upstream operation');
Store::fail((int) $parallelClaim['id'], $parallelClaim['lease_token'], 'LOCAL_PROBE_FAILED', '');
// Queue bindings survive model switches; changed configuration never silently reroutes old audio.
$oldTask = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
Db::name('followup_audio_task')->where('id', $oldTask['id'])->update(['upstream_ids_json' => '{}']);
$expect(Store::claim() === null, 'Legacy task without fingerprint is never claimed');
$legacy = Store::task($oldTask['id']);
$expect($legacy['status'] === 'failed' && $legacy['error_code'] === 'PROVIDER_CONFIGURATION_CHANGED'
&& (int) $legacy['attempts'] === 0 && !Store::summary($legacy)['can_retry'], 'Legacy binding failure is visible and cannot retry');
$changedQueued = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
$changedProviders = $testProviders; $changedProviders['qwen']['model'] = 'synthetic-changed-model';
$config->set(['providers' => $changedProviders], 'followup_audio');
$expect(!Store::verified('qwen') && Store::claim() === null, 'Changed unverified model cannot consume queued audio');
$changedRow = Store::task($changedQueued['id']);
$expect($changedRow['status'] === 'failed' && $changedRow['error_code'] === 'PROVIDER_CONFIGURATION_CHANGED'
&& (int) $changedRow['upstream_started_at'] === 0, 'Configuration drift fails before upstream intent');
$changedProviders['qwen']['verified_fingerprint'] = ProviderConfig::resolve('qwen')['fingerprint'];
$config->set(['providers' => $changedProviders], 'followup_audio');
$expect(Store::verified('qwen') && !Store::summary($changedRow)['can_retry'], 'Reverified new provider does not unlock old task retry');
$providerMismatch = false;
try { Store::retry($changedQueued['id']); }
catch (\app\common\service\followupaudio\FollowupAudioException $error) { $providerMismatch = $error->errorCode === 'PROVIDER_CONFIGURATION_CHANGED'; }
$expect($providerMismatch, 'Retry reports stable provider-binding error rather than rerouting');
$config->set(['providers' => $testProviders], 'followup_audio');
$expect(Store::retry($changedQueued['id'])['status'] === 'queued', 'Explicit restoration of exact original provider allows safe pre-request retry');
$restored = Store::claim();
Store::fail((int) $restored['id'], $restored['lease_token'], 'LOCAL_PROBE_FAILED', '');
$switchUpload = $upload(); $switchTask = Store::create($switchUpload, $recordedAt, 'qwen', 1, $root);
$switchClaim = Store::claim();
Store::checkpoint($switchTask['id'], $switchClaim['lease_token'], ['upstream_started_at' => time()]);
$config->set(['providers' => $changedProviders], 'followup_audio');
$expect(!Store::heartbeat($switchTask['id'], $switchClaim['lease_token'])
&& !Store::checkpoint($switchTask['id'], $switchClaim['lease_token'], ['stage' => 'analyzing'])
&& !Store::complete($switchTask['id'], $switchClaim['lease_token'], []),
'Heartbeat checkpoint and complete independently fence provider drift');
$config->set(['enabled' => false, 'audio_verified' => false], 'followup_audio');
$expect(Store::fail($switchTask['id'], $switchClaim['lease_token'], 'PROVIDER_CONFIGURATION_CHANGED', 'must not persist', false),
'Configuration and feature withdrawal cannot prevent fenced failure persistence');
$failedSwitch = Store::task($switchTask['id']);
$expect($failedSwitch['status'] === 'needs_reconciliation' && $failedSwitch['lease_token'] === '',
'Attempted old-provider work is held for reconciliation, not left running');
$config->set(['enabled' => true, 'audio_verified' => true], 'followup_audio');
$switchReuse = Store::create($upload(1, 1, $switchUpload), $recordedAt, 'qwen', 1, $root);
$expect($switchReuse['id'] === $switchTask['id'] && $switchReuse['reused']
&& $switchReuse['status'] === 'needs_reconciliation' && str_contains($switchReuse['reuse_message'], '配置'),
'Reverified provider switch plus reupload cannot bypass unknown-request dedupe');
$config->set(['providers' => $testProviders], 'followup_audio');
$makeReview = static function (array $extraction, int $diagnosisId = 1, int $actor = 1) use ($upload, $recordedAt, $root): array {
$created = Store::create($upload($diagnosisId, $actor), $recordedAt, 'qwen', $actor, $root);
$claim = Store::claim();
+6 -2
View File
@@ -51,7 +51,7 @@ $expectError = static function (callable $call, string $code, bool $uncertain =
$expect(!str_contains($e->getMessage(), 'private-body') && !str_contains($e->getMessage(), 'synthetic-test-key'), 'redacted error');
}
};
$settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'ffprobe' => 'ffprobe', 'request_timeout' => 5, 'max_seconds' => 3600];
$settings = ['allow_insecure_synthetic' => true, 'enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'ffprobe' => 'ffprobe', 'request_timeout' => 5, 'max_seconds' => 3600];
$adapter = static function (string $scenario, array $extra = []) use ($port, $settings): Dify {
return new Dify(null, $extra + $settings, ['base_url' => 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1',
'models' => ['qwen' => ['api_key' => 'synthetic-test-key'], 'openai' => ['api_key' => 'synthetic-test-key']]]);
@@ -115,8 +115,12 @@ try {
$expectError(static fn () => $unverified->analyze([], $heartbeat), 'AUDIO_NOT_VERIFIED');
$notVerifiedProfile = new Dify($testTransport, ['verified_profiles' => ['qwen']] + $settings, $provider);
$expectError(static fn () => $notVerifiedProfile->analyze(['model_key' => 'openai'], $heartbeat), 'AUDIO_NOT_VERIFIED');
$safeProvider = ['base_url' => 'https://synthetic.invalid/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]];
$verifiedSettings = $settings;
$verifiedSettings['providers']['qwen']['verified_fingerprint'] = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $settings, $safeProvider)['fingerprint'];
$verifiedGuarded = new Dify($testTransport, $verifiedSettings, $safeProvider);
$guarded = new Dify($testTransport, $settings, $provider);
$expectError(static fn () => $guarded->analyze(['upstream_started_at' => 1, 'model_key' => 'qwen'], $heartbeat), 'RECONCILIATION_REQUIRED', true);
$expectError(static fn () => $verifiedGuarded->analyze(['upstream_started_at' => 1, 'model_key' => 'qwen'], $heartbeat), 'RECONCILIATION_REQUIRED', true);
$expectError(static fn () => $guarded->probe($wave, $fixture + ['irrelevant' => 'x'], 'unsupported', $heartbeat), 'INVALID_PROFILE');
$badFixture = $fixture; $badFixture['sha256'] = str_repeat('0', 64);
$expectError(static fn () => $guarded->probe($wave, $badFixture, 'qwen', $heartbeat), 'AUDIO_INVALID');
@@ -27,7 +27,9 @@ expectEndpoint(str_contains($controller, 'count($items) > 500'), 'bounded review
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' => array_values(array_filter("), 'only verified models advertised');
expectEndpoint(str_contains($logic, "'models' => ProviderConfig::publicModels()"), 'only fingerprint-verified server model labels advertised');
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');
expectEndpoint(str_contains($logic, "new \\DateTimeZone('Asia/Shanghai')"), 'explicit local date anchor timezone');
expectEndpoint(str_contains($stream, 'private, no-store, max-age=0'), 'private playback response');
+24 -5
View File
@@ -64,12 +64,31 @@ $makeTask = static function (string $path, string $extension) use ($directory):
'file_name' => 'synthetic.' . $extension, 'extension' => $extension, 'total_bytes' => filesize($target),
'received_bytes' => filesize($target), 'sha256' => $hash, 'duration_seconds' => $duration,
'status' => 'complete', 'created_at' => time(), 'expires_at' => time() + 86400]);
return ['id' => 1, 'upload_id' => $id, 'diagnosis_id' => 91, 'actor_id' => 7, 'model_key' => 'qwen',
return ['upstream_ids_json' => '{}', 'id' => 1, 'upload_id' => $id, 'diagnosis_id' => 91, 'actor_id' => 7, 'model_key' => 'qwen',
'recorded_at' => '2026-09-29 10:00:00', 'sha256' => $hash, 'duration_seconds' => $duration, 'path' => $target];
};
$adapter = static function (string $scenario, array $overrides = []) use ($port, $settings): Dify {
return new Dify(null, $overrides + $settings, ['base_url' => 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1',
'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]]);
$provider = ['base_url' => 'https://synthetic.invalid/' . $scenario . '/v1',
'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]];
$effective = $overrides + $settings;
// Keep a stable task identity while varying transport scenarios; the test seam owns scenario routing.
$provider['base_url'] = 'https://synthetic.invalid/v1';
$effective['providers']['qwen']['verified_fingerprint'] = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $effective, $provider)['fingerprint'];
$adapter = null;
$transport = static function (array $spec, callable $heartbeat) use (&$adapter, $port, $scenario): array {
$spec['url'] = str_replace('https://synthetic.invalid', 'http://127.0.0.1:' . $port . '/' . $scenario, $spec['url']);
// Reflection is a test seam only; production cURL still verifies HTTPS certificates.
$curl = new ReflectionMethod(Dify::class, 'curl');
return $curl->invoke($adapter, $spec, $heartbeat);
};
$adapter = new Dify($transport, $effective, $provider);
return $adapter;
};
$taskFingerprint = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $settings,
['base_url' => 'https://synthetic.invalid/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]])['fingerprint'];
$bindTask = static function (array $task) use ($taskFingerprint): array {
$task['upstream_ids_json'] = json_encode(['provider_fingerprint' => $taskFingerprint]);
return $task;
};
$requests = static fn (): array => is_file($directory . '/requests.jsonl') ? array_map(static fn (string $line): array =>
json_decode($line, true), file($directory . '/requests.jsonl', FILE_IGNORE_NEW_LINES)) : [];
@@ -87,7 +106,7 @@ try {
file_put_contents($directory . '/source.amr', "#!AMR\n" . str_repeat("\x3c" . str_repeat("\0", 31), 150));
$tasks = [];
foreach (['wav', 'm4a', 'mp3', 'amr'] as $extension) {
$task = $makeTask($directory . '/source.' . $extension, $extension); $tasks[$extension] = $task;
$task = $bindTask($makeTask($directory . '/source.' . $extension, $extension)); $tasks[$extension] = $task;
$events = []; $copies = [];
$heartbeat = static function (array $fields = []) use (&$events, &$copies, $task): bool {
$events[] = $fields;
@@ -140,7 +159,7 @@ try {
$expect(count($requests()) === $before + 2 && glob(dirname($wave['path']) . '/processing.*') === [],
'unknown HTTP result is not resent and processing copy is erased');
$expect(hash_file('sha256', $wave['path']) === $wave['sha256'], 'unknown result leaves original intact');
$makeWav($directory . '/long.wav', 3600); $long = $makeTask($directory . '/long.wav', 'wav');
$makeWav($directory . '/long.wav', 3600); $long = $bindTask($makeTask($directory . '/long.wav', 'wav'));
$copyMetadata = [];
$adapter('media-long')->analyze($long, static function (array $fields = []) use ($long, &$copyMetadata): bool {
if (isset($fields['upstream_started_at'])) {
+114
View File
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
/** Synthetic bytes, real ffmpeg/ffprobe, injected HTTP response; no network, .env or patient data. */
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioDify as Adapter;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioException as AudioError;
new think\App();
$directory = sys_get_temp_dir() . '/followup-audio-openai-' . bin2hex(random_bytes(6)); mkdir($directory, 0700, true);
$pdo = new PDO('sqlite:' . $directory . '/dictionary.sqlite');
$pdo->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' => $directory . '/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++; };
$expectError = static function (callable $call, string $code, bool $uncertain = false) use ($expect): void {
try { $call(); } catch (AudioError $error) {
$expect($error->errorCode === $code && $error->uncertain === $uncertain, 'expected ' . $code . ', got ' . $error->errorCode); return;
}
throw new RuntimeException('Expected ' . $code);
};
$slot = ['driver' => 'openai_audio', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key', 'model' => 'audio-model-from-config'];
$settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen'], 'providers' => ['qwen' => $slot],
'max_seconds' => 3600, 'ffmpeg' => 'ffmpeg', 'ffprobe' => 'ffprobe', 'request_timeout' => 5];
$fingerprint = Provider::resolve('qwen', $settings, [])['fingerprint'];
$settings['providers']['qwen']['verified_fingerprint'] = $fingerprint;
$wave = $directory . '/short.wav';
$samples = str_repeat(pack('v', 0), 48000);
$bytes = 'RIFF' . pack('V', 36 + strlen($samples)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16)
. 'data' . pack('V', strlen($samples)) . $samples;
file_put_contents($wave, $bytes);
$fixture = ['synthetic' => true, 'generator' => 'followup-audio-synthetic-v1', 'case' => 'short', 'sha256' => hash_file('sha256', $wave), 'duration_seconds' => 3];
$raw = ['schema_version' => 'followup-audio-v1', 'audio_processed' => true, 'summary' => '合成事实',
'transcript' => '昨天晚上九点,收缩压126,舒张压82。', 'uncertainties' => [], 'items' => [[
'kind' => 'blood', 'values' => ['systolic_pressure' => 126, 'diastolic_pressure' => 82],
'record_date' => null, 'record_time' => '21:00:00', 'time_period' => null, 'time_estimated' => false,
'date_text' => '昨天', 'time_text' => '晚上九点', 'evidence' => [['text' => '昨天晚上九点,收缩压126,舒张压82。']], 'needs_review' => false,
]]];
$events = []; $requests = []; $mode = 'success';
$heartbeat = static function (array $fields = []) use (&$events): bool { $events[] = $fields; return true; };
$transport = static function (array $spec) use (&$requests, &$events, &$mode, $raw, $expect): array {
$expect(!empty(end($events)['upstream_started_at']), 'intent is durable before a single chat request');
$requests[] = $spec;
$answer = $raw;
if ($mode === 'not_read') { $answer['audio_processed'] = false; }
$content = $mode === 'text_only' ? '没有收到可读取音频附件' : json_encode($answer, JSON_UNESCAPED_UNICODE);
$response = ['id' => 'synthetic-completion-id', 'choices' => [['finish_reason' => $mode === 'truncated' ? 'length' : 'stop', 'message' => ['role' => 'assistant', 'content' => $content]]]];
if ($mode === 'malformed_choices') { $response['choices'] = 'invalid'; }
return ['http_code' => $mode === 'unknown' ? 504 : ($mode === 'rejected' ? 400 : 200),
'errno' => 0, 'request_id' => 'synthetic-request-id', 'body' => json_encode($response)];
};
$adapter = new Adapter($transport, $settings, []);
try {
$result = $adapter->probe($wave, $fixture, 'qwen', $heartbeat);
$spec = $requests[0]; $content = $spec['json']['messages'][0]['content'];
$expect(count($requests) === 1 && $spec['url'] === 'https://synthetic.invalid/v1/chat/completions', 'one explicit OpenAI endpoint, no upload or Dify fallback');
$expect($spec['json']['model'] === $slot['model'], 'selected model actually sent');
$expect($content[1]['type'] === 'input_audio' && $content[1]['input_audio']['format'] === 'wav'
&& base64_decode($content[1]['input_audio']['data'], true) === $bytes, 'exact original WAV bytes attached');
$expect($result['items'][0]['record_date'] === '2026-09-28', 'same policy and temporal normalization');
$ids = json_decode(end($events)['upstream_ids_json'], true);
$expect($ids['provider_fingerprint'] === $fingerprint && $ids['message_id'] === 'synthetic-completion-id'
&& $ids['upstream_request_id'] === 'synthetic-request-id', 'response ID and bound identity checkpointed');
foreach (['not_read' => 'AUDIO_NOT_PROCESSED', 'text_only' => 'UPSTREAM_SCHEMA_INVALID', 'truncated' => 'UPSTREAM_SCHEMA_INVALID',
'malformed_choices' => 'UPSTREAM_SCHEMA_INVALID', 'rejected' => 'UPSTREAM_AUDIO_REJECTED', 'unknown' => 'UPSTREAM_UNCERTAIN'] as $scenario => $code) {
$mode = $scenario; $before = count($requests);
$expectError(static fn () => $adapter->probe($wave, $fixture, 'qwen', $heartbeat), $code, $scenario === 'unknown');
$expect(count($requests) === $before + 1, 'HTTP success/rejection/unknown never causes fallback or resend');
$ids = json_decode(end($events)['upstream_ids_json'], true);
$expect($ids['provider_fingerprint'] === $fingerprint && $ids['message_id'] === 'synthetic-completion-id', 'failed response retains identity and billable ID');
}
$mode = 'success';
$http = $settings; $http['providers']['qwen']['base_url'] = 'http://synthetic.invalid/v1';
$before = count($requests);
$expectError(static fn () => (new Adapter($transport, $http, []))->probe($wave, $fixture, 'qwen', $heartbeat), 'HTTPS_REQUIRED');
$http['allow_insecure_synthetic'] = true;
(new Adapter($transport, $http, []))->probe($wave, $fixture, 'qwen', $heartbeat);
$expect(count($requests) === $before + 1, 'HTTP requires explicit synthetic-only switch');
$expectError(static fn () => (new Adapter($transport, $http, []))->analyze(['model_key' => 'qwen'], $heartbeat), 'AUDIO_NOT_VERIFIED');
$before = count($requests);
$expectError(static fn () => $adapter->analyze(['model_key' => 'qwen', 'upstream_started_at' => 1], $heartbeat), 'RECONCILIATION_REQUIRED', true);
$expect(count($requests) === $before, 'started tasks never resend');
$analyze = new ReflectionMethod(Adapter::class, 'analyzeFile');
$task = ['id' => 'synthetic-task', 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen',
'sha256' => $fixture['sha256'], 'duration_seconds' => 3, 'upstream_ids_json' => '{}'];
$expectError(static fn () => $analyze->invoke($adapter, $wave, $task, $heartbeat), 'PROVIDER_CONFIGURATION_CHANGED');
$task['upstream_ids_json'] = json_encode(['provider_fingerprint' => str_repeat('0', 64)]);
$expectError(static fn () => $analyze->invoke($adapter, $wave, $task, $heartbeat), 'PROVIDER_CONFIGURATION_CHANGED');
$expect(count($requests) === $before, 'missing or changed provider snapshot fails before sending');
$task['upstream_ids_json'] = json_encode(['provider_fingerprint' => $fingerprint]);
// M4A and AMR must fully normalize to MP3, not be mislabeled input_audio formats.
$p = proc_open(['ffmpeg', '-nostdin', '-v', 'error', '-i', $wave, '-c:a', 'aac', $directory . '/short.m4a'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
fclose($pipes[0]); stream_get_contents($pipes[1]); stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]);
$expect(proc_close($p) === 0, 'synthetic M4A generated');
file_put_contents($directory . '/short.amr', "#!AMR\n" . str_repeat("\x3c" . str_repeat("\0", 31), 150));
foreach (['m4a', 'amr'] as $extension) {
$path = $directory . '/short.' . $extension; $hash = hash_file('sha256', $path); $task['sha256'] = $hash;
$analyze->invoke($adapter, $path, $task, $heartbeat);
$audio = end($requests)['json']['messages'][0]['content'][1]['input_audio'];
$expect($audio['format'] === 'mp3' && base64_decode($audio['data'], true) !== false, $extension . ' complete MP3 processing payload');
$expect(hash_file('sha256', $path) === $hash && glob($directory . '/processing.*') === [], $extension . ' original preserved and private copy cleaned');
}
$source = file_get_contents(dirname(__DIR__) . '/app/common/service/followupaudio/FollowupAudioDify.php');
$expect(str_contains($source, 'CURLOPT_SSL_VERIFYPEER => true') && str_contains($source, 'CURLOPT_SSL_VERIFYHOST => 2'), 'TLS validation is never disabled');
echo 'FOLLOWUP_AUDIO_OPENAI assertions=' . $checks . ' PASS real_input_audio=1 model_configurable=1 no_text_fallback=1 no_resend=1 https_patient_gate=1 full_normalization=1' . PHP_EOL;
} finally {
$manager->connect()->close(); $pdo = null;
foreach (glob($directory . '/*') ?: [] as $path) { if (is_file($path)) { unlink($path); } }
rmdir($directory);
}
+25 -7
View File
@@ -6,7 +6,7 @@ namespace app\common\service\followupaudio {
/** Command-state test double. Actual HTTP/audio behavior is covered by FollowupAudioDifyTest. */
final class FollowupAudioDify {
public static string $mode = 'uncertain';
public static string $fingerprint = 'synthetic-application-one';
public static string $fingerprint = '1111111111111111111111111111111111111111111111111111111111111111';
public static int $calls = 0;
public function configurationStatus(string $profile): array { return ['configured' => true, 'profile' => $profile, 'code' => 'OK', 'application_fingerprint' => self::$fingerprint]; }
public function probe(string $path, array $fixture, string $profile, callable $heartbeat): array {
@@ -24,6 +24,8 @@ namespace {
use app\command\FollowupAudioProbe as Probe;
use think\console\Input;
use think\console\Output;
new think\App(); // No initialization, real config or DB.
$safeConfig = new think\Config(); think\Container::getInstance()->instance('config', $safeConfig);
$directory = sys_get_temp_dir() . '/followup-audio-probe-state-' . bin2hex(random_bytes(6));
mkdir($directory, 0700, true);
$directory = realpath($directory);
@@ -52,26 +54,42 @@ namespace {
$expect($code === 1 && Dify::$calls === 0 && str_contains($stdout, 'SYNTHETIC_ACK_REQUIRED'), 'explicit synthetic acknowledgement required');
[$code, $stdout] = $run('qwen');
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'needs_reconciliation'), 'unknown result stops next cases');
$report = json_decode(file_get_contents($directory . '/probe-qwen.json'), true);
$report = json_decode(file_get_contents($directory . '/probe-qwen-' . Dify::$fingerprint . '.json'), true);
$expect($report['cases']['short']['upstream_started_at'] > 0, 'intent persisted before request');
[$code, $stdout] = $run('qwen');
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'resume never recharges unknown result');
$currentQwenPath = $directory . '/probe-qwen-' . Dify::$fingerprint . '.json';
$legacyQwenPath = $directory . '/probe-qwen.json';
rename($currentQwenPath, $legacyQwenPath);
$legacyBytes = file_get_contents($legacyQwenPath);
[$code, $stdout] = $run('qwen');
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'matching legacy identity remains authoritative for unknown requests');
$expect(file_get_contents($legacyQwenPath) === $legacyBytes, 'legacy unknown evidence retained unchanged');
$legacyReport = json_decode($legacyBytes, true);
$legacyReport['configuration']['application_fingerprint'] = hash('sha256', "https://synthetic.invalid/v1\0synthetic-key");
file_put_contents($legacyQwenPath, json_encode($legacyReport));
$safeConfig->set(['providers' => ['qwen' => ['driver' => 'dify', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key']]], 'followup_audio');
[$code, $stdout] = $run('qwen');
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'legacy Dify hash upgrade cannot resubmit same-app unknown');
$safeConfig->set([], 'followup_audio');
Dify::$mode = 'success';
[$code, $stdout] = $run('openai');
$expect($code === 0 && Dify::$calls === 4, 'three lengths passed sequentially');
$bytes = file_get_contents($directory . '/probe-openai.json');
$bytes = file_get_contents($directory . '/probe-openai-' . Dify::$fingerprint . '.json');
$report = json_decode($bytes, true);
$expect($report['audio_verified'] && count($report['cases']) === 3, 'all three required before verification report');
$expect(!str_contains($bytes . $stdout, 'PRIVATE_TRANSCRIPT_NEVER_PRINTED'), 'no transcript in output or report');
[$code, $stdout] = $run('openai');
$expect($code === 0 && Dify::$calls === 4 && substr_count($stdout, 'retained_passed') === 3, 'passed gate results reused without resending');
Dify::$fingerprint = 'synthetic-application-two';
$oldPath = $directory . '/probe-openai-' . Dify::$fingerprint . '.json';
Dify::$fingerprint = '2222222222222222222222222222222222222222222222222222222222222222';
[$code, $stdout] = $run('openai');
$expect($code === 1 && Dify::$calls === 4 && str_contains($stdout, 'PROBE_APPLICATION_CHANGED'), 'new application cannot inherit old gate');
$expect(file_get_contents($directory . '/probe-openai.json') === $bytes, 'application mismatch leaves original evidence unchanged');
$expect($code === 0 && Dify::$calls === 7 && !str_contains($stdout, 'retained_passed'), 'new application executes its own gate');
$expect(file_get_contents($oldPath) === $bytes, 'application mismatch leaves original evidence unchanged');
file_put_contents($directory . '/short.mp3', 'tampered');
[$code, $stdout] = $run('qwen');
$expect($code === 1 && Dify::$calls === 4 && str_contains($stdout, 'SYNTHETIC_FIXTURE_REQUIRED'), 'tampered fixture cannot run');
$expect($code === 1 && Dify::$calls === 7 && str_contains($stdout, 'SYNTHETIC_FIXTURE_REQUIRED'), 'tampered fixture cannot run');
echo 'FOLLOWUP_AUDIO_PROBE_COMMAND assertions=' . $checks . ' PASS durable_no_resend=1 retained_results=1 app_identity=1' . PHP_EOL;
} finally {
foreach (glob($directory . '/*') ?: [] as $file) { if (is_file($file)) { unlink($file); } }
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/** No app initialization, .env, network or real credentials. */
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioException as AudioError;
new think\App();
$config = new think\Config();
think\Container::getInstance()->instance('config', $config);
$checks = 0;
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
$expectError = static function (callable $call, string $code) use ($expect): void {
try { $call(); } catch (AudioError $error) { $expect($error->errorCode === $code, $code); return; }
throw new RuntimeException('Expected ' . $code);
};
$legacy = ['base_url' => 'https://legacy.invalid/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-legacy-key', 'name' => 'must-not-inherit']]];
$dify = Provider::resolve('qwen', [], $legacy);
$expect($dify['driver'] === 'dify' && $dify['base_url'] === $legacy['base_url'] && $dify['model'] === '', 'legacy Dify app identity allowed without fake internal model');
$slot = ['driver' => 'openai_audio', 'base_url' => 'https://audio.invalid/v1/chat/completions',
'api_key' => 'synthetic-provider-key', 'model' => 'configurable-audio-model', 'label' => 'Audio label'];
$settings = ['enabled' => false, 'audio_verified' => true, 'verified_profiles' => ['qwen'], 'providers' => ['qwen' => $slot]];
$resolved = Provider::resolve('qwen', $settings, $legacy);
$expect($resolved['base_url'] === 'https://audio.invalid/v1' && $resolved['model'] === $slot['model'], 'explicit selected driver model and normalized endpoint');
$settings['providers']['qwen']['verified_fingerprint'] = $resolved['fingerprint'];
$config->set($settings, 'followup_audio'); $config->set($legacy, 'prescription_ai');
$expect(Provider::isVerified('qwen'), 'verified state independent of disabled operational switch');
$expect(Provider::publicModels() === [['value' => 'qwen', 'label' => 'Audio label']], 'only verified slots exposed');
$status = Provider::status('qwen', $settings, $legacy);
$expect(array_keys($status) === ['configured', 'profile', 'code', 'application_fingerprint'], 'safe exact status keys');
$public = json_encode([$status, Provider::publicModels()]);
$expect(!str_contains($public, 'synthetic-provider-key') && !str_contains($public, 'audio.invalid'), 'public response contains no endpoint or key');
foreach (['driver' => 'dify', 'base_url' => 'https://changed.invalid/v1', 'model' => 'another-model', 'api_key' => 'other-synthetic-key'] as $field => $value) {
$changed = $settings; $changed['providers']['qwen'][$field] = $value;
$expect(!Provider::verified('qwen', $changed, $legacy), 'identity change invalidates ' . $field);
}
$changed = $settings; $changed['providers']['qwen']['label'] = 'Renamed display';
$expect(Provider::verified('qwen', $changed, $legacy), 'display-only label does not change request identity');
$changed = $settings; $changed['providers']['qwen']['label'] = '';
$expect(Provider::resolve('qwen', $changed, $legacy)['label'] === $slot['model'], 'empty label falls back to configured model');
$changed = $settings; $changed['providers']['qwen']['base_url'] = 'http://audio.invalid/v1';
$changed['providers']['qwen']['verified_fingerprint'] = Provider::resolve('qwen', $changed, $legacy)['fingerprint'];
$changed['allow_insecure_synthetic'] = true;
$expect(!Provider::verified('qwen', $changed, $legacy), 'HTTP never becomes production-verified');
foreach (['model', 'base_url', 'api_key'] as $field) {
$changed = $settings; $changed['providers']['qwen'][$field] = '';
$expectError(static fn () => Provider::resolve('qwen', $changed, $legacy), 'CONFIG_MISSING');
}
foreach (['ftp://audio.invalid/v1', 'https://user:password@audio.invalid/v1', 'https://audio.invalid/v1?token=x',
"https://audio.invalid/v1\n", 'https://audio.invalid/v1#fragment'] as $url) {
$changed = $settings; $changed['providers']['qwen']['base_url'] = $url;
$expectError(static fn () => Provider::resolve('qwen', $changed, []), 'CONFIG_INVALID');
}
$changed = $settings; $changed['providers']['qwen']['driver'] = 'automatic';
$expectError(static fn () => Provider::resolve('qwen', $changed, []), 'CONFIG_INVALID');
$changed = $settings; $changed['providers']['qwen']['verified_fingerprint'] = '';
$expect(!Provider::verified('qwen', $changed, $legacy), 'old unbound flags do not enable audio');
$expect(Provider::status('invalid', $settings, $legacy)['code'] === 'INVALID_PROFILE', 'logical slot allowlist retained');
echo 'FOLLOWUP_AUDIO_PROVIDER_CONFIG assertions=' . $checks . ' PASS explicit_driver=1 model_configurable=1 exact_fingerprint=1 https_verified=1 secrets_hidden=1' . PHP_EOL;
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/** No autoloader, app, .env, filesystem inputs, network or DB: public gate must fail closed before I/O. */
namespace think\facade {
final class Db
{
public static int $calls = 0;
public static function __callStatic(string $name, array $args): never
{
self::$calls++;
throw new \RuntimeException('DATABASE_MUST_NOT_BE_USED');
}
}
}
namespace {
$settings = [];
function config(string $key, $default = null)
{
global $settings;
$value = $settings;
foreach (explode('.', $key) as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) { return $default; }
$value = $value[$part];
}
return $value;
}
$root = dirname(__DIR__) . '/app/common/service/followupaudio/';
foreach (['Exception', 'ProviderConfig', '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;
use app\adminapi\logic\tcm\FollowupAudioLogic as Logic;
$checks = 0;
$expect = static function (bool $ok, string $why) use (&$checks): void {
if (!$ok) { throw new RuntimeException($why); }
$checks++;
};
$expect(!Store::enabled() && !Store::verified() && !Store::verified('qwen'), 'Missing app configuration stays disabled and unverified');
$expect(Store::claim() === null, 'Missing app configuration never enters queue transaction');
$expect(Providers::publicModels() === [], 'Missing provider configuration publishes no models');
try { Store::assertEnabled('qwen'); throw new RuntimeException('Expected disabled gate'); }
catch (DomainException $error) { $expect($error->getMessage() === 'FOLLOWUP_AUDIO_DISABLED_OR_UNVERIFIED', 'Stable disabled error'); }
try { Logic::requireEnabled(true); throw new RuntimeException('Expected public disabled gate'); }
catch (DomainException $error) { $expect($error->getMessage() === '回访录音功能尚未启用', 'Public gate avoids database'); }
$settings['followup_audio'] = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen']];
$expect(!Store::verified(), 'Unbound flags alone never grant capability');
try { Logic::requireEnabled(true); throw new RuntimeException('Expected public unverified gate'); }
catch (DomainException $error) {
$expect(str_contains($error->getMessage(), '当前服务与模型') && !str_contains($error->getMessage(), 'Dify'), 'Public unverified error is provider-neutral');
}
$expect(Providers::publicModels() === [], 'Unconfigured flags do not advertise models');
$provider = ['driver' => 'openai_audio', 'base_url' => 'https://synthetic.invalid/v1',
'api_key' => 'synthetic-test-only', 'model' => 'audio-fixture-v1', 'label' => 'Synthetic audio model'];
$settings['followup_audio']['providers']['qwen'] = $provider;
$fingerprint = Providers::resolve('qwen')['fingerprint'];
$settings['followup_audio']['providers']['qwen']['verified_fingerprint'] = $fingerprint;
$task = ['model_key' => 'qwen', 'upstream_ids_json' => json_encode(['provider_fingerprint' => $fingerprint]), 'upstream_started_at' => 0];
$expect(Store::verified('qwen') && Store::providerMatches($task), 'Exact approved provider matches bound task');
$expect(Providers::publicModels() === [['value' => 'qwen', 'label' => 'Synthetic audio model']], 'Public model list contains only configured value/label');
$public = json_encode(Providers::publicModels());
$expect(!str_contains($public, 'synthetic.invalid') && !str_contains($public, $provider['api_key']), 'Public labels never expose server URL/key');
foreach (['{}', 'not-json', '{"provider_fingerprint":null}', '{"provider_fingerprint":123}'] as $ids) {
$unbound = array_replace($task, ['upstream_ids_json' => $ids]);
$expect(!Store::providerMatches($unbound), 'Missing malformed or invalid fingerprint cannot be trusted');
}
foreach (['driver' => 'dify', 'base_url' => 'https://changed.invalid/v1', 'api_key' => 'rotated-synthetic-key', 'model' => 'audio-fixture-v2'] as $field => $changed) {
$settings['followup_audio']['providers']['qwen'] = $provider + ['verified_fingerprint' => $fingerprint];
$settings['followup_audio']['providers']['qwen'][$field] = $changed;
$expect(!Store::verified('qwen') && !Store::providerMatches($task), 'Provider identity drift invalidates capability and task binding: ' . $field);
}
$expect(\think\facade\Db::$calls === 0, 'All missing-config and provider-binding checks performed without database I/O');
echo 'FOLLOWUP_AUDIO_PROVIDER_INTEGRATION assertions=' . $checks . " PASS database_calls=0 app_initialized=0 network_calls=0\n";
}
@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
/**
* Opt-in, LOCAL ONLY acceptance of user-supplied audio. No ASR, provider calls, application bootstrap or .env.
* Usage: php tests/FollowupAudioSampleAcceptanceTest.php --manifest /absolute/private/manifest.json
* A manifest must be a private (0600) local JSON file OUTSIDE Git, with this schema:
* {"samples":[{"id":"sample-1","path":"/absolute/local/audio.mp3","sha256":"<64 lowercase hex>"}],
* "recorded_at_cases":[{"id":"ordinary","recorded_at":"2026-10-07 00:00:00"},
* {"id":"month","recorded_at":"2026-03-01 00:00:00"},
* {"id":"year","recorded_at":"2026-01-01 00:00:00"},
* {"id":"leap","recorded_at":"2024-03-01 00:00:00"}]}
* Optional --ffmpeg/--ffprobe select absolute local executables; otherwise PATH is used.
* Real paths, recordings, keys and transcripts MUST NOT be added to committed fixtures.
* Date candidates below are explicitly SYNTHETIC, never transcripts of the supplied recordings.
*/
namespace app\common\service\followupaudio {
/** Test-only access boundary: upload implementation and SQLite are real; production RBAC is NOT exercised. */
final class FollowupAudioAccess
{
public static function diagnosis(int $id, int $actor, array $info, bool $daily = false): array
{
if ($id !== 91 || $actor !== 7) { throw new \DomainException('SYNTHETIC_SCOPE_DENIED'); }
return ['id' => 91, 'patient_id' => 101];
}
}
}
namespace {
use app\common\service\followupaudio\FollowupAudioDify as Dify;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
use think\Container;
use think\facade\Db;
function fail(string $code): never { throw new RuntimeException($code); }
function expect(bool $condition, string $code): void
{
if (!$condition) { fail($code); }
$GLOBALS['sample_checks']++;
}
function reject(callable $call, string $code): void
{
try { $call(); } catch (DomainException | RuntimeException $e) {
expect($e->getMessage() === $code, 'WRONG_REJECTION_CODE');
return;
}
fail('EXPECTED_REJECTION');
}
function exactKeys(array $value, array $keys): bool
{
$actual = array_keys($value); sort($actual); sort($keys);
return $actual === $keys;
}
function localFile(string $path, string $error): string
{
if ($path === '' || $path[0] !== '/' || preg_match('/[\x00-\x1f\x7f]/', $path)
|| preg_match('~/(?:\.|\.\.)(?:/|$)~', $path) || is_link($path) || !is_file($path) || !is_readable($path)) {
fail($error);
}
$real = realpath($path);
if ($real === false) { fail($error); }
// Reject symlink components too; no implicit reads through a linked private input.
$part = '';
foreach (explode('/', ltrim($path, '/')) as $component) {
$part .= '/' . $component;
if (is_link($part)) { fail($error); }
}
return $real;
}
function outsideGit(string $path): bool
{
for ($dir = dirname($path); ; $dir = dirname($dir)) {
if (file_exists($dir . '/.git')) { return false; }
if ($dir === dirname($dir)) { return true; }
}
}
function parseManifest(string $json): array
{
try { $value = json_decode($json, true, 64, JSON_THROW_ON_ERROR); }
catch (JsonException $e) { fail('MANIFEST_JSON_INVALID'); }
if (!is_array($value) || !exactKeys($value, ['samples', 'recorded_at_cases'])
|| !is_array($value['samples']) || !array_is_list($value['samples']) || count($value['samples']) < 1
|| count($value['samples']) > 20 || !is_array($value['recorded_at_cases']) || !array_is_list($value['recorded_at_cases'])) {
fail('MANIFEST_SCHEMA_INVALID');
}
$ids = []; $paths = [];
foreach ($value['samples'] as &$sample) {
if (!is_array($sample) || !exactKeys($sample, ['id', 'path', 'sha256'])
|| !is_string($sample['id']) || !preg_match('/^sample-[1-9][0-9]?$/D', $sample['id'])
|| isset($ids[$sample['id']]) || !is_string($sample['path'])
|| !is_string($sample['sha256']) || !preg_match('/^[a-f0-9]{64}$/D', $sample['sha256'])) {
fail('MANIFEST_SAMPLE_INVALID');
}
$sample['path'] = localFile($sample['path'], 'SAMPLE_PATH_INVALID');
if (isset($paths[$sample['path']])) { fail('MANIFEST_SAMPLE_DUPLICATE'); }
$ids[$sample['id']] = true; $paths[$sample['path']] = true;
}
unset($sample);
$expected = ['ordinary' => '2026-10-07', 'month' => '2026-03-01', 'year' => '2026-01-01', 'leap' => '2024-03-01'];
$dates = [];
foreach ($value['recorded_at_cases'] as $case) {
if (!is_array($case) || !exactKeys($case, ['id', 'recorded_at']) || !is_string($case['id'])
|| !isset($expected[$case['id']]) || isset($dates[$case['id']]) || !is_string($case['recorded_at'])
|| substr($case['recorded_at'], 0, 10) !== $expected[$case['id']]) { fail('MANIFEST_DATES_INVALID'); }
try { Policy::strictRecordedAt($case['recorded_at']); }
catch (DomainException $e) { fail('MANIFEST_DATES_INVALID'); }
$dates[$case['id']] = $case['recorded_at'];
}
if (count($dates) !== count($expected)) { fail('MANIFEST_DATES_INVALID'); }
return $value;
}
function process(array $command): array
{
$child = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($child)) { fail('DECODE_PROCESS_UNAVAILABLE'); }
fclose($pipes[0]); stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false);
$stdout = ''; $stderr = ''; $exit = -1; $deadline = microtime(true) + 180;
try {
do {
$stdout .= stream_get_contents($pipes[1]); $stderr .= stream_get_contents($pipes[2]);
$state = proc_get_status($child);
if (!$state['running']) { $exit = $state['exitcode']; break; }
if (microtime(true) > $deadline || strlen($stdout) + strlen($stderr) > 1048576) {
proc_terminate($child, 9); fail('DECODE_PROCESS_LIMIT');
}
usleep(10000);
} while (true);
$stdout .= stream_get_contents($pipes[1]); $stderr .= stream_get_contents($pipes[2]);
} finally {
fclose($pipes[1]); fclose($pipes[2]); $closed = proc_close($child);
}
return ['command' => $command, 'stdout' => $stdout, 'stderr' => $stderr, 'exit_status' => $exit < 0 ? $closed : $exit];
}
function eraseOwnedTree(string $root): void
{
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
$file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($root);
}
$GLOBALS['sample_checks'] = 0; $directory = null; $originals = []; $exit = 0; $oldMask = umask(0077);
set_error_handler(static function (int $severity): bool {
if (!(error_reporting() & $severity)) { return false; }
// Never print a warning that might contain a source filename or private input.
throw new RuntimeException('LOCAL_IO_FAILED');
});
try {
if ($argc === 1) {
echo "SKIP Followup audio real samples: opt in with --manifest outside Git; no files read, no ASR.\n";
} else {
$options = [];
for ($i = 1; $i < $argc; $i += 2) {
if (!in_array($argv[$i], ['--manifest', '--ffmpeg', '--ffprobe'], true) || !isset($argv[$i + 1])
|| isset($options[$argv[$i]])) { fail('ARGUMENTS_INVALID'); }
$options[$argv[$i]] = $argv[$i + 1];
}
if (!isset($options['--manifest'])) { fail('MANIFEST_REQUIRED'); }
$manifestPath = localFile($options['--manifest'], 'MANIFEST_PATH_INVALID');
if (!outsideGit($manifestPath)) { fail('MANIFEST_MUST_BE_OUTSIDE_GIT'); }
if ((fileperms($manifestPath) & 0777) !== 0600 || filesize($manifestPath) > 65536) { fail('MANIFEST_NOT_PRIVATE_OR_TOO_LARGE'); }
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
$manifest = parseManifest(file_get_contents($manifestPath));
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) { fail('SQLITE_REQUIRED_NO_PRODUCTION_FALLBACK'); }
$tools = [];
foreach (['ffmpeg', 'ffprobe'] as $name) {
$tools[$name] = $options['--' . $name] ?? $name;
if (isset($options['--' . $name]) && ($tools[$name][0] !== '/' || !is_executable($tools[$name]))) { fail('MEDIA_TOOL_INVALID'); }
}
$directory = realpath(sys_get_temp_dir()) . '/followup-audio-samples-' . bin2hex(random_bytes(12));
if (!mkdir($directory . '/private', 0700, true)) { fail('PRIVATE_DIRECTORY_FAILED'); }
new think\App(); // Intentionally NOT initialize(): never loads real app config/services/.env.
$database = $directory . '/test.sqlite';
$pdo = new PDO('sqlite:' . $database); chmod($database, 0600);
$pdo->exec('CREATE TABLE sample_followup_audio_upload (id TEXT PRIMARY KEY, diagnosis_id INT, actor_id INT,
file_name TEXT, extension TEXT, total_bytes INT, received_bytes INT, sha256 TEXT, duration_seconds REAL,
status TEXT, created_at INT, expires_at INT)');
$manager = new think\DbManager();
$manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => [
'type' => 'sqlite', 'database' => $database, 'prefix' => 'sample_']]]);
Container::getInstance()->instance('think\DbManager', $manager);
$settings = ['enabled' => false, 'audio_verified' => false, 'verified_profiles' => [],
'private_dir' => $directory . '/private', 'ffmpeg' => $tools['ffmpeg'], 'ffprobe' => $tools['ffprobe'],
'max_bytes' => 524288000, 'max_seconds' => 3600, 'chunk_bytes' => 65536,
'upstream_max_bytes' => 20971520, 'normalize_timeout' => 120];
$config = new think\Config(); $config->set($settings, 'followup_audio');
Container::getInstance()->instance('config', $config);
$networkAttempts = 0;
$adapter = new Dify(static function () use (&$networkAttempts): never { $networkAttempts++; fail('NETWORK_FORBIDDEN'); }, $settings, []);
$inspect = new ReflectionMethod(Dify::class, 'inspectAudio');
$prepare = new ReflectionMethod(Dify::class, 'prepareAudio');
$inspect->setAccessible(true); $prepare->setAccessible(true);
$media = [];
foreach ($manifest['samples'] as $sample) {
$path = $sample['path']; $hash = (string) hash_file('sha256', $path); $originals[$path] = $hash;
expect(hash_equals($sample['sha256'], $hash), 'INPUT_HASH_MISMATCH');
[, $extension] = Upload::fileName('sample.' . strtolower(pathinfo($path, PATHINFO_EXTENSION)));
$baseline = Upload::inspect($path, $extension);
$session = Upload::createSession(91, $sample['id'] . '.' . $extension, filesize($path), 7, []);
$source = fopen($path, 'rb'); $chunks = 0;
try {
while (!feof($source)) {
$contents = fread($source, $session['chunk_bytes']);
if ($contents === '') { break; }
file_put_contents($directory . '/chunk', $contents); chmod($directory . '/chunk', 0600);
Upload::putChunk($session['upload_id'], $chunks++, $directory . '/chunk', 7, []);
}
} finally { fclose($source); }
$result = Upload::complete($session['upload_id'], 7, []);
expect(hash_equals($hash, $result['sha256']), 'REASSEMBLY_HASH_MISMATCH');
expect(Upload::complete($session['upload_id'], 7, []) === $result, 'COMPLETION_NOT_IDEMPOTENT');
$assembled = Upload::path(Upload::session($session['upload_id']));
expect(hash_file('sha256', $assembled) === $hash, 'ASSEMBLED_BYTES_CHANGED');
expect(abs($baseline['duration_seconds'] - $result['duration_seconds']) < 0.001, 'ASSEMBLED_DURATION_CHANGED');
$decode = process([$tools['ffmpeg'], '-nostdin', '-hide_banner', '-v', 'error', '-xerror',
'-protocol_whitelist', 'file,pipe', '-threads', '1', '-i', $assembled, '-map', '0:a:0',
'-vn', '-sn', '-dn', '-f', 'null', '-']);
expect($decode['exit_status'] === 0, 'FULL_DECODE_FAILED');
$local = $inspect->invoke($adapter, $assembled, $hash);
expect(abs($local['duration'] - $baseline['duration_seconds']) <= 0.001, 'DIFY_LOCAL_INSPECT_MISMATCH');
// Only private LOCAL preprocessing helpers: never analyze(), probe(), or a provider response.
$prepared = $prepare->invoke($adapter, $local, static fn (): bool => true);
$temporary = !empty($prepared['temporary']);
if ($temporary) {
expect(dirname($prepared['path']) === dirname($assembled), 'PROCESSING_COPY_SCOPE_INVALID');
expect((fileperms($prepared['path']) & 0777) === 0600, 'PROCESSING_COPY_NOT_PRIVATE');
unlink($prepared['path']);
} else { expect($prepared['path'] === $assembled, 'UNEXPECTED_PREPARATION_PATH'); }
expect(hash_file('sha256', $path) === $hash && hash_file('sha256', $assembled) === $hash, 'ORIGINAL_CHANGED');
$media[] = ['id' => $sample['id'], 'bytes' => filesize($path), 'sha256' => $hash,
'duration_seconds' => $result['duration_seconds'], 'chunks' => $chunks,
'upload' => 'real_service_with_synthetic_access_and_disposable_sqlite',
'decode' => $decode, 'local_prepare' => $temporary ? 'private_processing_copy' : 'original_compatible_unchanged',
'original_sha256_unchanged' => true];
}
$dateResults = [];
$yesterdays = ['ordinary' => '2026-10-06', 'month' => '2026-02-28', 'year' => '2025-12-31', 'leap' => '2024-02-29'];
foreach ($manifest['recorded_at_cases'] as $case) {
$today = substr($case['recorded_at'], 0, 10);
$cases = [
['today', '今天', $today, '11:21', null, false, false],
['yesterday', '昨天', $yesterdays[$case['id']], '11:21', null, false, false],
['ambiguous-last-week', '上周', null, '11:21', null, false, true],
];
foreach (['凌晨' => null, '早晨' => '08:00', '上午' => '08:00', '中午' => '12:00',
'下午' => '15:00', '晚上' => '20:00', '睡前' => '22:00', '' => null] as $period => $clock) {
$cases[] = ['estimated-' . ($period ?: 'unspecified'), '今天', $today, $clock, $period ?: null, true, true];
}
foreach ($cases as [$id, $dateText, $expectedDate, $clock, $period, $estimate, $review]) {
$quote = $dateText . ($period ?? '') . '空腹血糖6.7';
$raw = ['kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.7], 'date_text' => $dateText,
'record_date' => $id === 'ambiguous-last-week' ? $today : null, 'record_time' => $estimate ? null : $clock,
'time_period' => $period, 'evidence' => [['text' => $quote]]];
$normalized = Policy::normalizeExtraction(['transcript' => $quote, 'summary' => 'Synthetic date-only candidate, NOT ASR',
'items' => [$raw], 'uncertainties' => []], $case['recorded_at']);
expect(count($normalized['items']) === 1, 'SYNTHETIC_CANDIDATE_MISSING');
$item = $normalized['items'][0];
expect($item['record_date'] === $expectedDate, 'SYNTHETIC_DATE_MISMATCH');
expect($item['record_time'] === $clock, 'SYNTHETIC_CLOCK_MISMATCH');
expect($item['time_estimated'] === $estimate, 'SYNTHETIC_ESTIMATION_MISMATCH');
expect($item['needs_review'] === $review && $item['selected'] === false, 'SYNTHETIC_REVIEW_MISMATCH');
$dateResults[] = ['case' => $case['id'] . ':' . $id, 'synthetic_only' => true,
'record_date' => $item['record_date'], 'record_time' => $item['record_time'],
'time_estimated' => $item['time_estimated'], 'needs_review' => $item['needs_review'], 'selected' => false];
}
}
// Input rejection tests use only anonymous temporary paths; no application/environment files are read.
$beforeNegative = $GLOBALS['sample_checks'];
reject(static fn () => parseManifest('{'), 'MANIFEST_JSON_INVALID');
reject(static fn () => parseManifest('{}'), 'MANIFEST_SCHEMA_INVALID');
foreach (['https://example.invalid/audio.mp3', 'relative.mp3', $directory . '/../missing.mp3', $directory . '/missing.mp3', $directory] as $bad) {
$invalid = $manifest; $invalid['samples'][0]['path'] = $bad;
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'SAMPLE_PATH_INVALID');
}
symlink($directory . '/chunk', $directory . '/linked.mp3');
$invalid = $manifest; $invalid['samples'][0]['path'] = $directory . '/linked.mp3';
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'SAMPLE_PATH_INVALID');
unlink($directory . '/linked.mp3');
$invalid = $manifest; $invalid['samples'][] = $invalid['samples'][0];
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'MANIFEST_SAMPLE_INVALID');
$invalid = $manifest; $invalid['recorded_at_cases'][0]['recorded_at'] = '2026-02-30 00:00:00';
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'MANIFEST_DATES_INVALID');
$negativeChecks = $GLOBALS['sample_checks'] - $beforeNegative;
expect($networkAttempts === 0, 'NETWORK_WAS_ATTEMPTED');
expect((fileperms($directory) & 0777) === 0700, 'PRIVATE_ROOT_MODE_INVALID');
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $file) {
expect(!$file->isLink() && (($file->getPerms() & 0777) === ($file->isDir() ? 0700 : 0600)), 'PRIVATE_MODE_INVALID');
}
echo json_encode(['suite' => 'followup-audio-local-samples-v1', 'status' => 'PASS',
'checks' => $GLOBALS['sample_checks'], 'media_samples' => count($media), 'synthetic_date_cases' => count($dateResults),
'negative_input_checks' => $negativeChecks, 'asr' => 'NOT_RUN', 'network_attempts' => $networkAttempts,
'production_database' => 'NOT_USED', 'app_env' => 'NOT_LOADED',
'limitations' => ['Synthetic Access stub does not validate production RBAC.',
'SQLite upload acceptance does not validate production MySQL or HTTP endpoints.',
'Date results are synthetic policy inputs, not recognition of supplied recordings.',
'No Dify upload, model recognition, transcript accuracy, or business writeback is claimed.'],
'media' => $media, 'synthetic_dates' => $dateResults], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL;
}
} catch (Throwable $error) {
$code = preg_match('/^[A-Z0-9_]+$/D', $error->getMessage()) ? $error->getMessage() : 'LOCAL_ACCEPTANCE_FAILED';
fwrite(STDERR, 'FAIL ' . $code . PHP_EOL); $exit = 1;
} finally {
foreach ($originals as $path => $hash) {
if (!is_file($path) || hash_file('sha256', $path) !== $hash) { fwrite(STDERR, "FAIL ORIGINAL_HASH_CHANGED\n"); $exit = 1; }
}
if ($directory !== null && is_dir($directory)) {
try { eraseOwnedTree($directory); }
catch (Throwable $error) { fwrite(STDERR, "FAIL TEMPORARY_CLEANUP_FAILED\n"); $exit = 1; }
}
umask($oldMask); restore_error_handler();
}
exit($exit);
}
+20 -3
View File
@@ -14,9 +14,11 @@ namespace app\common\service\followupaudio {
public static bool $verified = true;
public static array $events = [];
public static bool $lease = true;
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 claim(): ?array { self::$events[] = 'claim'; return ['id' => 1, 'actor_id' => 1, 'diagnosis_id' => 1, 'lease_token' => 'test', 'model_key' => 'qwen']; }
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; }
public static function checkpoint(int $id, string $token, array $fields): bool { self::$events[] = ['checkpoint' => $fields]; return self::$lease; }
public static function complete(int $id, string $token, array $extraction): bool { self::$events[] = ['complete' => $extraction]; return self::$lease; }
@@ -29,9 +31,11 @@ namespace app\common\service\followupaudio {
final class FollowupAudioDify {
public static string $mode = 'success';
public function analyze(array $task, callable $heartbeat): array {
if (self::$mode === 'change-before-send') { FollowupAudioStore::$providerMatches = false; }
if ($heartbeat(['upstream_started_at' => 1, 'stage' => 'uploading']) === false) { throw new FollowupAudioException('LEASE_LOST'); }
if (self::$mode === 'uncertain') { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
if (self::$mode === 'internal') { throw new \RuntimeException('private-patient'); }
if (self::$mode === 'change-after-send') { FollowupAudioStore::$providerMatches = false; }
if (self::$mode === 'revoke') { \app\common\service\prescriptionai\PrescriptionAiAccess::$active = false; }
return ['summary' => 'synthetic', 'items' => []];
}
@@ -51,8 +55,8 @@ namespace {
Store::$enabled = false;
$expect(!$worker->runOnce() && Store::$events === [], 'disabled before claim');
Store::$enabled = true; Store::$verified = false;
$expect(!$worker->runOnce() && Store::$events === [], 'unverified before claim');
Store::$verified = true;
$expect(!$worker->runOnce() && Store::$events === ['claim'], 'unverified task is not consumed; queue may fence stale bindings');
Store::$events = []; Store::$verified = true;
$expect($worker->runOnce(), 'success consumed task');
$expect(isset(Store::$events[count(Store::$events)-1]['complete']), 'success completed after authorization');
foreach (['uncertain' => 'UPSTREAM_UNCERTAIN', 'internal' => 'INTERNAL_ERROR', 'revoke' => 'LEASE_LOST'] as $mode => $code) {
@@ -68,5 +72,18 @@ namespace {
Actors::$active = true; Access::$allowed = false; Store::$events = [];
$worker->runOnce(); $last = end(Store::$events);
$expect($last['fail'] === 'INTERNAL_ERROR' && !$last['uncertain'], 'scope denied before upstream');
Access::$allowed = true; Actors::$active = true;
foreach (['change-before-send' => false, 'change-after-send' => true] as $mode => $uncertain) {
Dify::$mode = $mode; Store::$events = []; Store::$providerMatches = true;
$worker->runOnce(); $last = end(Store::$events);
$expect($last['fail'] === 'PROVIDER_CONFIGURATION_CHANGED' && $last['uncertain'] === $uncertain,
$mode . ' is fenced with correct upstream uncertainty');
$expect(!array_filter(Store::$events, static fn ($event): bool => is_array($event) && isset($event['complete'])),
$mode . ' never completes result under a changed provider');
}
Store::$providerMatches = false; Store::$events = []; Dify::$mode = 'success';
$worker->runOnce(); $last = end(Store::$events);
$expect($last['fail'] === 'PROVIDER_CONFIGURATION_CHANGED' && !$last['uncertain'] && count(Store::$events) === 2,
'Initial missing or stale task fingerprint is rejected before any heartbeat or transport');
echo 'FOLLOWUP_AUDIO_WORKER assertions=' . $checks . ' PASS gate=1 actor_recheck=1 reconciliation=1' . PHP_EOL;
}