1, 'show' => 0, 'msg' => '', 'data' => $data]; } } class_alias(MigrationTestJson::class, 'app\common\service\JsonService'); require dirname(__DIR__) . '/vendor/autoload.php'; final class MigrationConfigController extends \app\adminapi\controller\ConfigController { public function __construct(string $action = 'doctorWebConsultation') { $this->request = (new \think\Request())->setAction($action); } } $assertions = 0; function migrationExpect(bool $condition, string $message): void { global $assertions; $assertions++; if (!$condition) throw new \RuntimeException($message); } function migrationRejects(array $configuration, string $key): void { try { Policy::publicPolicy(123, $configuration); } catch (\InvalidArgumentException $error) { migrationExpect(str_contains($error->getMessage(), 'doctor_web_consultation.' . $key), 'configuration error names the invalid field'); return; } migrationExpect(false, 'invalid configuration was accepted: ' . $key); } $cutoff = (new \DateTimeImmutable('2026-10-23T16:00:00Z'))->getTimestamp(); $GLOBALS['migration_test_now'] = $cutoff; date_default_timezone_set('UTC'); migrationExpect(Policy::cutoffTimestamp() === $cutoff, 'cutoff is independent of process timezone'); $defaults = require dirname(__DIR__) . '/config/doctor_web_consultation.php'; $defaultPolicy = Policy::publicPolicy($cutoff); migrationExpect($defaultPolicy === [ 'cutoff_at' => '2026-10-24T00:00:00+08:00', 'approaching_at' => '2026-10-22T00:00:00+08:00', 'urgent_at' => '2026-10-23T00:00:00+08:00', 'server_time' => $cutoff, ], 'default config exposes the cutoff and both reminder boundaries with explicit Beijing offsets'); migrationExpect(Policy::cutoffAt() === $defaultPolicy['cutoff_at'], 'cutoff helper uses the same config'); migrationExpect(str_contains(Policy::message(), '2026年10月24日00:00:00'), 'message derives the exact configured second'); $doctor = ['admin_id' => 101, 'root' => 0, 'role_id' => [1], 'terminal' => 1]; $browser = ['User-Agent' => 'Mozilla/5.0', 'Origin' => 'https://example.test']; foreach (['getCallSignature', 'startCall', 'getDoctorSignature', 'getPatientSignature'] as $action) { foreach ([$cutoff - 1 => false, $cutoff => true, $cutoff + 1 => true] as $now => $expected) { migrationExpect(Policy::blocksRequest($doctor, 'tcm.Diagnosis', $action, $browser, $now) === $expected, "$action boundary $now"); } } foreach ([['role_id' => [2]], ['role_id' => [3, 7, 8]], ['root' => 1, 'role_id' => [1]], []] as $identity) { migrationExpect(!Policy::blocksRequest($identity, 'tcm.diagnosis', 'startCall', $browser), 'only non-root doctors migrate'); } migrationExpect(Policy::blocksRequest(['role_id' => ['2', '1']], 'tcm.diagnosis', 'startCall', $browser), 'mixed and numeric string roles retain doctor policy'); foreach (['ZytDoctorWorkstation/1.4.8', 'python-httpx/0.27.2', 'python-httpx/0.28.1'] as $ua) { migrationExpect(!Policy::blocksRequest($doctor, 'tcm.diagnosis', 'getCallSignature', ['User-Agent' => $ua]), 'new and already installed workstations keep working'); foreach (['Origin', 'Referer', 'Sec-Fetch-Mode', 'Sec-Fetch-Site', 'Sec-Fetch-Dest'] as $signal) { migrationExpect(Policy::blocksRequest($doctor, 'tcm.diagnosis', 'startCall', ['User-Agent' => $ua, $signal => 'same-origin']), 'browser metadata takes priority over desktop UA'); } } foreach (['', 'unknown', 'Mozilla/5.0', 'Mozilla/5.0 ZytDoctorWorkstation/1.4.8', 'python-httpx/0.28.1 extra'] as $ua) { migrationExpect(Policy::blocksRequest($doctor, 'tcm.diagnosis', 'startCall', ['user-agent' => $ua]), 'missing or unknown client never gains a desktop exemption'); } foreach (['lists', 'detail', 'readonlyDetail', 'edit', 'bindCallRoom', 'endCall', 'watchCall', 'attachLocalCallRecording', 'upsertCallTranscriptSegments', 'finishCallTranscription'] as $action) { migrationExpect(!Policy::blocksRequest($doctor, 'tcm.diagnosis', $action, $browser), 'history and in-flight cleanup remain available: ' . $action); } foreach (['doctor.appointment', 'tcm.prescription', 'auth.admin'] as $controller) { migrationExpect(!Policy::blocksRequest($doctor, $controller, 'startCall', $browser), 'exact controller scope'); } $custom = ['cutoff_at' => '2032-01-01 14:30:45', 'approaching_hours' => 72, 'urgent_hours' => 6]; $customCutoff = (new \DateTimeImmutable('2032-01-01T14:30:45+08:00'))->getTimestamp(); $customPolicy = Policy::publicPolicy($customCutoff - 1, $custom); migrationExpect($customPolicy === [ 'cutoff_at' => '2032-01-01T14:30:45+08:00', 'approaching_at' => '2031-12-29T14:30:45+08:00', 'urgent_at' => '2032-01-01T08:30:45+08:00', 'server_time' => $customCutoff - 1, ], 'arbitrary midday/year schedule moves both reminder phases automatically'); foreach ([$customCutoff - 1 => false, $customCutoff => true, $customCutoff + 1 => true] as $now => $expected) { migrationExpect(Policy::blocksRequest($doctor, 'tcm.Diagnosis', 'startCall', $browser, $now, $custom) === $expected, 'custom schedule controls the exact-second backend cutoff'); } migrationExpect(str_contains(Policy::message($custom), '2032年1月1日14:30:45'), 'custom schedule also changes backend notice text'); foreach (['UTC', 'America/New_York', 'Asia/Tokyo'] as $processTimezone) { date_default_timezone_set($processTimezone); migrationExpect(Policy::publicPolicy(123, $custom)['cutoff_at'] === $customPolicy['cutoff_at'], 'Beijing configuration ignores PHP process timezone'); } date_default_timezone_set('UTC'); $zeroHours = Policy::publicPolicy(123, array_replace($custom, ['approaching_hours' => 0, 'urgent_hours' => 0])); migrationExpect($zeroHours['approaching_at'] === $zeroHours['cutoff_at'] && $zeroHours['urgent_at'] === $zeroHours['cutoff_at'], 'zero-hour reminders are valid'); $equalHours = Policy::publicPolicy(123, array_replace($custom, ['approaching_hours' => 6])); migrationExpect($equalHours['approaching_at'] === $equalHours['urgent_at'], 'equal reminder offsets are valid'); migrationExpect(Policy::publicPolicy(123, array_replace($custom, ['cutoff_at' => '2032-02-29 12:00:01']))['cutoff_at'] === '2032-02-29T12:00:01+08:00', 'valid leap date is retained'); foreach ([null, false, 20320101, [], '', 'tomorrow', '2032-1-01 14:30:45', '2032-01-01 14:30', '2032-01-01 14:30:45 ', '2031-02-29 14:30:45', '2032-04-31 14:30:45', '2032-01-01 24:00:00', '2032-01-01 14:60:45', '2032-01-01 14:30:60', '0000-01-01 00:00:00', '1991-04-14 02:30:00', '2032-01-01T14:30:45+08:00', '2032-01-01 14:30:45+08:00', '2032-01-01T14:30:45+25:00'] as $invalid) { migrationRejects(array_replace($defaults, ['cutoff_at' => $invalid]), 'cutoff_at'); } migrationRejects([], 'cutoff_at'); foreach (['approaching_hours', 'urgent_hours'] as $key) { foreach ([null, -1, 1.5, '24', true, [], PHP_INT_MAX] as $invalid) { migrationRejects(array_replace($defaults, [$key => $invalid]), $key); } $missing = $defaults; unset($missing[$key]); migrationRejects($missing, $key); } migrationRejects(array_replace($defaults, ['approaching_hours' => 12, 'urgent_hours' => 24]), 'approaching_hours'); migrationRejects(array_replace($defaults, ['approaching_hours' => intdiv(PHP_INT_MAX, 3600)]), 'approaching_hours'); migrationExpect(!Policy::blocksRequest($doctor, 'config', 'doctorWebConsultation', $browser, $cutoff, []), 'invalid migration config never affects unrelated routes'); migrationExpect(!Policy::blocksRequest(['root' => 1, 'role_id' => [1]], 'tcm.diagnosis', 'startCall', $browser, $cutoff, []), 'invalid migration config never affects root exemption'); migrationExpect(!Policy::blocksRequest(['role_id' => [2]], 'tcm.diagnosis', 'startCall', $browser, $cutoff, []), 'invalid migration config never affects other roles'); migrationExpect(!Policy::blocksRequest($doctor, 'tcm.diagnosis', 'startCall', ['User-Agent' => 'python-httpx/0.28.1'], $cutoff, []), 'invalid migration config never affects native exemption'); $request = new class($doctor, $browser) { public string $actionName = 'getCallSignature'; public function __construct(public array $adminInfo, public array $headers) {} public function controller(): string { return 'tcm.Diagnosis'; } public function action(): string { return $this->actionName; } public function header(): array { return $this->headers; } }; $executed = 0; $next = static function ($request) use (&$executed): string { $executed++; return 'business action executed'; }; $middleware = new DoctorWebConsultationMiddleware(); $GLOBALS['migration_test_now'] = $cutoff - 1; migrationExpect($middleware->handle($request, $next) === 'business action executed', 'middleware allows October 23'); $GLOBALS['migration_test_now'] = $cutoff; $failure = $middleware->handle($request, $next); migrationExpect($executed === 1, 'expired doctor request never reaches signature generation'); migrationExpect($failure['code'] === 0 && $failure['data']['reason'] === 'doctor_web_consultation_expired', 'business failure does not log the user out'); migrationExpect(str_contains($failure['msg'], '桌面应用'), 'failure explains migration'); $request->headers = ['User-Agent' => 'python-httpx/0.28.1']; migrationExpect($middleware->handle($request, $next) === 'business action executed', 'same terminal=1 native session remains usable'); $request->headers = $browser; $request->actionName = 'endCall'; migrationExpect($middleware->handle($request, $next) === 'business action executed', 'active calls can still finish and persist recordings'); $request->actionName = 'getDoctorSignature'; $customMiddleware = new DoctorWebConsultationMiddleware($custom); $GLOBALS['migration_test_now'] = $customCutoff - 1; migrationExpect($customMiddleware->handle($request, $next) === 'business action executed', 'middleware respects configured midday cutoff before its exact second'); $GLOBALS['migration_test_now'] = $customCutoff; $beforeBlocked = $executed; $customFailure = $customMiddleware->handle($request, $next); migrationExpect($executed === $beforeBlocked && $customFailure['data']['cutoff_at'] === $customPolicy['cutoff_at'], 'actual middleware guard and payload use custom config'); migrationExpect(str_contains($customFailure['msg'], '2032年1月1日14:30:45'), 'actual middleware response formats custom config'); foreach (['ZytDoctorWorkstation/1.4.8', 'python-httpx/0.28.1'] as $ua) { $request->headers = ['User-Agent' => $ua]; migrationExpect($customMiddleware->handle($request, $next) === 'business action executed', 'custom cutoff preserves native client compatibility'); } $routes = file_get_contents(dirname(__DIR__) . '/app/adminapi/config/route.php'); migrationExpect(strpos($routes, 'AuthMiddleware::class') < strpos($routes, 'DoctorWebConsultationMiddleware::class'), 'migration runs after normal authorization'); $GLOBALS['migration_test_now'] = $cutoff; migrationExpect(Policy::publicPolicy()['server_time'] === $cutoff, 'profile exposes synchronized server time'); $controller = new MigrationConfigController(); migrationExpect($controller->isNotNeedLogin(), 'public polling endpoint passes the real inherited login whitelist'); migrationExpect(!(new MigrationConfigController('unlistedAction'))->isNotNeedLogin(), 'whitelist only exposes explicitly public actions'); $endpoint = $controller->doctorWebConsultation(); migrationExpect($endpoint['code'] === 1 && $endpoint['data'] === $defaultPolicy, 'actual ConfigController returns the full read-only public policy contract'); $profileSource = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/auth/AdminLogic.php'); migrationExpect(str_contains($profileSource, "DoctorWebConsultationPolicy::publicPolicy()"), 'logged-in profile keeps sharing the same backend policy'); // 在独立临时目录验证配置热加载;不改仓库配置,也不读取 .env 或项目密钥。 $fixtureRoot = sys_get_temp_dir() . '/zyt-consultation-policy-' . bin2hex(random_bytes(8)); $fixtureServiceDir = $fixtureRoot . '/app/adminapi/service'; $fixtureConfigDir = $fixtureRoot . '/config'; mkdir($fixtureServiceDir, 0700, true); mkdir($fixtureConfigDir, 0700, true); $fixtureService = $fixtureServiceDir . '/DoctorWebConsultationPolicy.php'; $fixtureConfig = $fixtureConfigDir . '/doctor_web_consultation.php'; try { $source = file_get_contents(dirname(__DIR__) . '/app/adminapi/service/DoctorWebConsultationPolicy.php'); file_put_contents($fixtureService, str_replace('namespace app\\adminapi\\service;', 'namespace migration_reload_fixture;', $source)); file_put_contents($fixtureConfig, 'set($defaults, 'doctor_web_consultation'); \think\Container::getInstance()->instance('config', $frameworkConfig); require $fixtureService; $fixtureClass = \migration_reload_fixture\DoctorWebConsultationPolicy::class; migrationExpect($fixtureClass::publicPolicy(123)['cutoff_at'] === $defaultPolicy['cutoff_at'], 'direct config loader starts with default fixture'); file_put_contents($fixtureConfig, 'getMessage(), 'doctor_web_consultation'), 'non-array config has a clear error'); } } finally { foreach ([$fixtureConfig, $fixtureService] as $file) { if (is_file($file)) unlink($file); } foreach ([$fixtureServiceDir, dirname($fixtureServiceDir), $fixtureRoot . '/app', $fixtureConfigDir, $fixtureRoot] as $directory) { if (is_dir($directory)) rmdir($directory); } } echo "Doctor web consultation policy: {$assertions} assertions passed\n"; }