feat: add scoped read-only audio preview and isolated Dify transport

This commit is contained in:
2026-10-09 16:10:59 +08:00
parent 27772bec61
commit 4d0af7f3f4
36 changed files with 1517 additions and 68 deletions
@@ -18,6 +18,8 @@ final class FollowupAudioStore
return $profile === null ? self::verifiedProfiles() !== [] : FollowupAudioProviderConfig::isVerified($profile);
}
public static function ready(?string $profile = null): bool { return FollowupAudioGate::ready($profile); }
private static function verifiedProfiles(): array
{
return array_values(array_filter(['qwen', 'openai'], [FollowupAudioProviderConfig::class, 'isVerified']));
@@ -43,7 +45,7 @@ final class FollowupAudioStore
public static function assertEnabled(?string $profile = null): void
{
if (!self::enabled() || !self::verified($profile)) { throw new DomainException('FOLLOWUP_AUDIO_DISABLED_OR_UNVERIFIED'); }
if (!self::enabled() || !self::ready($profile)) { throw new DomainException('FOLLOWUP_AUDIO_DISABLED_OR_UNVERIFIED'); }
}
public static function create(array $upload, string $recordedAt, string $modelKey, int $actor, array $info): array
@@ -89,7 +91,7 @@ final class FollowupAudioStore
'lease_token' => '', 'lease_until' => 0, 'upstream_started_at' => 0,
'upstream_run_id' => '', 'upstream_file_id' => '', 'upstream_ids_json' => FollowupAudioPolicy::canonical([
'provider_fingerprint' => FollowupAudioProviderConfig::resolve($modelKey)['fingerprint'],
]),
] + (FollowupAudioGate::previewOnly() ? ['preview_only' => true] : [])),
'error_code' => '', 'error_message' => '', 'extraction_cipher' => '', 'review_cipher' => '', 'applied_cipher' => '',
'created_at' => $now, 'updated_at' => $now, 'expires_at' => $expiresAt, 'applied_at' => 0, 'purged_at' => 0,
]);
@@ -149,7 +151,9 @@ final class FollowupAudioStore
$result['error_code'] = 'FOLLOWUP_AUDIO_EXPIRED';
$result['error_message'] = '录音及审阅已到保留期限,不能采用';
}
$result['can_retry'] = self::enabled() && self::verified((string) $task['model_key']) && $alive && $task['status'] === 'failed'
$result['preview_only'] = FollowupAudioGate::previewOnly() || FollowupAudioGate::taskPreview($task);
$result['can_retry'] = self::enabled() && self::ready((string) $task['model_key'])
&& FollowupAudioGate::scopeAllowed((int) $task['diagnosis_id'], (int) $task['actor_id']) && $alive && $task['status'] === 'failed'
&& self::providerMatches($task) && self::safeToResume($task) && (int) $task['attempts'] < 3;
$pipeline = $alive ? self::pipelineState($task) : [];
$segments = $pipeline ? FollowupAudioPipelineCheckpoint::segments($pipeline) : [];
@@ -233,6 +237,7 @@ final class FollowupAudioStore
$task = self::lockTask($taskId);
self::assertEnabled((string) $task['model_key']);
self::assertTaskProvider($task);
FollowupAudioGate::assertScope((int) $task['diagnosis_id'], (int) $task['actor_id']);
if ($task['status'] !== 'failed' || !self::safeToResume($task) || (int) $task['attempts'] >= 3
|| (int) $task['expires_at'] <= time() || (int) $task['purged_at']) {
throw new DomainException('FOLLOWUP_AUDIO_RETRY_NOT_SAFE');
@@ -272,6 +277,7 @@ final class FollowupAudioStore
->where('expires_at', '>', $now)->where('purged_at', 0)->order('id', 'asc')->limit(200)->lock(true)->select()->toArray();
$task = null;
foreach ($pending as $candidate) {
if (!FollowupAudioGate::scopeAllowed((int) $candidate['diagnosis_id'], (int) $candidate['actor_id'])) { continue; }
if (!self::safeToResume($candidate)) {
Db::name('followup_audio_task')->where('id', $candidate['id'])->update([
'status' => 'needs_reconciliation', 'stage' => 'needs_reconciliation',
@@ -289,7 +295,7 @@ final class FollowupAudioStore
]);
continue;
}
if (self::verified((string) $candidate['model_key'])) { $task = $candidate; break; }
if (self::ready((string) $candidate['model_key'])) { $task = $candidate; break; }
}
if ($task === null) { return null; }
$changes = ['status' => 'running', 'stage' => 'preparing', 'lease_token' => bin2hex(random_bytes(32)),
@@ -339,6 +345,10 @@ final class FollowupAudioStore
if (!is_array($ids)) { throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID'); }
$previous = json_decode($task['upstream_ids_json'] ?: '{}', true, 16, JSON_THROW_ON_ERROR);
foreach ($ids as $name => $identifier) {
if ($name === 'preview_only') {
if (($previous[$name] ?? null) !== true || $identifier !== true) { throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID'); }
continue;
}
if ($name === 'provider_fingerprint') {
if (!is_string($identifier) || !is_string($previous[$name] ?? null)
|| !hash_equals($previous[$name], $identifier)) {
@@ -547,7 +557,8 @@ final class FollowupAudioStore
private static function hasLease(array $task, string $token, bool $processing = true): bool
{
return (!$processing || self::enabled() && self::verified((string) $task['model_key']) && self::providerMatches($task))
return (!$processing || self::enabled() && self::ready((string) $task['model_key']) && self::providerMatches($task)
&& FollowupAudioGate::scopeAllowed((int) $task['diagnosis_id'], (int) $task['actor_id']))
&& $task['status'] === 'running' && $token !== ''
&& hash_equals((string) $task['lease_token'], $token) && (int) $task['lease_until'] > time()
&& (int) $task['expires_at'] > time() && !(int) $task['purged_at'];