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
@@ -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']++;