instance(MobileRecordingCos::class, $fakeCloud); $manager = new think\DbManager(); $driver = 'sqlite'; $pdo = null; if (isset($argv[1])) { $state = json_decode(file_get_contents($argv[1]), true, 512, JSON_THROW_ON_ERROR); if ($state['schema'] !== 'synthetic_mobile_media_test' || (int) $state['port'] === 3306 || !str_contains(str_replace('\\', '/', $state['run_root']), '/patient-media-mysql-20261109-')) { throw new RuntimeException('Refusing non-synthetic MySQL target'); } $pdo = new PDO('mysql:host=127.0.0.1;port=' . (int) $state['port'], 'root', '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); $actual = strtolower(rtrim(str_replace('\\', '/', (string) $pdo->query('SELECT @@datadir')->fetchColumn()), '/')); if ($actual !== strtolower(str_replace('\\', '/', $state['run_root']) . '/data')) { throw new RuntimeException('Refusing non-scratch datadir'); } $pdo->exec('DROP DATABASE IF EXISTS synthetic_mobile_media_test'); $pdo->exec('CREATE DATABASE synthetic_mobile_media_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'); $pdo->exec('USE synthetic_mobile_media_test'); $driver = 'mysql'; $connection = ['type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => (int) $state['port'], 'database' => 'synthetic_mobile_media_test', 'username' => 'root', 'password' => '', 'charset' => 'utf8mb4', 'prefix' => 'zyt_', 'fields_strict' => true, 'fields_cache' => false, 'trigger_sql' => false]; } else { if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) { throw new RuntimeException('SQLite PDO required; no production fallback'); } $connection = ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'zyt_']; } $manager->setConfig(['default' => $driver, 'auto_timestamp' => false, 'datetime_format' => false, 'connections' => [$driver => $connection]]); Container::getInstance()->instance('think\DbManager', $manager); $storage = sys_get_temp_dir() . '/mobile-media-synthetic-' . bin2hex(random_bytes(8)); $config = new think\Config(); $config->set(['data_scope' => ['enabled' => false]], 'project'); $config->set(['storage_root' => $storage], 'mobile_recording_media'); Container::getInstance()->instance('config', $config); $checks = []; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks[] = $why; }; $denied = static function (callable $call, string $why) use ($expect): void { try { $call(); } catch (DomainException $e) { $expect(true, $why); return; } throw new RuntimeException('Expected denial: ' . $why); }; $controller = static function (string $action, array $params, bool $post = true, string $body = '', array $headers = []) use ($app) { $request = new think\Request(); $request->withServer(['REQUEST_METHOD' => $post ? 'POST' : 'GET']); if ($action === 'chunk' || !$post) { $request->withGet($params); } else { $request->withPost($params); } $request->withInput($body)->withHeader($headers); $request->adminInfo = ['admin_id' => 11, 'root' => 0]; $app->instance('request', $request); return (new MobileRecordingMediaController($app))->$action(); }; $capture = static function ($response): string { ob_start(); try { $response->send(); return (string) ob_get_contents(); } finally { ob_end_clean(); } }; try { foreach ([ 'CREATE TABLE zyt_admin (id INT PRIMARY KEY, root INT, name VARCHAR(100), disable INT, delete_time INT NULL)', 'CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)', 'CREATE TABLE zyt_system_role (id INT PRIMARY KEY, name VARCHAR(100), data_scope INT, delete_time INT NULL)', 'CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)', 'CREATE TABLE zyt_system_menu (id INT PRIMARY KEY, perms VARCHAR(128), is_disable INT)', 'CREATE TABLE zyt_tcm_diagnosis (id INT PRIMARY KEY, patient_id INT, patient_name VARCHAR(100), phone VARCHAR(30), assistant_id INT, status INT, delete_time INT NULL)', 'CREATE TABLE zyt_doctor_appointment (id INT PRIMARY KEY, patient_id INT, doctor_id INT, status INT)', ] as $sql) { Db::execute($sql . ($driver === 'mysql' ? ' ENGINE=InnoDB' : '')); } if ($driver === 'mysql') { $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_06_mobile_recording_binding.sql')); $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_media.sql')); $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_media.sql')); $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_cos.sql')); $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_cos.sql')); $expect(true, 'real upgrade migration executes and is idempotent on isolated MySQL'); } else { Db::execute('CREATE TABLE zyt_mobile_recording_binding (id INTEGER PRIMARY KEY AUTOINCREMENT, admin_id INT NOT NULL, installation_id TEXT NOT NULL, recording_key TEXT NOT NULL, diagnosis_id INT NOT NULL, source_platform TEXT NOT NULL, recorded_at INT NOT NULL, duration_ms INT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE(admin_id,installation_id,recording_key))'); require __DIR__ . '/fixtures/mobile_recording_media_schema.php'; } Db::name('admin')->insertAll([ ['id' => 1, 'root' => 1, 'name' => 'Synthetic root', 'disable' => 0], ['id' => 11, 'root' => 0, 'name' => 'Synthetic assistant', 'disable' => 0], ['id' => 12, 'root' => 0, 'name' => 'Synthetic other', 'disable' => 0], ['id' => 13, 'root' => 0, 'name' => 'Synthetic doctor', 'disable' => 0], ['id' => 15, 'root' => 0, 'name' => 'Synthetic no-grant', 'disable' => 0], ]); Db::name('system_role')->insertAll([ ['id' => 1, 'name' => '医生', 'data_scope' => 1], ['id' => 2, 'name' => '医助', 'data_scope' => 4], ]); Db::name('admin_role')->insertAll([['admin_id' => 11, 'role_id' => 2], ['admin_id' => 12, 'role_id' => 2], ['admin_id' => 13, 'role_id' => 1]]); Db::name('system_role_menu')->insertAll([['role_id' => 2, 'menu_id' => 1], ['role_id' => 1, 'menu_id' => 1]]); Db::name('system_menu')->insert(['id' => 1, 'perms' => Binding::PAGE_PERMISSION, 'is_disable' => 0]); Db::name('tcm_diagnosis')->insertAll([ ['id' => 101, 'patient_id' => 501, 'patient_name' => 'Synthetic A', 'phone' => '', 'assistant_id' => 11, 'status' => 1], ['id' => 102, 'patient_id' => 502, 'patient_name' => 'Synthetic B', 'phone' => '', 'assistant_id' => 11, 'status' => 1], ['id' => 103, 'patient_id' => 503, 'patient_name' => 'Synthetic C', 'phone' => '', 'assistant_id' => 12, 'status' => 1], ]); Db::name('doctor_appointment')->insert(['id' => 1, 'patient_id' => 101, 'doctor_id' => 13, 'status' => 3]); $input = ['installation_id' => '12345678-1234-4123-8123-123456789abc', 'recording_key' => str_repeat('a', 64), 'diagnosis_id' => 101, 'source_platform' => 'android_bcr', 'recorded_at' => 1700000000000, 'duration_ms' => 65000]; $identity = array_intersect_key($input, array_flip(['installation_id', 'recording_key'])); $target = $identity + ['diagnosis_id' => 101]; $pcm = str_repeat("\x00\x00\x01\x00", 300000); // synthetic silence/tiny impulse, >1 MiB to exercise resume $wav = 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 8000, 16000, 2, 16) . 'data' . pack('V', strlen($pcm)) . $pcm; $prepare = $target + ['kind' => 'audio', 'size_bytes' => strlen($wav), 'sha256' => hash('sha256', $wav), 'media_type' => 'audio/wav']; $denied(fn () => Media::prepare($prepare, 11), 'unbound recording cannot upload'); $binding = Binding::bind($input, 11)['binding']; $id = $binding['id']; $expect(Binding::patientLists(['diagnosis_id' => 101], 11)['lists'][0]['upload_state'] === 'not_uploaded', 'old metadata row remains compatible before upload'); foreach ([['file_name' => 'private.wav'], ['path' => '/tmp/secret'], ['admin_id' => 1], ['token' => 'secret']] as $extra) { $denied(fn () => Media::prepare($prepare + $extra, 11), 'reject filename/path/actor/token extra field'); } $denied(fn () => Media::prepare(array_replace($prepare, ['size_bytes' => Media::AUDIO_MAX + 1]), 11), 'audio limit enforced before storage'); $denied(fn () => Media::prepare(array_replace($prepare, ['kind' => 'transcript', 'media_type' => 'text/plain', 'size_bytes' => Media::TEXT_MAX + 1]), 11), 'text limit enforced'); $denied(fn () => Media::prepare($prepare, 12), 'same device and key cannot upload another account binding'); $denied(fn () => Media::prepare($prepare, 15), 'missing live page grant denied'); $denied(fn () => Media::prepare(array_replace($prepare, ['diagnosis_id' => 103]), 11), 'wrong diagnosis denied'); $rejected = ['error_code' => 'request_rejected', 'retryable' => false]; $resync = ['error_code' => 'upload_resync', 'retryable' => true]; $scopeDenial = $controller('prepare', array_replace($prepare, ['diagnosis_id' => 103]))->getData(); $expect($scopeDenial['code'] === 0 && $scopeDenial['data'] === $rejected, 'patient scope error is machine-readable and never automatically retryable'); $methodError = $controller('prepare', $prepare, false)->getData(); $expect($methodError['code'] === 0 && $methodError['data'] === $rejected, 'wrong method remains non-retryable'); $session = $controller('prepare', $prepare)->getData(); $expect($session['code'] === 1, 'actual controller prepare succeeds'); $session = $session['data']; $upload = ['upload_id' => $session['upload_id'], 'diagnosis_id' => 101]; $expect(Media::prepare($prepare, 11)['upload_id'] === $session['upload_id'], 'repeat prepare reuses upload'); $denied(fn () => Media::complete($upload, 11), 'incomplete upload cannot publish'); $incomplete = $controller('complete', $upload)->getData(); $expect($incomplete['code'] === 0 && $incomplete['msg'] === '文件尚未上传完整' && $incomplete['data'] === $resync, 'incomplete complete preserves message and requests cursor resynchronization'); $first = substr($wav, 0, Media::CHUNK_SIZE); $chunk = $upload + ['offset' => 0, 'chunk_sha256' => hash('sha256', $first)]; $denied(fn () => Media::chunk(array_replace($chunk, ['chunk_sha256' => str_repeat('0', 64)]), $first, 11), 'wrong chunk hash rejected'); $hashError = $controller('chunk', array_replace($chunk, ['chunk_sha256' => str_repeat('0', 64)]), true, $first, ['content-type' => 'application/octet-stream'])->getData(); $expect($hashError['code'] === 0 && $hashError['data'] === $rejected, 'chunk integrity error is not automatically retryable'); $denied(fn () => Media::chunk($chunk, $first . 'x', 11), 'chunk over one MiB rejected'); $expect($controller('chunk', $chunk, true, $first, ['content-type' => 'text/plain'])->getData()['code'] === 0, 'binary content type enforced'); $response = $controller('chunk', $chunk, true, $first, ['content-type' => 'application/octet-stream'])->getData(); $expect($response['code'] === 1 && $response['data']['received_bytes'] === Media::CHUNK_SIZE, 'actual binary controller receives first chunk'); $expect(Media::chunk($chunk, $first, 11)['received_bytes'] === Media::CHUNK_SIZE, 'duplicate chunk is idempotent'); $badFirst = 'X' . substr($first, 1); $denied(fn () => Media::chunk(array_replace($chunk, ['chunk_sha256' => hash('sha256', $badFirst)]), $badFirst, 11), 'conflicting replay rejected'); $conflict = $controller('chunk', array_replace($chunk, ['chunk_sha256' => hash('sha256', $badFirst)]), true, $badFirst, ['content-type' => 'application/octet-stream'])->getData(); $expect($conflict['code'] === 0 && $conflict['data'] === $resync, 'conflicting replay has structured upload_resync code'); $gap = $controller('chunk', $upload + ['offset' => Media::CHUNK_SIZE + 1, 'chunk_sha256' => hash('sha256', 'x')], true, 'x', ['content-type' => 'application/octet-stream'])->getData(); $expect($gap['code'] === 0 && $gap['data'] === $resync, 'noncontiguous cursor has structured upload_resync code'); $denied(fn () => Media::chunk(array_replace($chunk, ['offset' => Media::CHUNK_SIZE + 1]), $first, 11), 'gap or size overflow rejected'); $expect(Media::status($target, 11)['audio']['received_bytes'] === Media::CHUNK_SIZE, 'interruption resumes with server cursor'); file_put_contents($storage . '/' . $session['upload_id'] . '.bin', 'uncommitted', FILE_APPEND); $expect(Media::prepare($prepare, 11)['received_bytes'] === Media::CHUNK_SIZE, 'crash after disk write before DB commit rolls back extra bytes'); $expect(filesize($storage . '/' . $session['upload_id'] . '.bin') === Media::CHUNK_SIZE, 'uncommitted trailing bytes removed'); $last = substr($wav, Media::CHUNK_SIZE); Media::chunk($upload + ['offset' => Media::CHUNK_SIZE, 'chunk_sha256' => hash('sha256', $last)], $last, 11); $expect(Media::complete($upload, 11)['state'] === 'complete', 'full size hash and WAV signature validated before confirmed COS publication'); $cosObject = Db::name('mobile_recording_object')->where('upload_id', $session['upload_id'])->find(); $expect($cosObject['state'] === 'ready' && $cosObject['version_id'] !== '', 'COS location and version durably recorded'); $expect(!is_file($storage . '/' . $session['upload_id'] . '.bin'), 'completed COS media clears local plaintext staging'); $expect(!str_starts_with($fakeCloud->objects[$fakeCloud->key($cosObject)]['bytes'], 'RIFF'), 'COS audio is ciphertext rather than playable plaintext'); if ($driver === 'mysql') { $tables = ['mobile_recording_binding', 'mobile_recording_upload', 'mobile_recording_asset', 'mobile_recording_object', 'mobile_recording_work']; $beforeUpgrade = []; foreach ($tables as $table) { $beforeUpgrade[$table] = Db::name($table)->select()->toArray(); } $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_cos.sql')); $pdo->exec(file_get_contents(dirname(__DIR__) . '/database/migrations/2026_11_09_mobile_recording_cos.sql')); foreach ($tables as $table) { $expect(Db::name($table)->select()->toArray() === $beforeUpgrade[$table], 'COS upgrade preserves existing synthetic ' . $table . ' rows'); } } $expect(Media::chunk($chunk, $first, 11)['state'] === 'complete', 'duplicate completed chunk validates against COS without recreating local file'); $expect(Media::complete($upload, 11)['state'] === 'complete', 'repeat complete idempotent'); $expect(Media::prepare($prepare, 11)['state'] === 'complete', 'repeat completed prepare reuses object'); $expect(Binding::patientLists(['diagnosis_id' => 101], 11)['lists'][0]['upload_state'] === 'partial', 'audio alone is partial and available'); $full = Media::audio(['binding_id' => $id, 'diagnosis_id' => 101], 11); $expect($full->getHeader('Cache-Control') === 'private, no-store, max-age=0' && $full->getHeader('X-Content-Type-Options') === 'nosniff', 'private response headers'); $expect($capture($full) === $wav, 'streamed full audio matches synthetic bytes'); foreach (['bytes=0-31' => substr($wav, 0, 32), 'bytes=-10' => substr($wav, -10), 'bytes=1200030-' => substr($wav, 1200030)] as $range => $expected) { $stream = $controller('audio', ['binding_id' => $id, 'diagnosis_id' => 101], false, '', ['range' => $range]); $expect($stream->getCode() === 206 && $capture($stream) === $expected, 'authenticated Range ' . $range); } foreach (['bytes=9999999-', 'bytes=9-2', 'bytes=0-1,3-4', 'bytes=-0', 'garbage'] as $range) { $stream = Media::audio(['binding_id' => $id], 11, $range); $expect($stream->getCode() === 416 && $capture($stream) === '', 'invalid range safely returns 416'); } $denied(fn () => Media::audio(['binding_id' => $id], 12), 'out-of-scope audio denied'); $expect($capture(Media::audio(['binding_id' => $id], 13, 'bytes=0-3')) === 'RIFF', 'doctor with current patient scope can read'); $denied(fn () => Media::chunk($chunk, $first, 13), 'reader cannot write another actor upload'); $denied(fn () => Media::audio(['binding_id' => $id, 'diagnosis_id' => 102], 11), 'archive diagnosis anchor rejects stale page'); Db::name('system_menu')->where('id', 1)->update(['is_disable' => 1]); $permissionError = $controller('status', $target, false)->getData(); $expect($permissionError['code'] === 0 && $permissionError['data'] === $rejected, 'revoked page permission is explicitly non-retryable'); $denied(fn () => Media::status($target, 11), 'revoked grant checked on status'); $denied(fn () => Media::audio(['binding_id' => $id], 11), 'revoked grant checked on read'); Db::name('system_menu')->where('id', 1)->update(['is_disable' => 0]); Db::name('admin')->where('id', 11)->update(['disable' => 1]); $denied(fn () => Media::complete($upload, 11), 'disabled account checked on every request'); Db::name('admin')->where('id', 11)->update(['disable' => 0]); Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => 1]); $denied(fn () => Media::audio(['binding_id' => $id], 11), 'deleted diagnosis blocks content'); Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => null]); $sendText = static function (string $text, ?string $sha = null) use ($target): array { $session = Media::prepare($target + ['kind' => 'transcript', 'size_bytes' => strlen($text), 'sha256' => $sha ?? hash('sha256', $text), 'media_type' => 'text/plain'], 11); $params = ['upload_id' => $session['upload_id'], 'diagnosis_id' => 101]; if ($text !== '') { Media::chunk($params + ['offset' => 0, 'chunk_sha256' => hash('sha256', $text)], $text, 11); } return $params; }; $textUpload = $sendText("合成转写\n"); Media::complete($textUpload, 11); $text = Media::transcript(['binding_id' => $id, 'diagnosis_id' => 101], 11); $expect(str_contains($text['text'], '