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