feat(followup-audio): soft delete unadopted recordings and expose extraction doubts

This commit is contained in:
2026-10-12 10:39:12 +08:00
parent 70ddc1a70a
commit 14e1722291
13 changed files with 426 additions and 18 deletions
+200
View File
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
/** Public controller + real app Request contract, against a disposable local database only. */
require __DIR__ . '/fixtures/followup_audio/database.php';
use app\adminapi\controller\tcm\FollowupAudioController;
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use think\Container;
use think\facade\Db;
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23319
|| getenv('FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE') !== '1') {
throw new RuntimeException('Controller flow requires dedicated disposable MySQL 23319');
}
function followupDeleteRequest(string $action, array $body, int $actor = 1, string $method = 'POST'): array
{
// Preserve the same JSON scalar types received by app Request; never call Logic directly here.
$body = json_decode(json_encode($body, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
$request = (new app\Request())->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); }