# Conflicts: # server/public/admin/assets/.pnpm-Cy9nIqIg.js # server/public/admin/assets/403-DZv43rg8.js # server/public/admin/assets/404-BdEnY8Op.js # server/public/admin/assets/@tencentcloud/call-engine-js-DC0nMlpX.js # server/public/admin/assets/@tencentcloud/chat-BgZr4keH.js # server/public/admin/assets/@tencentcloud/chat-uikit-engine-D2vNRuTA.js # server/public/admin/assets/AppointmentRecordPanel-BLiYiAbY.js # server/public/admin/assets/AssignLogPanel-B37Pa478.js # server/public/admin/assets/AssistantWatchCallDialog-CwSHnJSG.js # server/public/admin/assets/BloodRecordList-Drn0jNIG.js # server/public/admin/assets/CaseRecordList-DOWsaiL5.js # server/public/admin/assets/ConsultationTranscriptPanel-fqClePUL.js # server/public/admin/assets/DailyMatrix-B_BuE6Wk.js # server/public/admin/assets/DiagnosisTodoList-BvKuWevJ.js # server/public/admin/assets/DietRecordList-CNcxCdHz.js # server/public/admin/assets/ExerciseRecordList-DcrDxj4-.js # server/public/admin/assets/FollowupAudioField-DwFRovp6.js # server/public/admin/assets/FollowupAudioPanel-161TbfEd.js # server/public/admin/assets/FollowupAudioReview-DUx7pf7f.js # server/public/admin/assets/GancaoSubmissionReconcileButton.vue_vue_type_script_setup_true_lang-8OIk4lHB.js # server/public/admin/assets/ImChatRecordPanel-DHff4nFV.js # server/public/admin/assets/MediaSourceSelect-DAiidBXd.js # server/public/admin/assets/MediaSourceSelect.vue_vue_type_script_setup_true_lang-BnUzDRdh.js # server/public/admin/assets/MessageBubble-bYTr-oyb.js # server/public/admin/assets/NoteTimeline-jPsAfPnk.js # server/public/admin/assets/OrderActionHost-CM9FpHNh.js # server/public/admin/assets/OrderPanel-Co_XYZDR.js # server/public/admin/assets/PatientCaseCard-CDC9oDgL.js # server/public/admin/assets/PatientInfoCard-wIayMpdX.js # server/public/admin/assets/PatientOrderList-C0nBgRYQ.js # server/public/admin/assets/PrescriptionAiBatchGenerateDialog--28G2reg.js # server/public/admin/assets/PrescriptionAiReportDialog-BElqcJhz.js # server/public/admin/assets/PrescriptionOrderDetailDrawer-C1YLaoua.js # server/public/admin/assets/PrescriptionOrderTimeDialog.vue_vue_type_script_setup_true_lang-zSycYEpH.js # server/public/admin/assets/ProgressPanel-DuOaWdPj.js # server/public/admin/assets/PromotionAutomationForm-CEi-wURu.js # server/public/admin/assets/RecordingPlaybackBlock-B1YEgQib.js # server/public/admin/assets/RecordingVideoPlayer-CzGfJLYq.js # server/public/admin/assets/SendPanel-C81B60j9.js # server/public/admin/assets/TrackingMatrix-Dvlry4c2.js # server/public/admin/assets/TrackingNoteTimeline-CcOnnAQR.js # server/public/admin/assets/WecomFloatingWidgetBuilder-BZ1rtSPX.js # server/public/admin/assets/WelcomeMessageEditor-FXBSgF_s.js # server/public/admin/assets/account-adjust.vue_vue_type_script_setup_true_lang-BDg3Vt-5.js # server/public/admin/assets/account_cost-1Kig3__5.js # server/public/admin/assets/add-CnMHBvcC.js # server/public/admin/assets/admin-mWKPRS5Z.js # server/public/admin/assets/ai_mcp-BdB18eax.js # server/public/admin/assets/appointment-BABD2Tar.js # server/public/admin/assets/article-B2IS97ge.js # server/public/admin/assets/asset-fny0UJmY.js # server/public/admin/assets/attr-BO203wr6.js # server/public/admin/assets/attr-CYxX_t_S.js # server/public/admin/assets/attr-setting.vue_vue_type_script_setup_true_lang-BK3AJqkU.js # server/public/admin/assets/attr.vue_vue_type_script_setup_true_lang-C1vfB9ju.js # server/public/admin/assets/attr.vue_vue_type_script_setup_true_lang-CA3Eqz_I.js # server/public/admin/assets/attr.vue_vue_type_script_setup_true_lang-CISdFe5h.js # server/public/admin/assets/attr.vue_vue_type_script_setup_true_lang-CfyBAgmS.js # server/public/admin/assets/attr.vue_vue_type_script_setup_true_lang-LP4IWWFR.js # server/public/admin/assets/attr.vue_vue_type_script_setup_true_lang-R-6FX7Mu.js # server/public/admin/assets/attr.vue_vue_type_script_setup_true_lang-bgUhDqjK.js # server/public/admin/assets/attr.vue_vue_type_script_setup_true_lang-hGjVuUay.js # server/public/admin/assets/attr.vue_vue_type_script_setup_true_lang-hudO2m34.js # server/public/admin/assets/attr.vue_vue_type_script_setup_true_lang-i4BbFcAt.js # server/public/admin/assets/auth.vue_vue_type_script_setup_true_lang-BoZeVoZP.js # server/public/admin/assets/balance_details-DOLi1Dm0.js # server/public/admin/assets/bind-work-wechat-DFHKss6n.js # server/public/admin/assets/cache-nfUb4Iya.js # server/public/admin/assets/change-password-CeOlId8o.js # server/public/admin/assets/code-B18aVPXd.js # server/public/admin/assets/code-preview.vue_vue_type_script_setup_true_lang-DGCCjmlM.js # server/public/admin/assets/commission-settlement-CdEu_C9J.js # server/public/admin/assets/commission-settlement-CdlIY0iL.css # server/public/admin/assets/config-J5lzqSHX.js # server/public/admin/assets/consumer-BpKM0EGl.js # server/public/admin/assets/content-69Fk_4pj.js # server/public/admin/assets/content-BKD0OzuH.js # server/public/admin/assets/content-CBy84fnS.js # server/public/admin/assets/content.vue_vue_type_script_setup_true_lang-AQZfsEkF.js # server/public/admin/assets/content.vue_vue_type_script_setup_true_lang-BlBOVIVr.js # server/public/admin/assets/content.vue_vue_type_script_setup_true_lang-ByXjA9a8.js # server/public/admin/assets/content.vue_vue_type_script_setup_true_lang-DBQ1TxzP.js # server/public/admin/assets/content.vue_vue_type_script_setup_true_lang-DSdHmkxS.js # server/public/admin/assets/cost-edit.vue_vue_type_script_setup_true_lang-CFW87alA.js # server/public/admin/assets/data-table.vue_vue_type_script_setup_true_lang-DLwRo6Tk.js # server/public/admin/assets/dayjs-LzqDqVlD.js # server/public/admin/assets/decoration-DiTN-PkH.js # server/public/admin/assets/decoration-img-BHQwkVvP.js # server/public/admin/assets/default_reply-D8zCYW7U.js # server/public/admin/assets/department-DWsa1OjT.js # server/public/admin/assets/dept-tongji-eyhTfbdn.js # server/public/admin/assets/desktop_workstation-DjyYOet-.js # server/public/admin/assets/detail-BZAR9ddi.js # server/public/admin/assets/detail-CFyC8ImZ.js # server/public/admin/assets/dict-K5jXOVeu.js # server/public/admin/assets/doctor-67HUqvH6.js # server/public/admin/assets/edit-BVGUIRWC.js # server/public/admin/assets/edit-BYRC6YDz.css # server/public/admin/assets/edit-CiIgrwSx.js # server/public/admin/assets/edit-CtQmrXFk.js # server/public/admin/assets/edit-DFAdp19K.js # server/public/admin/assets/edit-DNccqn8z.js # server/public/admin/assets/edit-DjWNlPnW.js # server/public/admin/assets/edit-Qrv_aqRa.js # server/public/admin/assets/edit-vVRINxRB.js # server/public/admin/assets/edit.vue_vue_type_script_setup_true_lang-B_oC0AEU.js # server/public/admin/assets/edit.vue_vue_type_script_setup_true_lang-C7-_pAL8.js # server/public/admin/assets/edit.vue_vue_type_script_setup_true_lang-CTFgk6QV.js # server/public/admin/assets/edit.vue_vue_type_script_setup_true_lang-CX_KUDU8.js # server/public/admin/assets/edit.vue_vue_type_script_setup_true_lang-DyP0DJmF.js # server/public/admin/assets/edit.vue_vue_type_script_setup_true_lang-DyqPlqpu.js # server/public/admin/assets/edit.vue_vue_type_script_setup_true_lang-KBdoyuFG.js # server/public/admin/assets/edit.vue_vue_type_script_setup_true_lang-NbrHRZf2.js # server/public/admin/assets/edit.vue_vue_type_script_setup_true_lang-z-MjcUR0.js # server/public/admin/assets/edit.vue_vue_type_script_setup_true_name_articleColumnEdit_lang-DKmZkTfn.js # server/public/admin/assets/environment-CypnLxrG.js # server/public/admin/assets/error-qleaM0eA.js # server/public/admin/assets/fans-BEy6y9dV.js # server/public/admin/assets/file-BbPuzGA_.js # server/public/admin/assets/file-CYwMKS77.js # server/public/admin/assets/filing-CD0AWXGl.js # server/public/admin/assets/finance-D_CR5ikx.js # server/public/admin/assets/first_visit-D4R0U3AW.js # server/public/admin/assets/follow_reply-c3sC_GVa.js # server/public/admin/assets/footer.vue_vue_type_script_setup_true_lang-g2gc-rGu.js # server/public/admin/assets/guahao-Dx7MIOXD.js # server/public/admin/assets/h5-DEYp6ziA.js # server/public/admin/assets/icon-CXrybs-g.js # server/public/admin/assets/im-business-message-parse-D_ibrMLq.js # server/public/admin/assets/index-01yWb2WC.js # server/public/admin/assets/index-6D3QYnU-.js # server/public/admin/assets/index-B1iWGyoA.js # server/public/admin/assets/index-B4AHD13Q.js # server/public/admin/assets/index-BBfgtFWE.js # server/public/admin/assets/index-BBw5kvK3.js # server/public/admin/assets/index-BDAWyLAZ.js # server/public/admin/assets/index-BJGIfP1w.js # server/public/admin/assets/index-BQUdwcn-.js # server/public/admin/assets/index-BSMBP3_x.js # server/public/admin/assets/index-BdkP3TxP.js # server/public/admin/assets/index-BgMeTKOG.js # server/public/admin/assets/index-Bmrtumah.js # server/public/admin/assets/index-Brsj06xV.js # server/public/admin/assets/index-Burmlw_C.js # server/public/admin/assets/index-C06c4h92.js # server/public/admin/assets/index-C1h5WVyC.js # server/public/admin/assets/index-C2Gny0kk.js # server/public/admin/assets/index-C5O-FG0j.js # server/public/admin/assets/index-C8BoF1ue.js # server/public/admin/assets/index-CJuWPVul.js # server/public/admin/assets/index-CV08lFR0.js # server/public/admin/assets/index-C_WKJDWz.js # server/public/admin/assets/index-Cew5Amf6.js # server/public/admin/assets/index-CkXW7n_b.js # server/public/admin/assets/index-CprgR4Ho.js # server/public/admin/assets/index-Csf0cSVI.js # server/public/admin/assets/index-Cux23s8_.js # server/public/admin/assets/index-D0fXvBxu.js # server/public/admin/assets/index-D8yz_vWn.js # server/public/admin/assets/index-DBKQxzkM.js # server/public/admin/assets/index-DIjSXq4q.js # server/public/admin/assets/index-DIqVZCk0.js # server/public/admin/assets/index-DNsVxejj.js # server/public/admin/assets/index-DPWF9bqo.js # server/public/admin/assets/index-DQ51fSAo.js # server/public/admin/assets/index-DQ9Uu3LT.js # server/public/admin/assets/index-DWRHbuqT.js # server/public/admin/assets/index-Db2Y9m7h.js # server/public/admin/assets/index-DcHdMI2B.js # server/public/admin/assets/index-DgPgE-Vl.js # server/public/admin/assets/index-Dr8oROcK.js # server/public/admin/assets/index-Ds_Mrym4.js # server/public/admin/assets/index-DtSyPsbW.js # server/public/admin/assets/index-GT7BM7PS.js # server/public/admin/assets/index-JZkFc8I0.js # server/public/admin/assets/index-hNEe3rMW.js # server/public/admin/assets/index-kMiV0948.js # server/public/admin/assets/index-qwjw9zcC.js # server/public/admin/assets/index.vue_vue_type_script_setup_true_lang-BClfSqof.js # server/public/admin/assets/index.vue_vue_type_script_setup_true_lang-BtlhchBa.js # server/public/admin/assets/index.vue_vue_type_script_setup_true_lang-C6aeOkz-.js # server/public/admin/assets/index.vue_vue_type_script_setup_true_lang-Cxgoepog.js # server/public/admin/assets/index.vue_vue_type_script_setup_true_lang-Dx6SeCGU.js # server/public/admin/assets/index.vue_vue_type_style_index_0_lang-DFOUY-wL.js # server/public/admin/assets/information-D4VXaoVX.js # server/public/admin/assets/keyword_reply-CtUT2T_C.js # server/public/admin/assets/link-DAAa4-8h.js # server/public/admin/assets/list-B31OC0dY.js # server/public/admin/assets/login-D-CTVkUr.js # server/public/admin/assets/login_register-BiM6Wf25.js # server/public/admin/assets/medicine-BGJ8mZD8.js # server/public/admin/assets/medicine-CNGccw4f.js # server/public/admin/assets/menu-BhV_PotF.js # server/public/admin/assets/menu-CixPRDpt.js # server/public/admin/assets/menu-set.vue_vue_type_script_setup_true_lang-B0ghs-Pc.js # server/public/admin/assets/message-DWVo1Jk8.js # server/public/admin/assets/mobile-style.vue_vue_type_script_setup_true_lang-B9BK24HQ.js # server/public/admin/assets/mubiao-A1xD0JzP.js # server/public/admin/assets/mubiao-dept-card-DDiegAnr.js # server/public/admin/assets/mubiao-dept-node-Cmw8YiIy.js # server/public/admin/assets/oa-attr-DZLm3ufW.js # server/public/admin/assets/oa-menu-form-edit.vue_vue_type_script_setup_true_lang-CXz4UbLJ.js # server/public/admin/assets/oa-menu-form.vue_vue_type_script_setup_true_lang-BPJDNffP.js # server/public/admin/assets/open_setting-YVf3y2zz.js # server/public/admin/assets/order-Bc7o9SOH.js # server/public/admin/assets/overflow-DhSFQzu9.js # server/public/admin/assets/paiban-BY9Nnrmr.js # server/public/admin/assets/patient-DxJlPLVv.js # server/public/admin/assets/patient-call-q2qMhhFV.js # server/public/admin/assets/pay-DWoC89w_.js # server/public/admin/assets/pc-CDHcPjgN.js # server/public/admin/assets/perm-DaS8_T_U.js # server/public/admin/assets/picker-ruBeJVAv.js # server/public/admin/assets/picker.vue_vue_type_script_setup_true_lang-DsjIkNAZ.js # server/public/admin/assets/popover_input-CrckcagQ.js # server/public/admin/assets/post-DGDTwH-Z.js # server/public/admin/assets/prescription-drawer-DN1NpnaF.js # server/public/admin/assets/preview-BbXyDhjZ.js # server/public/admin/assets/preview-pc-DIFp8DvX.js # server/public/admin/assets/progress-BUj9Y2lB.js # server/public/admin/assets/protocol-Clsp_lEu.js # server/public/admin/assets/qywx-CxG05a7f.js # server/public/admin/assets/recharge_record-CymZKQ4E.js # server/public/admin/assets/refund-log.vue_vue_type_script_setup_true_lang-Choelpx6.js # server/public/admin/assets/refund_record-BLbeOAEj.js # server/public/admin/assets/relations-add.vue_vue_type_script_setup_true_lang-BRi9G5mf.js # server/public/admin/assets/role-Czcmecam.js # server/public/admin/assets/roster-Bh9PjuXi.js # server/public/admin/assets/rtc-detect-BFElJVVn.js # server/public/admin/assets/self_input_stats-P0WLo9tC.js # server/public/admin/assets/setting-CCd8WCbC.js # server/public/admin/assets/setup-HuwOPKIm.js # server/public/admin/assets/statistics-Cj3WlBni.js # server/public/admin/assets/stats-E_9WKdl6.js # server/public/admin/assets/style-DoPXLvzO.js # server/public/admin/assets/tabbar-DD2RRhme.js # server/public/admin/assets/tcm-E9u-W8Oy.js # server/public/admin/assets/theme-picker-NdSgC88S.js # server/public/admin/assets/tim-profanity-filter-plugin-BvbMOVpi.js # server/public/admin/assets/tim-upload-plugin-D7nWSdhF.js # server/public/admin/assets/tongji-yCfKN-LL.js # server/public/admin/assets/upload-CjtahvI1.js # server/public/admin/assets/useDictOptions-LZFsDBEH.js # server/public/admin/assets/useListTimeFilter-CUZgt-II.js # server/public/admin/assets/useLockFn-C2guENRT.js # server/public/admin/assets/useMediaSourceOptions-wCazM6uJ.js # server/public/admin/assets/useMenuOa-92GS9rAv.js # server/public/admin/assets/usePaging-DzgVFG9b.js # server/public/admin/assets/user-DHg9QKqg.js # server/public/admin/assets/weapp-C5C4Oypv.js # server/public/admin/assets/weapp-CgFixEn4.js # server/public/admin/assets/website-Dk_rtWyw.js # server/public/admin/assets/wx_oa-BR5bK2fX.js # server/public/admin/assets/yeji-BlMBwtyi.js # server/public/admin/assets/yeji-edit.vue_vue_type_script_setup_true_lang-AeltfJWh.js # server/public/admin/index.html
302 lines
25 KiB
PHP
302 lines
25 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/** Synthetic files and actors only. Never initializes the app or loads .env. */
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
|
|
|
use app\adminapi\logic\tcm\MobileRecordingLogic as Binding;
|
|
use app\adminapi\logic\tcm\MobileRecordingMediaLogic as Media;
|
|
use app\adminapi\controller\tcm\MobileRecordingMediaController;
|
|
use think\Container;
|
|
use think\facade\Db;
|
|
use app\common\service\mobilerecording\MobileRecordingObjects as Objects;
|
|
use app\common\service\mobilerecording\MobileRecordingCos;
|
|
|
|
$app = new think\App();
|
|
require __DIR__ . '/fixtures/FakeMobileRecordingCos.php';
|
|
$fakeCloud = new FakeMobileRecordingCos();
|
|
Container::getInstance()->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<script>never render HTML</script>");
|
|
Media::complete($textUpload, 11);
|
|
$text = Media::transcript(['binding_id' => $id, 'diagnosis_id' => 101], 11);
|
|
$expect(str_contains($text['text'], '<script>') && strlen($text['text']) === $text['size_bytes'], 'UTF8 transcript round trips as plain text');
|
|
$expect(Binding::patientLists(['diagnosis_id' => 101], 11)['lists'][0]['upload_state'] === 'complete', 'both assets report complete');
|
|
$invalid = $sendText("\xff\xfe");
|
|
$expect(Media::transcript(['binding_id' => $id], 11)['text'] === $text['text'], 'pending replacement preserves last completed transcript');
|
|
$denied(fn () => Media::complete($invalid, 11), 'non-UTF8 rejected');
|
|
$expect(Media::status($target, 11)['transcript']['received_bytes'] === 0, 'validation failure resets retry cursor');
|
|
$badHash = $sendText('abc', str_repeat('0', 64));
|
|
$denied(fn () => Media::complete($badHash, 11), 'wrong whole-file SHA rejected');
|
|
$empty = $sendText('');
|
|
Media::complete($empty, 11);
|
|
$expect(Media::transcript(['binding_id' => $id], 11)['text'] === '', 'empty UTF8 transcript supported');
|
|
$expect(!is_file($storage . '/' . $textUpload['upload_id'] . '.bin'), 'replaced completed content cleaned up');
|
|
$invalidAudio = Media::prepare(array_replace($prepare, ['size_bytes' => 12, 'sha256' => hash('sha256', 'not an audio')]), 11);
|
|
$badAudioParams = ['upload_id' => $invalidAudio['upload_id'], 'diagnosis_id' => 101];
|
|
Media::chunk($badAudioParams + ['offset' => 0, 'chunk_sha256' => hash('sha256', 'not an audio')], 'not an audio', 11);
|
|
$formatError = $controller('complete', $badAudioParams)->getData();
|
|
$expect($formatError['code'] === 0 && $formatError['data'] === $rejected, 'claimed WAV with invalid signature rejected without automatic retry');
|
|
$expect($capture(Media::audio(['binding_id' => $id], 11, 'bytes=0-3')) === 'RIFF', 'invalid replacement never replaces published audio');
|
|
Binding::bind(array_replace($input, ['diagnosis_id' => 102]), 11);
|
|
$denied(fn () => Media::complete($badAudioParams, 11), 'old pending rejected after reassignment');
|
|
$denied(fn () => Media::status($target, 11), 'old diagnosis upload target rejected');
|
|
$denied(fn () => Media::audio(['binding_id' => $id, 'diagnosis_id' => 101], 11), 'old archive anchor rejected after reassignment');
|
|
$denied(fn () => Media::audio(['binding_id' => $id], 13), 'old patient doctor cannot read moved media');
|
|
$expect($capture(Media::audio(['binding_id' => $id, 'diagnosis_id' => 102], 11, 'bytes=0-3')) === 'RIFF', 'completed media moves with current binding');
|
|
Binding::bind($input, 11);
|
|
$denied(fn () => Media::complete($badAudioParams, 11), 'A to B to A cannot resurrect old pending');
|
|
$expect(!is_file($storage . '/' . $badAudioParams['upload_id'] . '.bin'), 'reassignment cleans cancelled pending bytes');
|
|
Binding::unbind($identity, 12);
|
|
$expect(Media::status($target, 11)['audio']['state'] === 'complete', 'other account unbind cannot remove assets');
|
|
touch($storage . '/' . $session['upload_id'] . '.bin', time() - 90000);
|
|
$protected = \app\common\service\mobilerecording\MobileRecordingStorage::cleanupOrphans(false);
|
|
$expect($protected['eligible'] === 0 && is_file($storage . '/' . $session['upload_id'] . '.bin'), 'orphan cleanup preserves referenced patient content regardless of age');
|
|
Binding::unbind($identity, 11);
|
|
$denied(fn () => Media::audio(['binding_id' => $id], 11), 'unbind immediately revokes content reads');
|
|
$denied(fn () => Media::chunk($chunk, $first, 11), 'unbind immediately revokes pending writes');
|
|
$expect(Db::name('mobile_recording_upload')->count() === 0 && Db::name('mobile_recording_asset')->count() === 0, 'unbind removes all media DB references');
|
|
$expect((glob($storage . '/*.bin') ?: []) === [], 'unbind removes private files');
|
|
$cloudCleanup = Objects::cleanup(100);
|
|
$expect($cloudCleanup['errors'] === 0 && $fakeCloud->objects === [], 'revoked COS assets deleted with explicit version IDs');
|
|
$orphan = $storage . '/' . str_repeat('f', 32) . '.bin';
|
|
file_put_contents($orphan, 'synthetic orphan');
|
|
touch($orphan, time() - 90000);
|
|
$cleanup = \app\common\service\mobilerecording\MobileRecordingStorage::cleanupOrphans(true);
|
|
$expect($cleanup['eligible'] === 1 && is_file($orphan), 'orphan cleanup dry run preserves files');
|
|
$cleanup = \app\common\service\mobilerecording\MobileRecordingStorage::cleanupOrphans(false);
|
|
$expect($cleanup['deleted'] === 1 && !is_file($orphan), 'orphan cleanup removes only aged unreferenced objects');
|
|
if (defined('MOBILE_RECORDING_COS_FAULT_TEST')) { require __DIR__ . '/fixtures/mobile_recording_cos_cases.php'; }
|
|
Binding::bind($input, 11);
|
|
Db::execute('DROP TABLE zyt_mobile_recording_upload');
|
|
$missingSchema = $controller('prepare', $prepare)->getData();
|
|
$expect($missingSchema['code'] === 0 && !str_contains(json_encode($missingSchema), $storage), 'missing migration fails closed without SQL or storage path');
|
|
$expect($missingSchema['msg'] === '手机录音上传服务暂不可用,请稍后重试或联系管理员'
|
|
&& $missingSchema['data'] === ['error_code' => 'temporarily_unavailable', 'retryable' => true],
|
|
'Throwable preserves code0 and message while allowing bounded automatic retry');
|
|
$expect(Db::name('mobile_recording_binding')->count() === 1, 'media deployment failure preserves original metadata binding');
|
|
$middleware = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php');
|
|
$expect(strpos($middleware, "'tcm.mobilerecordingmedia/'") < strpos($middleware, '$allUri ='), 'media group protected before unregistered-route bypass');
|
|
echo json_encode(['status' => 'passed', 'driver' => $driver, 'checks' => count($checks), 'evidence' => $checks,
|
|
'storage' => 'temporary synthetic objects cleaned in finally', 'production_connected' => false], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n";
|
|
} finally {
|
|
// Only direct objects in the unique directory created above; never walk unrelated directories.
|
|
foreach (glob($storage . '/*.bin') ?: [] as $file) { if (!is_link($file)) { @unlink($file); } }
|
|
if (is_dir($storage)) { @rmdir($storage); }
|
|
}
|