feat(followup-audio): soft delete unadopted recordings and expose extraction doubts
This commit is contained in:
@@ -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']++;
|
||||
|
||||
Reference in New Issue
Block a user