setConfig(['default' => 'sqlite', 'auto_timestamp' => false, 'datetime_format' => false, 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'zyt_']]]); Container::getInstance()->instance('think\DbManager', $manager); $config = new think\Config(); $config->set(['data_scope' => ['enabled' => false]], 'project'); Container::getInstance()->instance('config', $config); foreach ([ 'CREATE TABLE zyt_admin (id INTEGER PRIMARY KEY, root INT, name TEXT, disable INT, delete_time INT)', '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 INTEGER PRIMARY KEY, perms TEXT, is_disable INT)', 'CREATE TABLE zyt_tcm_diagnosis (id INTEGER PRIMARY KEY, patient_id INT, patient_name TEXT, phone TEXT, assistant_id INT, status INT, delete_time INT)', 'CREATE TABLE zyt_doctor_appointment (id INTEGER PRIMARY KEY, patient_id INT, doctor_id INT, status INT)', '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))', ] as $ddl) { Db::execute($ddl); } require __DIR__ . '/fixtures/mobile_recording_media_schema.php'; Db::name('admin')->insertAll([ ['id' => 1, 'root' => 1, 'name' => '合成管理员', 'disable' => 0], ['id' => 11, 'root' => 0, 'name' => '合成助理甲', 'disable' => 0], ['id' => 12, 'root' => 0, 'name' => '合成助理乙', 'disable' => 0], ['id' => 13, 'root' => 0, 'name' => '合成医生', 'disable' => 0], ['id' => 14, 'root' => 0, 'name' => '合成停用账号', 'disable' => 1], ['id' => 15, 'root' => 0, 'name' => '合成无权限账号', '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], ['admin_id' => 14, 'role_id' => 2], ]); Db::name('system_menu')->insert(['id' => 1, 'perms' => Logic::PAGE_PERMISSION, 'is_disable' => 0]); Db::name('system_role_menu')->insertAll([['role_id' => 1, 'menu_id' => 1], ['role_id' => 2, 'menu_id' => 1]]); Db::name('tcm_diagnosis')->insertAll([ ['id' => 101, 'patient_id' => 501, 'patient_name' => '合成患者甲', 'phone' => '13800000001', 'assistant_id' => 11, 'status' => 1, 'delete_time' => null], ['id' => 102, 'patient_id' => 502, 'patient_name' => '合成患者乙', 'phone' => '13800000002', 'assistant_id' => 11, 'status' => 1, 'delete_time' => null], ['id' => 103, 'patient_id' => 503, 'patient_name' => '合成患者丙', 'phone' => '13800000003', 'assistant_id' => 12, 'status' => 1, 'delete_time' => null], ['id' => 104, 'patient_id' => 504, 'patient_name' => '合成停用患者', 'phone' => '', 'assistant_id' => 11, 'status' => 0, 'delete_time' => null], ['id' => 105, 'patient_id' => 505, 'patient_name' => '合成已删患者', 'phone' => '', 'assistant_id' => 11, 'status' => 1, 'delete_time' => 1], ['id' => 106, 'patient_id' => 506, 'patient_name' => '合成接诊患者', 'phone' => '', 'assistant_id' => 12, 'status' => 1, 'delete_time' => null], ]); Db::name('doctor_appointment')->insert(['id' => 1, 'patient_id' => 106, 'doctor_id' => 13, 'status' => 3]); $checks = 0; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; }; $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); }; $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'])); foreach ([ ['admin_id' => 1], ['phone' => '13800000000'], ['file_name' => 'secret.wav'], ['audio_path' => '/private/audio'], ['transcript_text' => 'content'], ] as $extra) { $denied(fn () => Logic::bindingInput($input + $extra), 'unexpected private or actor field rejected'); } foreach ([ ['installation_id' => 'not-a-uuid'], ['recording_key' => '../path'], ['diagnosis_id' => true], ['duration_ms' => 1.5], ['duration_ms' => -1], ['duration_ms' => 604800001], ['recorded_at' => (time() + 3600) * 1000], ['recorded_at' => '1e10'], ['source_platform' => 'other'], ] as $invalid) { $denied(fn () => Logic::bindingInput(array_replace($input, $invalid)), 'invalid typed field rejected'); } $denied(fn () => Logic::bind($input, 15), 'missing page permission'); $denied(fn () => Logic::bind($input, 14), 'disabled actor'); $denied(fn () => Logic::bind($input, 0), 'missing actor'); $denied(fn () => Logic::bind(array_replace($input, ['diagnosis_id' => 103]), 11), 'other assistant patient'); $denied(fn () => Logic::bind(array_replace($input, ['diagnosis_id' => 104]), 11), 'disabled patient'); $denied(fn () => Logic::bind(array_replace($input, ['diagnosis_id' => 105]), 11), 'deleted patient'); $created = Logic::bind($input, 11)['binding']; $again = Logic::bind($input, 11)['binding']; $expect($created['id'] === $again['id'] && Db::name('mobile_recording_binding')->count() === 1, 'repeat bind is idempotent'); $expect($created['patient']['diagnosis_id'] === 101 && $created['patient']['source_patient_id'] === 501, 'separate diagnosis/source patient identities'); $expect($created['patient']['phone_masked'] === '138****0001', 'masked phone only'); $expect(!str_contains(json_encode($created), '13800000001') && !isset($created['admin_id']), 'private data and actor ID omitted'); $other = Logic::bind(array_replace($input, ['diagnosis_id' => 103]), 12)['binding']; $expect($other['id'] !== $created['id'], 'unique key includes current actor'); Logic::unbind($identity, 12); Logic::unbind($identity, 12); $expect(Db::name('mobile_recording_binding')->where('id', $created['id'])->count() === 1, 'another actor unbind cannot touch original row'); $denied(fn () => Logic::bind(array_replace($input, ['diagnosis_id' => 103]), 11), 'replacement target is checked'); $replaced = Logic::bind(array_replace($input, ['diagnosis_id' => 102]), 11)['binding']; $expect($replaced['id'] === $created['id'] && $replaced['diagnosis_id'] === 102, 'replacement updates existing identity'); $second = array_replace($input, ['recording_key' => str_repeat('b', 64)]); Logic::bind($second, 11); $device = ['installation_id' => $input['installation_id']]; $page = Logic::deviceLists($device + ['page_no' => 1, 'page_size' => 1], 11); $expect($page['count'] === 2 && count($page['lists']) === 1 && $page['page_size'] === 1, 'bounded device pagination'); $expect(Logic::deviceLists(['installation_id' => '22345678-1234-4123-8123-123456789abc'], 11)['count'] === 0, 'device isolation'); $expect(Logic::deviceLists($device, 12)['count'] === 0, 'account isolation'); $denied(fn () => Logic::patientLists(['diagnosis_id' => 102], 12), 'patient listing scope'); $records = Logic::patientLists(['diagnosis_id' => 102], 11); $record = $records['lists'][0]; $expect($record['bound_by'] === '合成助理甲' && $record['playback_location'] === 'original_device', 'archive playback and actor metadata'); $expect(!isset($record['installation_id'], $record['recording_key']) && !isset($record['patient_name']), 'archive omits device keys and unnecessary patient names'); $denied(fn () => Logic::deviceLists($device + ['page_size' => 101], 11), 'page limit'); $doctor = Logic::bind(array_replace($input, ['diagnosis_id' => 106]), 13); $expect($doctor['binding']['diagnosis_id'] === 106, 'doctor uses own effective appointment'); $denied(fn () => Logic::bind($input, 13), 'doctor cannot use unrelated patient'); Logic::bind(array_replace($input, ['diagnosis_id' => 103]), 1); $expect(Logic::patientLists(['diagnosis_id' => 103], 1)['count'] === 1, 'root uses same metadata contract'); Db::name('tcm_diagnosis')->where('id', 102)->update(['assistant_id' => 12]); $denied(fn () => Logic::bind($input, 11), 'old patient checked on reassignment after scope loss'); $denied(fn () => Logic::unbind($identity, 11), 'old patient checked on unlink after scope loss'); $afterTransfer = Logic::deviceLists($device, 11); $expect($afterTransfer['count'] === 1 && !str_contains(json_encode($afterTransfer, JSON_UNESCAPED_UNICODE), '合成患者乙'), 'lost-scope row and patient name hidden'); Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => 2]); $expect(Logic::deviceLists($device, 11)['count'] === 0, 'deleted diagnosis hidden on restore'); $denied(fn () => Logic::patientLists(['diagnosis_id' => 101], 11), 'deleted patient archive denied'); Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => null]); Logic::unbind(array_replace($identity, ['recording_key' => str_repeat('b', 64)]), 11); $expect(Logic::deviceLists($device, 11)['count'] === 0, 'authorized unbind removes association'); Db::name('system_menu')->where('id', 1)->update(['is_disable' => 1]); $denied(fn () => Logic::deviceLists($device, 11), 'revoked page permission is re-read'); Db::name('admin')->where('id', 1)->update(['root' => 0]); $denied(fn () => Logic::deviceLists($device, 1), 'revoked root is re-read'); // Guard the integration boundary which runtime service tests cannot exercise without an HTTP app. $controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/MobileRecordingController.php'); $middleware = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php'); $expect(str_contains($controller, 'MobileRecordingLogic::actor((int) $this->adminId)'), 'controller explicitly checks page grant'); $expect(str_contains($controller, '$this->request->file()'), 'controller refuses binary upload'); $expect(str_contains($middleware, "'tcm.mobilerecording/'") && strpos($middleware, "'tcm.mobilerecording/'") < strpos($middleware, '$allUri ='), 'middleware guards before legacy unregistered-route bypass'); echo "Mobile recording binding: {$checks} checks passed (synthetic in-memory SQLite).\n";