withServer(['REQUEST_METHOD' => $method]); $request = $method === 'GET' ? $request->withGet($body) : $request->withPost($body); $controller = (new ReflectionClass(FollowupAudioController::class))->newInstanceWithoutConstructor(); // The endpoint itself reloads the current actor; injected info must not grant root rights. foreach (['request' => $request, 'adminId' => $actor, 'adminInfo' => ['root' => 1]] as $key => $value) { $property = new ReflectionProperty($controller, $key); $property->setAccessible(true); $property->setValue($controller, $value); } return $controller->$action()->getData(); } if (($argv[1] ?? '') === '--request') { $path = $argv[2] ?? ''; if (!preg_match('#^/private/tmp/fa_pipeline_[a-f0-9]{12}/controller-child-[a-f0-9]+\.json$#D', $path)) { throw new RuntimeException('Disposable child input required'); } $input = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); if ($input['connection']['hostname'] !== '127.0.0.1' || (int) $input['connection']['hostport'] !== 23319 || !preg_match('/^fa_pipeline_[a-f0-9]{12}$/D', $input['connection']['database'])) { throw new RuntimeException('Disposable child connection required'); } new think\App(); // Deliberately not initialized: no application .env/config discovery. $manager = new think\DbManager(); $manager->setConfig(['default' => 'mysql', 'connections' => ['mysql' => $input['connection']]]); Container::getInstance()->instance('think\DbManager', $manager); $config = new think\Config(); $config->set($input['settings'], 'followup_audio'); Container::getInstance()->instance('config', $config); file_put_contents($path . '.ready', 'ready'); $deadline = microtime(true) + 15; while (!is_file($input['barrier'])) { if (microtime(true) > $deadline) { throw new RuntimeException('Child barrier timeout'); } usleep(10000); } echo json_encode(followupControllerRequest($input['action'], $input['body']), JSON_THROW_ON_ERROR) . PHP_EOL; exit(0); } $f = followupAudioTestDatabase(['preview_only' => false]); $checks = 0; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; }; $success = static function (array $response, string $why) use ($expect): array { $expect($response['code'] === 1, $why . ': ' . $response['msg']); return $response['data']; }; $reject = static function (array $response, string $message) use ($expect): void { $expect($response['code'] === 0 && $response['msg'] === $message, 'expected ' . $message . ', got ' . json_encode($response, JSON_UNESCAPED_UNICODE)); }; $body = static fn (array $detail): array => ['id' => $detail['id'], 'version' => $detail['version'], 'items' => $detail['items']]; $resolve = static function (array $detail): array { foreach ($detail['items'] as &$item) { $item['selected'] = true; $item['needs_review'] = false; } unset($item); return $detail; }; $snapshot = static function (): string { $rows = []; foreach (['tcm_diagnosis', 'tcm_prescription_order', 'tcm_blood_record', 'patient_diet_record', 'patient_exercise_record', 'tracking_note', 'followup_audio_audit', 'followup_audio_task'] as $table) { $rows[$table] = Db::name($table)->order('id')->select()->toArray(); } return hash('sha256', json_encode($rows, JSON_THROW_ON_ERROR)); }; $make = static function (array $proposals, bool $stereo = true) use ($f, $success, $expect): array { static $serial = 0; $serial++; $pcm = str_repeat(pack('vv', 1000 + $serial, 2000 + $serial), 16000); $path = $f['private'] . '/controller-synthetic-' . $serial . '.wav'; file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 2, 16000, 64000, 4, 16) . 'data' . pack('V', strlen($pcm)) . $pcm); chmod($path, 0600); $upload = followupAudioTestUpload($f, $path, 'synthetic.wav'); $created = $success(followupControllerRequest('create', ['diagnosis_id' => 1, 'upload_id' => $upload['id'], 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen']), 'controller create'); $texts = [' 今天我说合成症状,空腹血糖六点一,早餐鸡蛋,散步二十五分钟,合成回访。 ', '今天客服确认合成数据。']; $segments = [ ['id' => 'left', 'text' => $texts[0], 'start_ms' => 0, 'end_ms' => 1000, 'channel' => 0], ['id' => 'right', 'text' => $texts[1], 'start_ms' => 0, 'end_ms' => 1000, 'channel' => 1], ]; $items = []; foreach ($proposals as [$kind, $values]) { $evidence = ['text' => $texts[0]]; if ($stereo) { $evidence += ['segment_id' => 'left', 'position_type' => 'segment', 'start_ms' => 0, 'end_ms' => 1000, 'channel' => 0]; } $items[] = ['kind' => $kind, 'values' => $values, 'record_date' => '2026-09-29', 'date_text' => '今天', 'record_time' => '08:00', 'time_text' => '早晨', 'evidence' => [$evidence]]; } $extraction = ['summary' => 'Synthetic controller flow', 'transcript' => implode("\n", $texts), 'items' => $items, 'uncertainties' => []]; if ($stereo) { $extraction['transcript_segments'] = $segments; } $claim = Store::claim(); $expect((int) ($claim['id'] ?? 0) === (int) $created['id'], 'claim exact synthetic controller task'); $expect(Store::complete($created['id'], $claim['lease_token'], $extraction), 'synthetic extraction accepted without upstream'); $detail = $success(followupControllerRequest('detail', ['id' => $created['id']], 1, 'GET'), 'controller detail'); $expect(count($detail['items']) === count($proposals), 'all synthetic proposals normalized'); return $detail; }; $parallel = static function (string $action, array $payload) use ($f, $expect): array { $jobs = []; $barrier = $f['private'] . '/barrier-' . bin2hex(random_bytes(6)); try { for ($i = 0; $i < 2; $i++) { $path = $f['private'] . '/controller-child-' . bin2hex(random_bytes(6)) . '.json'; file_put_contents($path, json_encode(['connection' => Db::connect()->getConfig(), 'settings' => config('followup_audio'), 'action' => $action, 'body' => $payload, 'barrier' => $barrier], JSON_THROW_ON_ERROR)); chmod($path, 0600); $pipes = []; $process = proc_open([PHP_BINARY, __FILE__, '--request', $path], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); if (!is_resource($process)) { throw new RuntimeException('Child launch failed'); } fclose($pipes[0]); $jobs[] = [$process, $pipes, $path]; } $deadline = microtime(true) + 15; while (!is_file($jobs[0][2] . '.ready') || !is_file($jobs[1][2] . '.ready')) { if (microtime(true) > $deadline) { throw new RuntimeException('Concurrent controller startup timeout'); } usleep(10000); } file_put_contents($barrier, 'go'); } finally { // Release even on setup failure, then reap every launched PHP process before fixture cleanup. if (!is_file($barrier)) { file_put_contents($barrier, 'go'); } $results = []; foreach ($jobs as [$process, $pipes]) { $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $exit = proc_close($process); $results[] = ['exit' => $exit, 'stderr' => $stderr, 'stdout' => $stdout]; } } foreach ($results as $result) { $expect($result['exit'] === 0 && $result['stderr'] === '', 'concurrent controller child exits cleanly'); } return array_map(static fn (array $result): array => json_decode(trim($result['stdout']), true, 512, JSON_THROW_ON_ERROR), $results); }; try { if (($argv[1] ?? '') === '--direct-apply-regression') { $direct = $make([['blood', ['fasting_blood_sugar' => 6.1]]], false); $submitted = $resolve($direct); $submitted['items'][0]['values']['fasting_blood_sugar'] = 6.8; $result = $success(followupControllerRequest('apply', $body($submitted)), 'direct apply without saving draft'); $detail = $success(followupControllerRequest('detail', ['id' => $direct['id']], 1, 'GET'), 'detail after direct apply'); $actual = (float) Db::name('tcm_blood_record')->where('id', $result['applied_items'][0]['record_id'])->value('fasting_blood_sugar'); $expect($actual === 6.8 && $result['applied_items'][0]['values']['fasting_blood_sugar'] === 6.8, 'direct Apply persists submitted correction'); $observed = ['status' => $detail['status'], 'database_value' => $actual, 'applied_value' => $detail['applied_items'][0]['values']['fasting_blood_sugar'], 'detail_value' => $detail['items'][0]['values']['fasting_blood_sugar'], 'detail_selected' => $detail['items'][0]['selected'], 'detail_needs_review' => $detail['items'][0]['needs_review']]; echo 'DIRECT_APPLY_DETAIL_OBSERVED ' . json_encode($observed, JSON_THROW_ON_ERROR) . PHP_EOL; $expect($detail['status'] === 'applied' && $detail['items'][0]['values']['fasting_blood_sugar'] === 6.8 && $detail['items'][0]['selected'] === true && $detail['items'][0]['needs_review'] === false, 'DIRECT_APPLY_DETAIL_MISMATCH: applied detail must reflect confirmed submitted values and selection without a prior saveDraft'); echo 'FOLLOWUP_AUDIO_DIRECT_APPLY_DETAIL assertions=' . $checks . ' PASS' . PHP_EOL; } else { $cap = $success(followupControllerRequest('capabilities', ['diagnosis_id' => 1], 1, 'GET'), 'normal capabilities'); $expect($cap['enabled'] && !$cap['preview_only'] && $cap['can_apply'], 'normal mode enabled only inside isolated fixture'); $detail = $make([['diagnosis', ['symptoms' => '合成症状']], ['blood', ['fasting_blood_sugar' => 6.1]]]); $reject(followupControllerRequest('apply', $body($resolve($detail))), 'FOLLOWUP_AUDIO_CHANNEL_ROLES_REQUIRED'); $initialVersion = $detail['version']; $detail = $success(followupControllerRequest('saveDraft', $body($resolve($detail)) + ['channel_roles' => 'left_service']), 'save left role'); $expect($detail['channel_roles'] === 'left_service' && $detail['version'] === $initialVersion + 1 && !$detail['items'][0]['selected'] && $detail['items'][0]['needs_review'], 'role confirmation invalidates simultaneous selection'); $beforeRole = $detail; $detail = $success(followupControllerRequest('saveDraft', $body($resolve($detail)) + ['channel_roles' => 'right_service']), 'save right role'); $expect($detail['channel_roles'] === 'right_service' && !$detail['items'][1]['selected'] && $detail['items'][1]['needs_review'], 'role swap forces fresh review'); $reject(followupControllerRequest('apply', $body($detail)), 'FOLLOWUP_AUDIO_NOTHING_SELECTED'); $unreviewed = $detail; $unreviewed['items'][0]['selected'] = true; $reject(followupControllerRequest('apply', $body($unreviewed)), 'FOLLOWUP_AUDIO_REVIEW_REQUIRED'); $reject(followupControllerRequest('saveDraft', $body($beforeRole) + ['channel_roles' => 'left_service']), 'FOLLOWUP_AUDIO_VERSION_CONFLICT'); $detail = $resolve($detail); $detail['items'][1]['values']['fasting_blood_sugar'] = 6.2; $evidence = array_column($detail['items'], 'evidence'); $detail = $success(followupControllerRequest('saveDraft', $body($detail)), 'manual correction and confirmation'); $expect($detail['items'][1]['values']['fasting_blood_sugar'] === 6.2 && $detail['items'][1]['selected'] === true && $detail['items'][1]['needs_review'] === false, 'typed JSON values and booleans survive draft roundtrip'); $expect(array_column($detail['items'], 'evidence') === $evidence && $evidence[0][0]['channel'] === 0 && $evidence[0][0]['start_ms'] === 0 && str_starts_with($evidence[0][0]['text'], ' '), 'exact whitespace evidence and integer zero retained'); $unchanged = $snapshot(); foreach ([ ['selected', 'true', 'FOLLOWUP_AUDIO_BOOLEAN_INVALID'], ['selected', 1, 'FOLLOWUP_AUDIO_BOOLEAN_INVALID'], ['needs_review', 'false', 'FOLLOWUP_AUDIO_BOOLEAN_INVALID'], ['target_id', '1', 'FOLLOWUP_AUDIO_TARGET_INVALID'], ['expected_hash', str_repeat('0', 64), 'FOLLOWUP_AUDIO_IMMUTABLE_FIELD'], ['invented_field', true, 'FOLLOWUP_AUDIO_IMMUTABLE_FIELD'], ] as [$key, $value, $error]) { $invalid = $body($detail); $invalid['items'][0][$key] = $value; foreach (['saveDraft', 'apply'] as $action) { $reject(followupControllerRequest($action, $invalid), $error); } } foreach (['saveDraft', 'apply'] as $action) { $invalid = $body($detail); $invalid['items'][0]['evidence'][0]['text'] .= '伪造'; $reject(followupControllerRequest($action, $invalid), 'FOLLOWUP_AUDIO_IMMUTABLE_FIELD'); $invalid = $body($detail); $invalid['items'][0]['values']['patient_id'] = 202; $reject(followupControllerRequest($action, $invalid), 'FOLLOWUP_AUDIO_UNPROPOSED_FIELD'); $invalid = $body($detail); $invalid['items'][1]['values']['fasting_blood_sugar'] = 6.123; $reject(followupControllerRequest($action, $invalid), 'FOLLOWUP_AUDIO_NUMBER_INVALID'); $reject(followupControllerRequest($action, $body($detail) + ['unlisted' => true]), '请求包含不支持的字段'); $reject(followupControllerRequest($action, $body($detail), 3), '诊单不存在或无权操作'); } $reject(followupControllerRequest('saveDraft', $body($detail) + ['channel_roles' => 'invented']), 'FOLLOWUP_AUDIO_CHANNEL_ROLES_INVALID'); Db::name('admin')->where('id', 1)->update(['disable' => 1]); $reject(followupControllerRequest('apply', $body($detail)), '账号已停用或无权访问'); Db::name('admin')->where('id', 1)->update(['disable' => 0]); Db::name('system_role_menu')->where('role_id', 2)->where('menu_id', 2)->delete(); $reject(followupControllerRequest('apply', $body($detail), 2), '诊单不存在或无权操作'); Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => 2]); $expect($snapshot() === $unchanged, 'invalid payloads and permissions never mutate review or business records'); $applied = $success(followupControllerRequest('apply', $body($detail)), 'normal apply succeeds'); $expect($applied['status'] === 'applied' && count($applied['applied_items']) === 2 && (float) Db::name('tcm_blood_record')->value('fasting_blood_sugar') === 6.2 && Db::name('tcm_diagnosis')->where('id', 1)->value('symptoms') === '合成症状', 'confirmed manual values reach clinical records'); $afterApply = $snapshot(); $expect(Policy::canonical($success(followupControllerRequest('apply', $body($detail)), 'repeated apply')) === Policy::canonical($applied) && $snapshot() === $afterApply, 'repeat apply returns original IDs without writes'); $audit = Db::name('followup_audio_audit')->where('task_id', $detail['id'])->where('kind', 'blood')->find(); $source = Store::open($detail['id'], 'audit-source:' . $audit['item_id'], $audit['source_cipher']); $expect($source['values']['fasting_blood_sugar'] === 6.1 && $source['channel_roles'] === 'right_service' && $source['evidence'] === $evidence[1], 'audit retains original proposal and evidence separate from human correction'); $stale = $make([['diagnosis', ['symptoms' => '合成更新']]], false); Db::name('tcm_diagnosis')->where('id', 1)->update(['symptoms' => 'concurrent editor value']); $response = followupControllerRequest('apply', $body($resolve($stale))); $reject($response, '病历或日常记录已发生变化,请重新审阅差异后确认'); $expect($response['data']['code'] === 'FOLLOWUP_AUDIO_STALE_REVIEW' && Db::name('tcm_diagnosis')->where('id', 1)->value('symptoms') === 'concurrent editor value', 'concurrent business edit is never overwritten'); $fresh = $success(followupControllerRequest('detail', ['id' => $stale['id']], 1, 'GET'), 'fresh conflict detail'); $expect($fresh['version'] === $stale['version'] + 1 && !$fresh['items'][0]['selected'] && $fresh['items'][0]['needs_review'] && $fresh['items'][0]['current_values']['symptoms'] === 'concurrent editor value', 'stale conflict refresh commits only new review state'); foreach (['saveDraft', 'apply'] as $action) { $reject(followupControllerRequest($action, $body($resolve($stale))), 'FOLLOWUP_AUDIO_VERSION_CONFLICT'); } $success(followupControllerRequest('apply', $body($resolve($fresh))), 'explicit re-review adopts fresh version'); $concurrent = $make([['diagnosis', ['symptoms' => '并发草稿']]], false); $results = $parallel('saveDraft', $body($resolve($concurrent))); $codes = array_column($results, 'code'); sort($codes); $expect($codes === [0, 1] && count(array_filter($results, static fn (array $r): bool => $r['msg'] === 'FOLLOWUP_AUDIO_VERSION_CONFLICT')) === 1, 'two actual controller processes with same draft version yield exactly one winner'); $fresh = $success(followupControllerRequest('detail', ['id' => $concurrent['id']], 1, 'GET'), 'concurrent winner detail'); $expect($fresh['version'] === $concurrent['version'] + 1, 'parallel draft advances version exactly once'); $results = $parallel('apply', $body($fresh)); $first = $success($results[0], 'parallel apply first'); $second = $success($results[1], 'parallel apply second'); $expect(Policy::canonical($first) === Policy::canonical($second) && (int) Db::name('followup_audio_audit')->where('task_id', $fresh['id'])->count() === 1, 'two actual controller Apply processes produce one adoption and identical IDs'); $atomic = $make([['diagnosis', ['symptoms' => 'rollback symptom']], ['blood', ['fasting_blood_sugar' => 6.4]], ['diet', ['breakfast' => 'rollback breakfast']], ['exercise', ['exercise_type' => 'walk', 'duration' => 25, 'intensity' => 1]], ['tracking_note', ['content' => 'rollback note']]], false); $beforeFailure = $snapshot(); Db::execute('SET @fa_controller_rollback_reached = 0'); $f['pdo']->exec("CREATE TRIGGER fa_controller_audit_failure BEFORE INSERT ON zyt_followup_audio_audit FOR EACH ROW BEGIN IF NEW.kind = 'tracking_note' THEN SET @fa_controller_rollback_reached = 1; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='synthetic final audit rejection'; END IF; END"); try { $reject(followupControllerRequest('apply', $body($resolve($atomic))), '回访录音服务暂不可用,请联系管理员检查部署'); $expect((int) Db::query('SELECT @fa_controller_rollback_reached AS reached')[0]['reached'] === 1, 'actual failing Apply reached the fifth audit after writing all five business targets'); $expect($snapshot() === $beforeFailure && Store::task($atomic['id'])['status'] === 'review', 'failure at final audit restores all five business tables, preceding audits, task version/status/cipher'); } finally { $f['pdo']->exec('DROP TRIGGER fa_controller_audit_failure'); } $applied = $success(followupControllerRequest('apply', $body($resolve($atomic))), 'retry after removed synthetic failure'); $expect(count($applied['applied_items']) === 5 && (int) Db::name('followup_audio_audit')->where('task_id', $atomic['id'])->count() === 5, 'transaction rollback restores retryable behavior and clean retry writes exactly five records/audits'); $providers = config('followup_audio.providers'); $providers['qwen']['preview_verified_fingerprint'] = Provider::resolve('qwen')['fingerprint']; $f['config']->set(['providers' => $providers, 'preview_only' => true, 'test_diagnosis_ids' => '1', 'test_admin_ids' => '1'], 'followup_audio'); $preview = $make([['blood', ['fasting_blood_sugar' => 6.5]]], false); $beforePreview = $snapshot(); $reject(followupControllerRequest('apply', $body($resolve($preview))), 'FOLLOWUP_AUDIO_PREVIEW_ONLY'); $f['config']->set(['preview_only' => false], 'followup_audio'); $reject(followupControllerRequest('apply', $body($resolve($preview))), 'FOLLOWUP_AUDIO_PREVIEW_ONLY'); $expect($snapshot() === $beforePreview, 'preview-origin task permanently forbids writes after normal-mode switch'); echo 'FOLLOWUP_AUDIO_CONTROLLER_FLOW assertions=' . $checks . ' PASS mysql23319=1 request_controller=1 normal_apply=1 role_confirm_swap=1 manual_correction=1 typed_json=1' . ' invalid_payload_denied=1 rbac=1 stale_review=1 concurrent_draft_one_winner=1 concurrent_apply_idempotent=1' . ' five_table_atomic_rollback=1 retry_restored=1 preview_origin_permanent=1 upstream_calls=0' . PHP_EOL; } } finally { followupAudioTestDatabaseCleanup($f); }