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(followupDeleteRequest($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 $r) use ($expect): array { $expect($r['code'] === 1, json_encode($r, JSON_UNESCAPED_UNICODE)); return $r['data']; }; $reject = static function (array $r, string $message) use ($expect): void { $expect($r['code'] === 0 && $r['msg'] === $message, json_encode($r, JSON_UNESCAPED_UNICODE)); }; $body = static fn (array $t): array => ['id' => $t['id'], 'version' => $t['version']]; $clinical = static function (): string { $rows = []; foreach (['tcm_diagnosis','tcm_blood_record','patient_diet_record','patient_exercise_record','tracking_note','followup_audio_audit'] as $table) { $rows[$table] = Db::name($table)->order('id')->select()->toArray(); } return hash('sha256', json_encode($rows)); }; $make = static function (bool $review = true) use ($f, $success, $expect): array { static $serial = 0; $serial++; $pcm = str_repeat(pack('v', 1000 + $serial), 16000); $path = $f['private'] . '/delete-synthetic-' . $serial . '.wav'; file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', strlen($pcm)) . $pcm); chmod($path, 0600); $upload = followupAudioTestUpload($f, $path, 'synthetic.wav'); $task = Store::create($upload, '2026-09-29 10:00:00', 'qwen', 1, $f['actor']); if ($review) { $claim = Store::claim(); $expect((int) $claim['id'] === $task['id'], 'claim exact synthetic task'); $expect(Store::complete($task['id'], $claim['lease_token'], ['summary' => 'Synthetic', 'transcript' => '今天合成症状', 'uncertainties' => [], 'items' => [['kind' => 'diagnosis', 'values' => ['symptoms' => '合成症状'], 'record_date' => '2026-09-29', 'date_text' => '今天', 'evidence' => [['text' => '今天合成症状']]]]]), 'complete'); } return $success(followupDeleteRequest('detail', ['id' => $task['id']], 1, 'GET')); }; $parallel = static function (array $requests) use ($f, $expect): array { $jobs = []; $barrier = $f['private'] . '/barrier-' . bin2hex(random_bytes(6)); try { foreach ($requests as [$action, $payload]) { $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 (count(array_filter($jobs, static fn ($j): bool => is_file($j[2] . '.ready'))) !== count($jobs)) { if (microtime(true) > $deadline) { throw new RuntimeException('Child startup timeout'); } usleep(10000); } file_put_contents($barrier, 'go'); } finally { 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); $expect($exit === 0 && $stderr === '', 'child clean: ' . $stderr); $results[] = json_decode(trim($stdout), true, 512, JSON_THROW_ON_ERROR); } } return $results; }; try { $detail = $make(); $original = Store::task($detail['id']); $snapshot = $clinical(); $expect($detail['can_delete'] && $detail['delete_block_reason'] === '', 'root can delete review'); $other = $success(followupDeleteRequest('detail', ['id' => $detail['id']], 2, 'GET')); $expect(!$other['can_delete'] && $other['delete_block_reason'] !== '', 'nonowner capability denied'); $reject(followupDeleteRequest('delete', $body($detail), 2), '仅上传者或超级管理员可删除录音'); $reject(followupDeleteRequest('delete', $body($detail), 3), '诊单不存在或无权操作'); Db::name('admin')->where('id', 1)->update(['disable' => 1]); $reject(followupDeleteRequest('delete', $body($detail)), '账号已停用或无权访问'); Db::name('admin')->where('id', 1)->update(['disable' => 0]); $reject(followupDeleteRequest('delete', ['id' => $detail['id'], 'version' => $detail['version'] + 1]), 'FOLLOWUP_AUDIO_VERSION_CONFLICT'); $reject(followupDeleteRequest('delete', $body($detail), 1, 'GET'), '请求方式错误'); $reject(followupDeleteRequest('delete', $body($detail) + ['force' => true]), '请求包含不支持的字段'); $deleted = $success(followupDeleteRequest('delete', $body($detail))); $expect($deleted['status'] === 'deleted' && $deleted['version'] === $detail['version'] + 1, 'tombstone response'); $row = Db::name('followup_audio_task')->where('id', $detail['id'])->find(); foreach (['sha256','upload_id','upstream_ids_json','extraction_cipher','review_cipher','pipeline_cipher','expires_at'] as $key) { $expect($row[$key] === $original[$key], 'immutable retained ' . $key); } $expect((int) $row['active_slot'] === $detail['id'] && (int) $row['deleted_by'] === 1 && (int) $row['deleted_at'] > 0 && json_decode($row['deletion_json'], true)['previous_status'] === 'review', 'deletion audit metadata'); $expect(is_file(app\common\service\followupaudio\FollowupAudioUpload::path(app\common\service\followupaudio\FollowupAudioUpload::session($row['upload_id']))), 'audio retained'); $items = $success(followupDeleteRequest('lists', ['diagnosis_id' => 1], 1, 'GET'))['items']; $expect(!in_array($detail['id'], array_column($items,'id'), true), 'deleted hidden'); foreach (['detail','audio','retry','saveDraft','apply','delete'] as $action) { $payload = in_array($action,['detail','audio','retry'],true) ? ['id' => $detail['id']] : $body($detail); if (in_array($action,['saveDraft','apply'],true)) { $payload['items'] = $detail['items']; } $reject(followupDeleteRequest($action,$payload,1,in_array($action,['detail','audio'],true)?'GET':'POST'), 'FOLLOWUP_AUDIO_TASK_UNAVAILABLE'); } $create = ['diagnosis_id' => 1,'upload_id' => $row['upload_id'],'recorded_at' => '2026-09-29 10:00:00','model_key' => 'qwen']; $reject(followupDeleteRequest('create',$create), 'FOLLOWUP_AUDIO_UPLOAD_ALREADY_USED'); $upload = followupAudioTestUpload($f, $f['private'] . '/delete-synthetic-1.wav','same-content.wav'); $create['upload_id'] = $upload['id']; $new = $success(followupDeleteRequest('create',$create)); $expect(!$new['reused'] && $new['id'] !== $detail['id'] && Store::task($new['id'])['sha256'] === $row['sha256'], 'fresh identical upload is new inference'); $repeat = $success(followupDeleteRequest('create',$create)); $expect($repeat['reused'] && $repeat['id'] === $new['id'], 'new live dedup retained'); $expect($clinical() === $snapshot, 'no clinical or adoption audit changed'); // Keep the queue clear for following fixtures, without executing any upstream provider. Db::name('followup_audio_task')->where('id',$new['id'])->update(['status'=>'failed']); foreach (['queued','running','needs_reconciliation','applied'] as $status) { $t = $make(); Db::name('followup_audio_task')->where('id',$t['id'])->update(['status'=>$status]); $fresh = $success(followupDeleteRequest('detail',['id'=>$t['id']],1,'GET')); $expect(!$fresh['can_delete'], 'blocked status '.$status); $reject(followupDeleteRequest('delete',$body($t)), $fresh['delete_block_reason']); Db::name('followup_audio_task')->where('id',$t['id'])->update(['status'=>'failed']); } $t=$make(); Db::name('followup_audio_task')->where('id',$t['id'])->update(['status'=>'failed','upstream_started_at'=>time()]); $reject(followupDeleteRequest('delete',$body($t)),'上游结果尚未确定,须先核对'); $t=$make(); Db::name('followup_audio_task')->where('id',$t['id'])->update(['pipeline_cipher'=>'corrupt']); $reject(followupDeleteRequest('delete',$body($t)),'上游结果尚未确定,须先核对'); $t=$make(); Db::name('followup_audio_task')->where('id',$t['id'])->update(['status'=>'failed']); $success(followupDeleteRequest('delete',$body($t))); $t=$make(); Db::name('followup_audio_audit')->insert(['task_id'=>$t['id'],'item_id'=>'synthetic-audit', 'diagnosis_id'=>1,'actor_id'=>1,'kind'=>'diagnosis','table_name'=>'tcm_diagnosis','record_id'=>1,'action'=>'update', 'source_cipher'=>'retained','before_cipher'=>'retained','after_cipher'=>'retained','created_at'=>time()]); $reject(followupDeleteRequest('delete',$body($t)),'已采用或已有采用审计的录音须保留'); $t=$make(); Db::name('tcm_diagnosis')->where('id',1)->update(['patient_id'=>999]); $reject(followupDeleteRequest('delete',$body($t)),'诊单患者归属已变化,不能访问原回访录音'); Db::name('tcm_diagnosis')->where('id',1)->update(['patient_id'=>101]); $t=$make(); Db::name('followup_audio_task')->where('id',$t['id'])->update(['actor_id'=>2]); $fresh=$success(followupDeleteRequest('detail',['id'=>$t['id']],2,'GET')); $expect($fresh['can_delete'],'uploader capability'); Db::name('system_role_menu')->where('role_id',2)->where('menu_id',1)->delete(); $reject(followupDeleteRequest('delete',$body($t),2),'诊单不存在或无权操作'); Db::name('system_role_menu')->insert(['role_id'=>2,'menu_id'=>1]); $success(followupDeleteRequest('delete',$body($t),2)); $t=$make(); $results=$parallel([['delete',$body($t)],['delete',$body($t)]]); $codes=array_column($results,'code');sort($codes);$expect($codes===[0,1],'parallel delete one winner'); $t=$make(); foreach($t['items'] as &$item){$item['selected']=true;$item['needs_review']=false;}unset($item); $before=Db::name('tcm_diagnosis')->where('id',1)->value('symptoms'); $results=$parallel([['apply',$body($t)+['items'=>$t['items']]],['delete',$body($t)]]); $codes=array_column($results,'code');sort($codes);$expect($codes===[0,1],'apply-delete exactly one winner'); $race=Db::name('followup_audio_task')->where('id',$t['id'])->find(); $count=(int)Db::name('followup_audio_audit')->where('task_id',$t['id'])->count(); $expect(($race['status']==='deleted' && $count===0 && Db::name('tcm_diagnosis')->where('id',1)->value('symptoms')===$before) || ($race['status']==='applied' && $count===1),'race result consistent with clinical writes'); if($race['status']==='applied'){$reject(followupDeleteRequest('delete',['id'=>$t['id'],'version'=>(int)$race['version']]),'已采用或已有采用审计的录音须保留');} // Existing retention cleanup still purges tombstones, without making them visible again. Db::name('followup_audio_task')->where('id',$detail['id'])->update(['expires_at'=>time()-1]); Db::name('followup_audio_upload')->where('id',$row['upload_id'])->update(['expires_at'=>time()-1]); $counts=Store::cleanupExpired(); $purged=Db::name('followup_audio_task')->where('id',$detail['id'])->find(); $expect($counts['tasks_purged']===1 && $purged['status']==='deleted' && (int)$purged['purged_at']>0 && $purged['review_cipher']==='', 'retention purge keeps tombstone hidden'); $reject(followupDeleteRequest('detail',['id'=>$detail['id']],1,'GET'),'FOLLOWUP_AUDIO_TASK_UNAVAILABLE'); echo "FOLLOWUP_AUDIO_DELETE assertions={$checks} PASS mysql23319=1 rbac=1 version=1 immutable_retention=1 same_content_new_upload=1 old_upload_denied=1 direct_access_denied=1 blocked_states=1 parallel_delete_one_winner=1 apply_delete_one_winner=1 retention_cleanup=1 upstream_calls=0\n"; } finally { followupAudioTestDatabaseCleanup($f); }