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_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('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'], '