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
@@ -93,6 +93,12 @@ final class FollowupAudioController extends BaseAdminController
$this->items($p), $this->adminId, $this->adminInfo), true);
}
public function delete()
{
return $this->handle(true, ['id', 'version'], fn (array $p): array =>
Logic::delete($this->positive($p, 'id'), $this->positive($p, 'version'), $this->adminId, $this->adminInfo));
}
public function retry()
{
return $this->handle(true, ['id'], fn (array $p): array =>
@@ -83,7 +83,7 @@ class AuthMiddleware
// This group must never use the legacy unregistered-route bypass.
if (str_starts_with($accessUri, 'tcm.followupaudio/')) {
$allowed = ['capabilities', 'uploadsession', 'uploadchunk', 'uploadcomplete',
'create', 'lists', 'detail', 'savedraft', 'apply', 'retry', 'audio'];
'create', 'lists', 'detail', 'savedraft', 'apply', 'retry', 'audio', 'delete'];
$action = substr($accessUri, strlen('tcm.followupaudio/'));
$uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
if (in_array($action, $allowed, true) && in_array('tcm.diagnosis/edit', $uris, true)) {
@@ -70,7 +70,8 @@ final class FollowupAudioLogic
public static function lists(int $diagnosisId, int $actor, array $info): array
{
Access::diagnosis($diagnosisId, $actor, $info);
return ['items' => Store::enabled() ? Store::lists($diagnosisId) : []];
return ['items' => Store::enabled() ? array_map(static fn (array $task): array =>
self::withDeleteCapability($task, $actor), Store::lists($diagnosisId)) : []];
}
public static function detail(int $id, int $actor, array $info): array
@@ -96,6 +97,22 @@ final class FollowupAudioLogic
return Apply::apply($id, $version, $items, $actor, $info);
}
public static function delete(int $id, int $version, int $actor, array $info): array
{
self::requireEnabled();
return Store::delete($id, $version, $actor, $info);
}
private static function withDeleteCapability(array $summary, int $actor): array
{
$task = Store::task((int) $summary['id']);
$info = Access::actor($actor);
$reason = !Store::enabled() ? '回访录音功能尚未启用'
: ((int) $info['root'] !== 1 && (int) $task['actor_id'] !== $actor
? '仅上传者或超级管理员可删除录音' : Store::deleteBlockReason($task));
return $summary + ['can_delete' => $reason === '', 'delete_block_reason' => $reason];
}
public static function retry(int $id, int $actor, array $info): array
{
self::requireEnabled(true);
@@ -160,7 +177,7 @@ final class FollowupAudioLogic
}
unset($item);
}
return $detail;
return self::withDeleteCapability($detail, $actor);
}
}
@@ -67,12 +67,13 @@ final class FollowupAudioStore
FollowupAudioAccess::diagnosis($diagnosisId, $actor, $info);
$diagnosis = FollowupAudioApply::diagnosis($diagnosisId, true);
$existing = Db::name('followup_audio_task')->where('upload_id', $stored['id'])->lock(true)->find();
if ($existing && (int) ($existing['active_slot'] ?? 0) !== 0) { throw new DomainException('FOLLOWUP_AUDIO_UPLOAD_ALREADY_USED'); }
if ($existing && $existing['model_key'] !== $modelKey) {
throw new DomainException('FOLLOWUP_AUDIO_UPLOAD_ALREADY_USED');
}
// Diagnose+content+profile is immutable even if a retry changes upload ID, filename or recordedAt.
$existing = $existing ?: Db::name('followup_audio_task')->where('diagnosis_id', $diagnosisId)
->where('sha256', $stored['sha256'])->where('model_key', $modelKey)->lock(true)->find();
->where('sha256', $stored['sha256'])->where('model_key', $modelKey)->where('active_slot', 0)->lock(true)->find();
if ($existing) { return self::reuse($existing, $recordedAt, $channelRoles); }
$path = FollowupAudioUpload::path($stored);
if (!is_file($path) || (int) filesize($path) !== (int) $stored['total_bytes']
@@ -102,7 +103,7 @@ final class FollowupAudioStore
// The unique content key is the final arbiter even for a concurrent caller outside this service.
if ((int) ($exception->getData()['PDO Error Info']['Driver Error Code'] ?? 0) !== 1062) { throw $exception; }
$winner = Db::name('followup_audio_task')->where('diagnosis_id', $diagnosisId)
->where('sha256', $stored['sha256'])->where('model_key', $modelKey)->lock(true)->find();
->where('sha256', $stored['sha256'])->where('model_key', $modelKey)->where('active_slot', 0)->lock(true)->find();
if (!$winner) { throw $exception; }
Db::name('followup_audio_upload')->where('id', $stored['id'])->update(['expires_at' => $stored['expires_at']]);
return self::reuse($winner, $recordedAt, $channelRoles);
@@ -136,13 +137,13 @@ final class FollowupAudioStore
public static function task(int $taskId): array
{
$task = Db::name('followup_audio_task')->where('id', $taskId)->find();
if (!$task) { throw new DomainException('FOLLOWUP_AUDIO_TASK_UNAVAILABLE'); }
if (!$task || (int) ($task['active_slot'] ?? 0) !== 0 || $task['status'] === 'deleted') { throw new DomainException('FOLLOWUP_AUDIO_TASK_UNAVAILABLE'); }
return $task;
}
public static function lists(int $diagnosisId): array
{
$rows = Db::name('followup_audio_task')->where('diagnosis_id', $diagnosisId)->order('id', 'desc')->limit(200)->select()->toArray();
$rows = Db::name('followup_audio_task')->where('diagnosis_id', $diagnosisId)->where('active_slot', 0)->order('id', 'desc')->limit(200)->select()->toArray();
return array_map([self::class, 'summary'], $rows);
}
@@ -238,6 +239,54 @@ final class FollowupAudioStore
return $detail;
}
/** Actor-independent deny reasons also guard against a corrupt status hiding an adoption/intent. */
public static function deleteBlockReason(array $task): string
{
if ((int) ($task['active_slot'] ?? 0) !== 0 || $task['status'] === 'deleted') { return '录音已删除'; }
if ($task['status'] === 'applied' || (int) $task['applied_at'] > 0 || $task['applied_cipher'] !== ''
|| Db::name('followup_audio_audit')->where('task_id', $task['id'])->lock(true)->find()) {
return '已采用或已有采用审计的录音须保留';
}
if (!in_array($task['status'], ['review', 'failed'], true) || (int) $task['lease_until'] > time()) {
return '仅已完成且未采用、或确定失败的录音可删除;排队、处理中及待核对任务须先等待或核对';
}
if (!empty($task['pipeline_cipher'])) {
$state = self::pipelineState($task);
if ($state === [] || FollowupAudioPipelineCheckpoint::hasIntent($state)) { return '上游结果尚未确定,须先核对'; }
} elseif ($task['status'] === 'failed' && !self::safeToResume($task)) {
return '上游结果尚未确定,须先核对';
}
return '';
}
/** Soft deletion releases only the live dedup slot. Immutable provenance and retention remain intact. */
public static function delete(int $taskId, int $version, int $actor, array $info): array
{
$connection = Db::connect();
if ($connection->getPdo()->inTransaction()) { throw new DomainException('FOLLOWUP_AUDIO_NESTED_DELETE_FORBIDDEN'); }
$connection->execute('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
return Db::transaction(static function () use ($taskId, $version, $actor, $info): array {
$task = self::lockTask($taskId);
$info = FollowupAudioAccess::actor($actor);
FollowupAudioAccess::task($taskId, $actor, $info);
if ((int) $info['root'] !== 1 && (int) $task['actor_id'] !== $actor) {
throw new DomainException('仅上传者或超级管理员可删除录音');
}
if ((int) $task['version'] !== $version) { throw new DomainException('FOLLOWUP_AUDIO_VERSION_CONFLICT'); }
$reason = self::deleteBlockReason($task);
if ($reason !== '') { throw new DomainException($reason); }
$now = time();
Db::name('followup_audio_task')->where('id', $taskId)->update([
'status' => 'deleted', 'stage' => 'deleted', 'active_slot' => $taskId,
'deleted_at' => $now, 'deleted_by' => $actor,
'deletion_json' => FollowupAudioPolicy::canonical(['previous_status' => $task['status'],
'previous_version' => (int) $task['version'], 'retained_until' => (int) $task['expires_at']]),
'version' => $version + 1, 'updated_at' => $now,
]);
return ['id' => $taskId, 'status' => 'deleted', 'version' => $version + 1];
});
}
public static function retry(int $taskId): array
{
self::assertEnabled();
@@ -454,8 +503,8 @@ final class FollowupAudioStore
if (!(int) $task['purged_at']) {
Db::name('followup_audio_task')->where('id', $id)->update([
'extraction_cipher' => '', 'review_cipher' => '', 'pipeline_cipher' => null, 'file_name' => '已清理录音', 'purged_at' => time(),
'status' => $task['status'] === 'applied' ? 'applied' : 'expired',
'stage' => $task['status'] === 'applied' ? 'applied' : 'expired',
'status' => in_array($task['status'], ['applied', 'deleted'], true) ? $task['status'] : 'expired',
'stage' => in_array($task['status'], ['applied', 'deleted'], true) ? $task['status'] : 'expired',
'lease_token' => '', 'lease_until' => 0, 'updated_at' => time(), 'version' => (int) $task['version'] + 1,
]);
$counts['tasks_purged']++;
@@ -0,0 +1,16 @@
-- Apply AFTER 2026_09_29_followup_audio.sql, BEFORE deploying delete-aware application code.
-- Re-runnable. Preserve original content SHA, upload binding, provider and all encrypted snapshots.
-- Rollback: leave these additive columns/index in place and disable deletion first.
-- Old application code must NOT be restored after tombstones exist: it exposes hidden tasks.
-- Restoring the old unique key is not generally possible after same-content reuploads.
SET @fa_db = DATABASE();
SET @fa_sql = IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=@fa_db AND TABLE_NAME='zyt_followup_audio_task' AND COLUMN_NAME='active_slot')=0,
'ALTER TABLE zyt_followup_audio_task ADD COLUMN active_slot BIGINT UNSIGNED NOT NULL DEFAULT 0, ADD COLUMN deleted_at BIGINT UNSIGNED NOT NULL DEFAULT 0, ADD COLUMN deleted_by BIGINT UNSIGNED NOT NULL DEFAULT 0, ADD COLUMN deletion_json TEXT NULL', 'SELECT 1');
PREPARE fa_stmt FROM @fa_sql;
EXECUTE fa_stmt;
DEALLOCATE PREPARE fa_stmt;
SET @fa_sql = IF((SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=@fa_db AND TABLE_NAME='zyt_followup_audio_task' AND INDEX_NAME='uk_content_profile' AND COLUMN_NAME='active_slot')=0,
'ALTER TABLE zyt_followup_audio_task DROP INDEX uk_content_profile, ADD UNIQUE KEY uk_content_profile (diagnosis_id,sha256,model_key,active_slot)', 'SELECT 1');
PREPARE fa_stmt FROM @fa_sql;
EXECUTE fa_stmt;
DEALLOCATE PREPARE fa_stmt;
+4
View File
@@ -122,6 +122,10 @@ try {
for ($i = 0; $i < 2; $i++) {
foreach (explode(';', $migration) as $statement) { if (trim($statement) !== '') { $stmt = $pdo->query($statement); $stmt->closeCursor(); } }
}
$migration = preg_replace('/^--.*$/m', '', file_get_contents(dirname(__DIR__) . '/sql/2026_10_12_followup_audio_soft_delete.sql'));
for ($i = 0; $i < 2; $i++) {
foreach (explode(';', $migration) as $statement) { if (trim($statement) !== '') { $stmt = $pdo->query($statement); $stmt->closeCursor(); } }
}
$expect((int) Db::name('followup_audio_mutex')->count() === 2, 'Migration is rerunnable');
$expect(count(array_filter(Fields::catalog()['diagnosis'], static fn ($f) => $f['key'] === 'status')) === 0, 'Ownership/status fields are not writable');
$catalog = array_column(Fields::catalog()['diagnosis'], null, 'key');
+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); }
@@ -25,6 +25,8 @@ expectEndpoint(str_contains($access, 'MyPatientLogic::applyScope($query, $actor,
expectEndpoint(str_contains($access, 'tcm.diagnosis/dailyRecord'), 'daily permission guard');
expectEndpoint(str_contains($access, "\$task['patient_id'] !== (int) (\$diagnosis['patient_id']"), 'historical patient rebind guard');
expectEndpoint(str_contains($controller, 'array_diff(array_keys($params), $allowed)'), 'request field allowlist');
expectEndpoint(str_contains($middleware, "'apply', 'retry', 'audio', 'delete'"), 'delete uses explicit edit permission route group');
expectEndpoint(str_contains($controller, "Logic::delete(\$this->positive(\$p, 'id'), \$this->positive(\$p, 'version')"), 'delete requires task and optimistic version');
expectEndpoint(str_contains($controller, 'count($items) > 500'), 'bounded review items');
expectEndpoint(str_contains($controller, "'code' => 'FOLLOWUP_AUDIO_STALE_REVIEW'"), 'typed stale review response');
expectEndpoint(substr_count($controller, 'Logic::requireEnabled(true)') >= 3, 'upload capability gate');
+5
View File
@@ -71,6 +71,11 @@ function followupAudioTestDatabase(array $settings = []): array
foreach (explode(';', $migration) as $statement) { if (trim($statement) !== '') { $stmt = $pdo->query($statement); $stmt->closeCursor(); } }
}
$migration = preg_replace('/^--.*$/m', '', file_get_contents(dirname(__DIR__, 3) . '/sql/2026_10_12_followup_audio_soft_delete.sql'));
for ($i = 0; $i < 2; $i++) {
foreach (explode(';', $migration) as $statement) { if (trim($statement) !== '') { $stmt = $pdo->query($statement); $stmt->closeCursor(); } }
}
return ['pdo' => $pdo, 'database' => $database, 'private' => $private, 'config' => $config,
'actor' => ['root' => 1, 'admin_id' => 1, 'id' => 1, 'name' => 'Synthetic'], 'diagnosis_id' => 1];
}