Files
zyt/server/tests/MobileRecordingMediaTest.php
Your Name b9ac9a6635 Merge branch 'master' into 11-5
# 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
2026-11-12 14:21:14 +08:00

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); }
}