feat: configure audio providers and add local sample acceptance

This commit is contained in:
2026-10-07 18:15:32 +08:00
parent 70be2fc70f
commit fea33285e8
21 changed files with 1103 additions and 131 deletions
+20 -1
View File
@@ -1,5 +1,5 @@
# Merge this section into the target environment's existing .env. No credentials are provided here.
# Existing [prescription_ai] configuration is reused, not replaced.
# Only dify may reuse existing [prescription_ai] base/key. MODEL is required only for openai_audio.
[followup_audio]
ENABLED = false
AUDIO_VERIFIED = false
@@ -14,3 +14,22 @@ FFPROBE = ffprobe
# At least 32 characters; keep secret, stable, backed up and identical on web/worker nodes.
# Empty uses the existing prescription_analysis key; never rotate without a data re-encryption plan.
ENCRYPTION_KEY =
# Never enable HTTP for real audio. This switch applies only to the synthetic CLI probe.
ALLOW_INSECURE_SYNTHETIC = false
# Supported drivers: dify, openai_audio. openai_audio MODEL is sent in the request.
# Dify selects an application with its key; MODEL does not change the model inside that app.
QWEN_DRIVER = dify
QWEN_BASE_URL =
QWEN_API_KEY =
QWEN_MODEL =
QWEN_LABEL = qwen
QWEN_VERIFIED_FINGERPRINT =
OPENAI_DRIVER = dify
OPENAI_BASE_URL =
OPENAI_API_KEY =
OPENAI_MODEL =
OPENAI_LABEL = openai
OPENAI_VERIFIED_FINGERPRINT =
# Fingerprints must come from all three passing synthetic gates for this exact driver/URL/model/key.
# Any identity change invalidates prior verification; unknown prior requests still require reconciliation.
@@ -7,6 +7,7 @@ namespace app\adminapi\logic\tcm;
use app\common\service\followupaudio\FollowupAudioAccess as Access;
use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\service\followupaudio\FollowupAudioFields as Fields;
use app\common\service\followupaudio\FollowupAudioProviderConfig as ProviderConfig;
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
use DomainException;
@@ -23,10 +24,7 @@ final class FollowupAudioLogic
'enabled' => $enabled, 'audio_verified' => $verified,
'can_upload' => $enabled && $verified, 'can_apply' => $enabled && $verified,
'can_daily' => $daily, 'limits' => Upload::limits(),
'models' => array_values(array_filter(
[['value' => 'qwen', 'label' => '千问'], ['value' => 'openai', 'label' => 'OpenAI']],
static fn (array $model): bool => Store::verified($model['value'])
)),
'models' => ProviderConfig::publicModels(),
// No migration/dictionary dependency is introduced when the feature is OFF.
'fields' => $enabled ? self::catalogForActor($diagnosis, $actor, $info) : [],
];
@@ -38,7 +36,7 @@ final class FollowupAudioLogic
throw new DomainException('回访录音功能尚未启用');
}
if ($verified && !Store::verified()) {
throw new DomainException('Dify 音频能力尚未验证,暂不接受真实录音或写入');
throw new DomainException('当前服务与模型的音频能力尚未验证,暂不接受真实录音或写入');
}
}
+38 -5
View File
@@ -6,6 +6,7 @@ namespace app\command;
use app\common\service\followupaudio\FollowupAudioDify;
use app\common\service\followupaudio\FollowupAudioException;
use app\common\service\followupaudio\FollowupAudioProviderConfig;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
@@ -19,7 +20,7 @@ final class FollowupAudioProbe extends Command
$this->setName('followup-audio:probe')->setDescription('只对脚本生成的合成短/15分钟/1小时音频做能力验证;不会自动开启功能')
->addOption('synthetic', null, Option::VALUE_NONE, '明确确认只使用合成数据')
->addOption('manifest', null, Option::VALUE_REQUIRED, 'generate_followup_audio_fixtures.py 输出的 manifest.json')
->addOption('profile', null, Option::VALUE_REQUIRED, '现有 prescription_ai 配置 qwen/openai', 'qwen')
->addOption('profile', null, Option::VALUE_REQUIRED, '服务端 followup_audio 配置的 qwen/openai 逻辑槽位', 'qwen')
->addOption('case', null, Option::VALUE_REQUIRED, 'all/short/medium/long', 'all');
}
@@ -39,20 +40,43 @@ final class FollowupAudioProbe extends Command
}
$manifest = json_decode((string) file_get_contents($path), true, 32, JSON_THROW_ON_ERROR);
$fixtures = self::validateManifest($manifest, dirname($path));
$reportPath = dirname($path) . '/probe-' . $profile . '.json';
$dify = new FollowupAudioDify();
$configuration = $dify->configurationStatus($profile);
$fingerprint = (string) ($configuration['application_fingerprint'] ?? '');
if ($fingerprint !== '' && !preg_match('/^[a-f0-9]{64}$/D', $fingerprint)) {
throw new FollowupAudioException('PROBE_IDENTITY_CHANGED');
}
// Identity-specific evidence never overwrites a previous app or protocol's probe report.
$reportPath = dirname($path) . '/probe-' . $profile . '-' . ($fingerprint !== '' ? $fingerprint : 'unconfigured') . '.json';
$legacyPath = dirname($path) . '/probe-' . $profile . '.json';
if (is_file($legacyPath)) {
$legacy = json_decode((string) file_get_contents($legacyPath), true, 32, JSON_THROW_ON_ERROR);
$legacyFingerprint = (string) ($legacy['configuration']['application_fingerprint'] ?? '');
if (self::sameLegacyDifyApplication($profile, $legacyFingerprint) && array_filter((array) ($legacy['cases'] ?? []),
static fn (array $row): bool => !empty($row['upstream_started_at']) && ($row['status'] ?? '') !== 'passed')) {
// Upgrading the identity algorithm must not turn an old billable unknown into a new request.
$output->writeln('FOLLOWUP_AUDIO_PROBE ' . json_encode(['status' => 'needs_reconciliation',
'code' => 'NO_RESUBMISSION', 'report' => $legacyPath]));
return 2;
}
if (!is_file($reportPath) && $fingerprint !== '' && hash_equals($fingerprint, $legacyFingerprint)) {
$reportPath = $legacyPath; // Same-identity unknown/passed entries remain authoritative.
} elseif ($legacyFingerprint === '' && array_filter((array) ($legacy['cases'] ?? []),
static fn (array $row): bool => !empty($row['upstream_started_at']))) {
throw new FollowupAudioException('PROBE_IDENTITY_UNBOUND');
}
}
$lock = fopen($reportPath . '.lock', 'c+b');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) { throw new FollowupAudioException('PROBE_ALREADY_RUNNING'); }
@chmod($reportPath . '.lock', 0600);
$hash = hash_file('sha256', $path);
$report = is_file($reportPath) ? json_decode((string) file_get_contents($reportPath), true, 32, JSON_THROW_ON_ERROR) : [
'schema_version' => 'followup-audio-probe-v1', 'synthetic' => true,
'schema_version' => 'followup-audio-probe-v2', 'synthetic' => true,
'manifest_sha256' => $hash, 'profile' => $profile, 'cases' => [],
];
if (!is_array($report) || ($report['manifest_sha256'] ?? '') !== $hash || ($report['profile'] ?? '') !== $profile) {
throw new FollowupAudioException('PROBE_IDENTITY_CHANGED');
}
$dify = new FollowupAudioDify();
$configuration = $dify->configurationStatus($profile);
if (!empty($report['configuration']['application_fingerprint'])
&& ($report['configuration']['application_fingerprint'] !== ($configuration['application_fingerprint'] ?? ''))) {
throw new FollowupAudioException('PROBE_APPLICATION_CHANGED');
@@ -111,6 +135,15 @@ final class FollowupAudioProbe extends Command
}
}
private static function sameLegacyDifyApplication(string $profile, string $fingerprint): bool
{
if (!preg_match('/^[a-f0-9]{64}$/D', $fingerprint)) { return false; }
try { $provider = FollowupAudioProviderConfig::resolve($profile); }
catch (FollowupAudioException $error) { return false; }
return $provider['driver'] === 'dify'
&& hash_equals(hash('sha256', $provider['base_url'] . "\0" . $provider['api_key']), $fingerprint);
}
private static function validateManifest($manifest, string $directory): array
{
if (!is_array($manifest) || ($manifest['generator'] ?? '') !== 'followup-audio-synthetic-v1'
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace app\common\service\followupaudio;
/** Dedicated, fail-closed local_file audio transport; intentionally does not use DifyChatService. */
/** Fail-closed audio transport; explicit Dify/OpenAI driver, never text or provider fallback. */
final class FollowupAudioDify
{
private array $settings;
@@ -24,7 +24,7 @@ final class FollowupAudioDify
if (empty($this->settings['enabled'])) {
throw new FollowupAudioException('FEATURE_DISABLED');
}
if (empty($this->settings['audio_verified']) || !in_array((string) ($task['model_key'] ?? ''), (array) ($this->settings['verified_profiles'] ?? []), true)) {
if (!FollowupAudioProviderConfig::verified((string) ($task['model_key'] ?? ''), $this->settings, $this->provider)) {
throw new FollowupAudioException('AUDIO_NOT_VERIFIED');
}
if ((int) ($task['upstream_started_at'] ?? 0) > 0) {
@@ -53,25 +53,28 @@ final class FollowupAudioDify
'upload_id' => 'synthetic', 'sha256' => $fixture['sha256'],
'recorded_at' => '2026-09-29 10:00:00', 'model_key' => $profile,
'duration_seconds' => (float) ($fixture['duration_seconds'] ?? 0),
], $heartbeat);
], $heartbeat, true);
}
/** Returns status and an irreversible app-identity fingerprint, never configuration values/credentials. */
public function configurationStatus(string $profile): array
{
try {
[$base, $key] = $this->resolveProfile($profile);
return ['configured' => true, 'profile' => $profile, 'code' => 'OK',
'application_fingerprint' => hash('sha256', $base . "\0" . $key)];
} catch (FollowupAudioException $e) {
return ['configured' => false, 'profile' => in_array($profile, ['qwen', 'openai'], true) ? $profile : 'invalid',
'code' => $e->errorCode];
}
return FollowupAudioProviderConfig::status($profile, $this->settings, $this->provider);
}
private function analyzeFile(string $path, array $task, callable $heartbeat): array
private function analyzeFile(string $path, array $task, callable $heartbeat, bool $synthetic = false): array
{
[$base, $key, $timeout] = $this->resolveProfile((string) ($task['model_key'] ?? ''));
[$base, $key, $timeout, $provider] = $this->resolveProfile((string) ($task['model_key'] ?? ''));
if (!str_starts_with($base, 'https://') && (!$synthetic || empty($this->settings['allow_insecure_synthetic']))) {
throw new FollowupAudioException('HTTPS_REQUIRED');
}
if (!$synthetic) {
$snapshot = json_decode((string) ($task['upstream_ids_json'] ?? ''), true);
if (!is_array($snapshot) || !is_string($snapshot['provider_fingerprint'] ?? null)
|| !hash_equals($provider['fingerprint'], $snapshot['provider_fingerprint'])) {
throw new FollowupAudioException('PROVIDER_CONFIGURATION_CHANGED');
}
}
$audio = $this->inspectAudio($path, (string) ($task['sha256'] ?? ''));
if (isset($task['duration_seconds']) && abs((float) $task['duration_seconds'] - $audio['duration']) > 1.0) {
throw new FollowupAudioException('AUDIO_INVALID');
@@ -81,36 +84,52 @@ final class FollowupAudioDify
if (!$date || $date->format('Y-m-d H:i:s') !== $recordedAt) {
throw new FollowupAudioException('RECORDED_AT_INVALID');
}
$processing = $this->prepareAudio($audio, $heartbeat);
$processing = $this->prepareAudio($audio, $heartbeat, $provider['driver'] === 'openai_audio');
try {
$query = $this->prompt($recordedAt, $audio['duration']);
$ids = ['request_id' => 'fa-' . bin2hex(random_bytes(16))];
$ids = ['request_id' => 'fa-' . bin2hex(random_bytes(16)), 'provider_fingerprint' => $provider['fingerprint']];
$user = 'followup-audio-' . substr(hash('sha256', (string) ($task['id'] ?? '') . ':' . $ids['request_id']), 0, 32);
// Persist intent BEFORE any network side effect, including upload. A crashed worker cannot resend.
$this->checkpoint($heartbeat, ['stage' => 'uploading', 'upstream_started_at' => time(),
'upstream_ids_json' => json_encode($ids, JSON_THROW_ON_ERROR)], false);
$uploaded = $this->request([
'url' => $base . '/files/upload', 'api_key' => $key, 'timeout' => $timeout, 'request_ids' => $ids,
'multipart' => ['user' => $user, 'file' => new \CURLFile($processing['path'], $processing['mime'], 'followup-audio.' . $processing['extension'])],
], $heartbeat);
$fileId = $this->identifier($uploaded['id'] ?? null);
if ($fileId === '') {
throw new FollowupAudioException('UPSTREAM_UPLOAD_INVALID', true);
if ($provider['driver'] === 'openai_audio') {
// Build and validate the complete request before persisting the single billable intent.
$payload = $this->openAiPayload($processing, $query, $provider['model']);
$this->checkpoint($heartbeat, ['stage' => 'analyzing', 'upstream_started_at' => time(),
'upstream_ids_json' => json_encode($ids, JSON_THROW_ON_ERROR)], false);
$response = $this->request(['url' => $base . '/chat/completions', 'api_key' => $key,
'timeout' => $timeout, 'json' => $payload, 'request_ids' => $ids], $heartbeat);
$response['message_id'] = $this->identifier($response['id'] ?? null);
$choices = is_array($response['choices'] ?? null) ? $response['choices'] : [];
$choice = is_array($choices[0] ?? null) ? $choices[0] : [];
$choice['message'] = is_array($choice['message'] ?? null) ? $choice['message'] : [];
$response['answer'] = count($choices) === 1
&& ($choice['finish_reason'] ?? '') === 'stop' && empty($choice['message']['refusal'])
&& empty($choice['message']['tool_calls']) ? ($choice['message']['content'] ?? null) : null;
} else {
// Persist intent BEFORE any network side effect, including upload. A crashed worker cannot resend.
$this->checkpoint($heartbeat, ['stage' => 'uploading', 'upstream_started_at' => time(),
'upstream_ids_json' => json_encode($ids, JSON_THROW_ON_ERROR)], false);
$uploaded = $this->request([
'url' => $base . '/files/upload', 'api_key' => $key, 'timeout' => $timeout, 'request_ids' => $ids,
'multipart' => ['user' => $user, 'file' => new \CURLFile($processing['path'], $processing['mime'], 'followup-audio.' . $processing['extension'])],
], $heartbeat);
$fileId = $this->identifier($uploaded['id'] ?? null);
if ($fileId === '') {
throw new FollowupAudioException('UPSTREAM_UPLOAD_INVALID', true);
}
// Preserve the observed file ID even if the upstream mislabeled/rejected its media type.
$this->checkpoint($heartbeat, ['stage' => 'analyzing', 'upstream_file_id' => $fileId], true);
if (isset($uploaded['mime_type']) && (!is_string($uploaded['mime_type']) || !str_starts_with($uploaded['mime_type'], 'audio/'))) {
throw new FollowupAudioException('UPSTREAM_AUDIO_REJECTED');
}
$payload = [
'inputs' => new \stdClass(),
'query' => $query,
'response_mode' => 'blocking', 'user' => $user, 'auto_generate_name' => false,
'files' => [['type' => 'audio', 'transfer_method' => 'local_file', 'upload_file_id' => $fileId]],
];
self::assertAudioPayload($payload, $fileId);
$response = $this->request(['url' => $base . '/chat-messages', 'api_key' => $key,
'timeout' => $timeout, 'json' => $payload, 'request_ids' => $ids], $heartbeat);
}
// Preserve the observed file ID even if the upstream mislabeled/rejected its media type.
$this->checkpoint($heartbeat, ['stage' => 'analyzing', 'upstream_file_id' => $fileId], true);
if (isset($uploaded['mime_type']) && (!is_string($uploaded['mime_type']) || !str_starts_with($uploaded['mime_type'], 'audio/'))) {
throw new FollowupAudioException('UPSTREAM_AUDIO_REJECTED');
}
$payload = [
'inputs' => new \stdClass(),
'query' => $query,
'response_mode' => 'blocking', 'user' => $user, 'auto_generate_name' => false,
'files' => [['type' => 'audio', 'transfer_method' => 'local_file', 'upload_file_id' => $fileId]],
];
self::assertAudioPayload($payload, $fileId);
$response = $this->request(['url' => $base . '/chat-messages', 'api_key' => $key,
'timeout' => $timeout, 'json' => $payload, 'request_ids' => $ids], $heartbeat);
foreach (['task_id', 'message_id', 'conversation_id', 'upstream_request_id'] as $name) {
$id = $this->identifier($response[$name] ?? null);
if ($id !== '') { $ids[$name] = $id; }
@@ -156,38 +175,25 @@ final class FollowupAudioDify
private function resolveProfile(string $profile): array
{
if (!in_array($profile, ['qwen', 'openai'], true)) {
throw new FollowupAudioException('INVALID_PROFILE');
}
$base = rtrim((string) ($this->provider['base_url'] ?? ''), '/');
$key = (string) ($this->provider['models'][$profile]['api_key'] ?? '');
if ($base === '' || trim($key) === '') {
throw new FollowupAudioException('CONFIG_MISSING');
}
$parts = parse_url($base);
if (!is_array($parts) || !in_array($parts['scheme'] ?? '', ['https', 'http'], true)
|| empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query'])
|| isset($parts['fragment']) || preg_match('/[\x00-\x20\x7f]/', $base)
|| preg_match('/[\x00-\x20\x7f]/', $key)) {
throw new FollowupAudioException('CONFIG_INVALID');
}
$path = (string) ($parts['path'] ?? '');
if (str_ends_with($path, '/chat/completions')) {
throw new FollowupAudioException('DIFY_APPLICATION_REQUIRED');
}
if (str_ends_with($path, '/chat-messages')) {
$base = substr($base, 0, -strlen('/chat-messages'));
} elseif (!str_ends_with($path, '/v1')) {
$base .= '/v1';
}
$provider = FollowupAudioProviderConfig::resolve($profile, $this->settings, $this->provider);
$timeout = (int) ($this->settings['request_timeout'] ?? 240);
if ($timeout < 1 || $timeout > 300) {
throw new FollowupAudioException('CONFIG_INVALID');
if ($timeout < 1 || $timeout > 300) { throw new FollowupAudioException('CONFIG_INVALID'); }
if (!function_exists('curl_init')) { throw new FollowupAudioException('CURL_UNAVAILABLE'); }
return [$provider['base_url'], $provider['api_key'], $timeout, $provider];
}
/** Only MP3/WAV input_audio is supported, with bytes from the verified complete processing copy. */
private function openAiPayload(array $audio, string $query, string $model): array
{
if (!in_array($audio['extension'], ['mp3', 'wav'], true)) { throw new FollowupAudioException('AUDIO_INVALID'); }
$bytes = file_get_contents($audio['path']);
if (!is_string($bytes) || $bytes === '' || strlen($bytes) > (int) ($this->settings['upstream_max_bytes'] ?? 20971520)) {
throw new FollowupAudioException('UPSTREAM_AUDIO_LIMIT');
}
if (!function_exists('curl_init')) {
throw new FollowupAudioException('CURL_UNAVAILABLE');
}
return [$base, $key, $timeout];
return ['model' => $model, 'stream' => false, 'messages' => [['role' => 'user', 'content' => [
['type' => 'text', 'text' => $query],
['type' => 'input_audio', 'input_audio' => ['data' => base64_encode($bytes), 'format' => $audio['extension']]],
]]]];
}
private function inspectAudio(string $path, string $sha256, bool $processing = false): array
@@ -240,7 +246,7 @@ final class FollowupAudioDify
}
/** Preserve the exact original. Only a private, bounded audio copy may be sent to the same Dify app. */
private function prepareAudio(array $audio, callable $heartbeat): array
private function prepareAudio(array $audio, callable $heartbeat, bool $openAi = false): array
{
// Dify has a separate audio-upload ceiling (default 50 MiB); use a conservative 20 MiB local budget.
// Deployment must verify its own app/model limits via the synthetic probe before enabling a profile.
@@ -249,7 +255,8 @@ final class FollowupAudioDify
if ($limit < 1 || $limit > 52428800 || $timeout < 1 || $timeout > 600) {
throw new FollowupAudioException('CONFIG_INVALID');
}
if (filesize($audio['path']) <= $limit && $audio['extension'] !== 'amr') { return $audio; }
if (filesize($audio['path']) <= $limit && $audio['extension'] !== 'amr'
&& (!$openAi || in_array($audio['extension'], ['mp3', 'wav'], true))) { return $audio; }
// 32 kbit/s mono speech preserves the entire hour within ~14.5 MB, without splitting model requests.
if ($audio['duration'] * 4000 + 2048 > $limit) {
throw new FollowupAudioException('UPSTREAM_AUDIO_LIMIT');
@@ -416,8 +423,9 @@ final class FollowupAudioDify
$http = (int) ($response['http_code'] ?? 0);
// Even failed/uncertain replies can contain a billable task ID. Persist it before raising a sanitized error.
$candidate = json_decode((string) ($response['body'] ?? ''), true);
if (is_array($candidate) && isset($candidate['id'], $spec['json']['messages'])) { $candidate['message_id'] = $candidate['id']; }
if ((int) ($response['errno'] ?? 0) !== 0 || $http < 200 || $http >= 300
|| !is_array($candidate) || !empty($candidate['code']) || ($candidate['event'] ?? '') === 'error') {
|| !is_array($candidate) || !empty($candidate['code']) || !empty($candidate['error']) || ($candidate['event'] ?? '') === 'error') {
$observed = $spec['request_ids'] ?? [];
foreach (['task_id', 'message_id', 'conversation_id'] as $name) {
$id = $this->identifier($candidate[$name] ?? null);
@@ -441,7 +449,7 @@ final class FollowupAudioDify
try { $body = json_decode((string) ($response['body'] ?? ''), true, 64, JSON_THROW_ON_ERROR); }
catch (\Throwable $e) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
if (!is_array($body)) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
if (!empty($body['code']) || ($body['event'] ?? '') === 'error') {
if (!empty($body['code']) || !empty($body['error']) || ($body['event'] ?? '') === 'error') {
throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true);
}
$requestId = $this->identifier($response['request_id'] ?? null);
@@ -15,14 +15,16 @@ final class FollowupAudioException extends \RuntimeException
$this->errorCode = $errorCode;
$this->uncertain = $uncertain;
$messages = [
'CONFIG_MISSING' => '当前 Dify 应用凭据未配置,音频能力尚未验证',
'CONFIG_MISSING' => '当前音频模型服务未完整配置,音频能力尚未验证',
'HTTPS_REQUIRED' => '真实音频只允许通过有效 HTTPS 服务传输',
'PROVIDER_CONFIGURATION_CHANGED' => '音频模型配置已变化或缺少绑定,任务未发送,请重新核对',
'FEATURE_DISABLED' => '随访音频功能未启用',
'AUDIO_NOT_VERIFIED' => '当前应用尚未通过音频能力验证',
'AUDIO_NOT_PROCESSED' => '上游未确认读取原始音频,未生成可采用结果',
'UPSTREAM_UNCERTAIN' => '上游结果未知,请先核对任务或计费,禁止重复提交',
'RECONCILIATION_REQUIRED' => '任务已有上游请求,须先人工核对,禁止重复提交',
'UPSTREAM_SCHEMA_INVALID' => '上游未返回完整、可核验的结构化音频事实',
'UPSTREAM_AUDIO_REJECTED' => '当前 Dify 应用拒绝音频附件,未降级为纯文本',
'UPSTREAM_AUDIO_REJECTED' => '当前模型服务拒绝音频附件,未降级为纯文本',
'LEASE_LOST' => '任务租约或访问权限已失效',
'ACCESS_REVOKED' => '执行权限已撤销',
'UPSTREAM_AUDIO_LIMIT' => '录音处理副本仍超出已配置的上游限制,未发送,请联系管理员',
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace app\common\service\followupaudio;
/** Server-only provider identity. Changing any request identity invalidates the audio gate. */
final class FollowupAudioProviderConfig
{
public static function resolve(string $profile, ?array $settings = null, ?array $legacy = null): array
{
if (!in_array($profile, ['qwen', 'openai'], true)) { throw new FollowupAudioException('INVALID_PROFILE'); }
$settings = $settings ?? (array) config('followup_audio', []);
$legacy = $legacy ?? (array) config('prescription_ai', []);
$provider = (array) ($settings['providers'][$profile] ?? []);
$driver = (string) ($provider['driver'] ?? 'dify');
if (!in_array($driver, ['dify', 'openai_audio'], true)) { throw new FollowupAudioException('CONFIG_INVALID'); }
$base = (string) ($provider['base_url'] ?? '');
$key = (string) ($provider['api_key'] ?? '');
if ($driver === 'dify') {
if ($base === '') { $base = (string) ($legacy['base_url'] ?? ''); }
if ($key === '') { $key = (string) ($legacy['models'][$profile]['api_key'] ?? ''); }
}
$model = (string) ($provider['model'] ?? '');
$label = trim((string) ($provider['label'] ?? ''));
if ($label === '') { $label = $model !== '' ? $model : $profile; }
if ($base === '' || $key === '' || ($driver === 'openai_audio' && $model === '')) { throw new FollowupAudioException('CONFIG_MISSING'); }
$parts = parse_url($base);
if (!is_array($parts) || !in_array($parts['scheme'] ?? '', ['http', 'https'], true)
|| empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])
|| preg_match('/[\x00-\x20\x7f\\\\]/', $base) || preg_match('/[\x00-\x20\x7f]/', $key)
|| strlen($model) > 200 || preg_match('/[\x00-\x20\x7f]/', $model)
|| trim($label) === '' || strlen($label) > 200 || preg_match('/[\x00-\x1f\x7f]/', $label)) {
throw new FollowupAudioException('CONFIG_INVALID');
}
$base = rtrim($base, '/');
$path = rtrim((string) ($parts['path'] ?? ''), '/');
if ($driver === 'dify' && str_ends_with($path, '/chat/completions')) {
throw new FollowupAudioException('DIFY_APPLICATION_REQUIRED');
}
if ($driver === 'openai_audio' && str_ends_with($path, '/chat-messages')) {
throw new FollowupAudioException('CONFIG_INVALID');
}
$endpoint = $driver === 'dify' ? '/chat-messages' : '/chat/completions';
if (str_ends_with($base, $endpoint)) { $base = substr($base, 0, -strlen($endpoint)); }
elseif (!str_ends_with($base, '/v1')) { $base .= '/v1'; }
$fingerprint = hash('sha256', json_encode([$driver, $base, $model, $key], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
return ['driver' => $driver, 'base_url' => $base, 'api_key' => $key, 'model' => $model, 'label' => $label, 'fingerprint' => $fingerprint];
}
/** No endpoint, model credential or plaintext provider data in capability responses. */
public static function status(string $profile, ?array $settings = null, ?array $legacy = null): array
{
try {
$provider = self::resolve($profile, $settings, $legacy);
return ['configured' => true, 'profile' => $profile, 'code' => 'OK', 'application_fingerprint' => $provider['fingerprint']];
} catch (FollowupAudioException $error) {
return ['configured' => false, 'profile' => in_array($profile, ['qwen', 'openai'], true) ? $profile : 'invalid', 'code' => $error->errorCode];
}
}
public static function isVerified(string $profile): bool
{
return self::verified($profile, (array) config('followup_audio', []), (array) config('prescription_ai', []));
}
/** Shared with injected-config transport tests; enabled remains an independent operational switch. */
public static function verified(string $profile, array $settings, array $legacy): bool
{
if (empty($settings['audio_verified']) || !in_array($profile, (array) ($settings['verified_profiles'] ?? []), true)) { return false; }
try { $provider = self::resolve($profile, $settings, $legacy); }
catch (FollowupAudioException $error) { return false; }
$verified = (string) ($settings['providers'][$profile]['verified_fingerprint'] ?? '');
return str_starts_with($provider['base_url'], 'https://') && preg_match('/^[a-f0-9]{64}$/D', $verified)
&& hash_equals($provider['fingerprint'], $verified);
}
public static function publicModels(): array
{
$models = [];
foreach (['qwen', 'openai'] as $profile) {
if (self::isVerified($profile)) { $models[] = ['value' => $profile, 'label' => self::resolve($profile)['label']]; }
}
return $models;
}
}
@@ -14,13 +14,30 @@ final class FollowupAudioStore
public static function enabled(): bool { return (bool) config('followup_audio.enabled', false); }
public static function verified(?string $profile = null): bool
{
$profiles = self::verifiedProfiles();
return (bool) config('followup_audio.audio_verified', false) && ($profile === null ? $profiles !== [] : in_array($profile, $profiles, true));
return $profile === null ? self::verifiedProfiles() !== [] : FollowupAudioProviderConfig::isVerified($profile);
}
private static function verifiedProfiles(): array
{
return array_values(array_intersect(['qwen', 'openai'], (array) config('followup_audio.verified_profiles', [])));
return array_values(array_filter(['qwen', 'openai'], [FollowupAudioProviderConfig::class, 'isVerified']));
}
/** A task binds to the exact provider/endpoint/model/key identity approved when it was created. */
public static function providerMatches(array $task): bool
{
$ids = json_decode((string) ($task['upstream_ids_json'] ?? '{}'), true);
$fingerprint = is_array($ids) ? ($ids['provider_fingerprint'] ?? '') : '';
if (!is_string($fingerprint) || !preg_match('/^[a-f0-9]{64}$/D', $fingerprint)) { return false; }
$status = FollowupAudioProviderConfig::status((string) ($task['model_key'] ?? ''));
return !empty($status['configured']) && is_string($status['application_fingerprint'] ?? null)
&& hash_equals($fingerprint, $status['application_fingerprint']);
}
public static function assertTaskProvider(array $task): void
{
if (!self::providerMatches($task)) {
throw new FollowupAudioException('PROVIDER_CONFIGURATION_CHANGED', (int) ($task['upstream_started_at'] ?? 0) > 0);
}
}
public static function assertEnabled(?string $profile = null): void
@@ -69,7 +86,9 @@ final class FollowupAudioStore
'duration_seconds' => $stored['duration_seconds'], 'recorded_at' => $recordedAt, 'model_key' => $modelKey,
'status' => 'queued', 'stage' => 'queued', 'version' => 1, 'attempts' => 0,
'lease_token' => '', 'lease_until' => 0, 'upstream_started_at' => 0,
'upstream_run_id' => '', 'upstream_file_id' => '', 'upstream_ids_json' => '{}',
'upstream_run_id' => '', 'upstream_file_id' => '', 'upstream_ids_json' => FollowupAudioPolicy::canonical([
'provider_fingerprint' => FollowupAudioProviderConfig::resolve($modelKey)['fingerprint'],
]),
'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,
]);
@@ -91,6 +110,9 @@ final class FollowupAudioStore
private static function reuse(array $task, string $recordedAt): array
{
$message = '同一诊单、录音内容及模型已有任务,已复用原任务,不会再次调用模型。';
if (!self::providerMatches($task)) {
$message .= '原任务的服务配置已变更或缺少配置快照,不能自动改用新服务重跑。';
}
if ($task['recorded_at'] !== $recordedAt) {
$message .= '沿用原任务的录音时间,请在未采用的审阅项中更正记录日期;已采用任务不会重跑。';
}
@@ -127,7 +149,7 @@ final class FollowupAudioStore
$result['error_message'] = '录音及审阅已到保留期限,不能采用';
}
$result['can_retry'] = self::enabled() && self::verified((string) $task['model_key']) && $alive && $task['status'] === 'failed'
&& (int) $task['upstream_started_at'] === 0 && (int) $task['attempts'] < 3;
&& self::providerMatches($task) && (int) $task['upstream_started_at'] === 0 && (int) $task['attempts'] < 3;
$result['audio_available'] = $alive && (string) Db::name('followup_audio_upload')->where('id', $task['upload_id'])->value('status') === 'complete';
return $result;
}
@@ -192,6 +214,7 @@ final class FollowupAudioStore
Db::transaction(static function () use ($taskId): void {
$task = self::lockTask($taskId);
self::assertEnabled((string) $task['model_key']);
self::assertTaskProvider($task);
if ($task['status'] !== 'failed' || (int) $task['upstream_started_at'] !== 0 || (int) $task['attempts'] >= 3
|| (int) $task['expires_at'] <= time() || (int) $task['purged_at']) {
throw new DomainException('FOLLOWUP_AUDIO_RETRY_NOT_SAFE');
@@ -207,7 +230,7 @@ final class FollowupAudioStore
/** A singleton DB mutex enforces concurrency across independent CLI processes. */
public static function claim(): ?array
{
if (!self::enabled() || !self::verified()) { return null; }
if (!self::enabled()) { return null; }
return Db::transaction(static function (): ?array {
if (!Db::name('followup_audio_mutex')->where('id', 1)->lock(true)->find()) {
throw new DomainException('FOLLOWUP_AUDIO_MIGRATION_REQUIRED');
@@ -227,9 +250,22 @@ final class FollowupAudioStore
$active = Db::name('followup_audio_task')->where('status', 'running')->where('lease_until', '>', $now)
->field('id')->lock(true)->select()->toArray();
if (count($active) >= max(1, min(8, (int) config('followup_audio.concurrency', 1)))) { return null; }
$task = Db::name('followup_audio_task')->where('status', 'queued')->where('upstream_started_at', 0)->whereIn('model_key', self::verifiedProfiles())
->where('expires_at', '>', $now)->where('purged_at', 0)->order('id', 'asc')->lock(true)->find();
if (!$task) { return null; }
$pending = Db::name('followup_audio_task')->where('status', 'queued')->where('upstream_started_at', 0)
->where('expires_at', '>', $now)->where('purged_at', 0)->order('id', 'asc')->limit(200)->lock(true)->select()->toArray();
$task = null;
foreach ($pending as $candidate) {
if (!self::providerMatches($candidate)) {
// Old rows without a binding and changed configurations are visible failures, never silently re-routed.
Db::name('followup_audio_task')->where('id', $candidate['id'])->update([
'status' => 'failed', 'stage' => 'failed', 'error_code' => 'PROVIDER_CONFIGURATION_CHANGED',
'error_message' => '任务服务配置已变更或缺少配置快照,已停止处理;恢复原配置并核对后再操作',
'updated_at' => $now, 'version' => (int) $candidate['version'] + 1,
]);
continue;
}
if (self::verified((string) $candidate['model_key'])) { $task = $candidate; break; }
}
if ($task === null) { return null; }
$changes = ['status' => 'running', 'stage' => 'preparing', 'lease_token' => bin2hex(random_bytes(32)),
'lease_until' => $now + self::leaseSeconds(), 'attempts' => (int) $task['attempts'] + 1,
'updated_at' => $now, 'version' => (int) $task['version'] + 1];
@@ -269,6 +305,13 @@ 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 === 'provider_fingerprint') {
if (!is_string($identifier) || !is_string($previous[$name] ?? null)
|| !hash_equals($previous[$name], $identifier)) {
throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID');
}
continue; // The immutable task snapshot is retained, never replaced by a callback.
}
if (!in_array($name, ['request_id', 'task_id', 'message_id', 'conversation_id', 'upstream_request_id'], true)) {
throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID');
}
@@ -313,13 +356,17 @@ final class FollowupAudioStore
{
return Db::transaction(static function () use ($id, $token, $code, $uncertain): bool {
$task = self::lockTask($id);
if (!self::hasLease($task, $token)) { return false; }
if (!self::hasLease($task, $token, false)) { return false; }
// An arbitrary exception/message can contain patient text or credentials: never persist it.
$code = preg_match('/^[A-Z][A-Z0-9_]{2,95}$/D', $code) ? $code : 'FOLLOWUP_AUDIO_PROCESS_FAILED';
$uncertain = $uncertain || (int) $task['upstream_started_at'] > 0;
$status = $uncertain ? 'needs_reconciliation' : 'failed';
Db::name('followup_audio_task')->where('id', $id)->update([
'status' => $status, 'stage' => $status, 'error_code' => $code,
'error_message' => $uncertain ? '上游结果待核对,禁止重复提交' : '处理未完成,请查看错误代码;不会自动重复调用',
'error_message' => $uncertain ? '上游结果待核对,禁止重复提交'
: ($code === 'PROVIDER_CONFIGURATION_CHANGED'
? '任务服务配置已变更或缺少配置快照,已停止处理;恢复原配置并核对后再操作'
: '处理未完成,请查看错误代码;不会自动重复调用'),
'lease_token' => '', 'lease_until' => 0, 'updated_at' => time(), 'version' => (int) $task['version'] + 1,
]);
return true;
@@ -422,9 +469,10 @@ final class FollowupAudioStore
return new PrescriptionAiCipher($key === '' ? null : $key);
}
private static function hasLease(array $task, string $token): bool
private static function hasLease(array $task, string $token, bool $processing = true): bool
{
return self::enabled() && self::verified((string) $task['model_key']) && $task['status'] === 'running' && $token !== ''
return (!$processing || self::enabled() && self::verified((string) $task['model_key']) && self::providerMatches($task))
&& $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'];
}
@@ -17,15 +17,17 @@ final class FollowupAudioWorker
public function runOnce(): bool
{
if (!FollowupAudioStore::enabled() || !FollowupAudioStore::verified() || !($task = FollowupAudioStore::claim())) {
if (!FollowupAudioStore::enabled() || !($task = FollowupAudioStore::claim())) {
return false;
}
$id = (int) $task['id'];
$token = (string) $task['lease_token'];
$started = (int) ($task['upstream_started_at'] ?? 0) > 0;
try {
FollowupAudioStore::assertTaskProvider($task);
if (!FollowupAudioStore::verified((string) $task['model_key'])) { throw new FollowupAudioException('AUDIO_NOT_VERIFIED'); }
$heartbeat = function (array $fields = []) use ($task, $id, $token, &$started): bool {
FollowupAudioStore::assertTaskProvider($task);
if (!FollowupAudioStore::enabled() || !FollowupAudioStore::verified((string) $task['model_key'])) { return false; }
$actor = PrescriptionAiAccess::actor((int) $task['actor_id']);
if (!$actor) { return false; }
@@ -42,7 +44,7 @@ final class FollowupAudioWorker
throw new FollowupAudioException('LEASE_LOST', true);
}
} catch (FollowupAudioException $e) {
FollowupAudioStore::fail($id, $token, $e->errorCode, $e->getMessage(), $e->uncertain);
FollowupAudioStore::fail($id, $token, $e->errorCode, $e->getMessage(), $e->uncertain || $started);
} catch (\Throwable $e) {
// The exception may contain SQL, names, transcript, credentials or a signed URL.
FollowupAudioStore::fail($id, $token, 'INTERNAL_ERROR', '音频任务执行异常,请核对任务状态', $started);
+15 -1
View File
@@ -26,5 +26,19 @@ return [
'ffmpeg' => (string) env('followup_audio.FFMPEG', 'ffmpeg'),
'ffprobe' => (string) env('followup_audio.FFPROBE', 'ffprobe'),
'max_response_bytes' => 8388608,
// No audio-specific provider key or fallback: prescription_ai.base_url/models are reused.
// HTTP is permitted only for explicitly acknowledged synthetic probes, never patient requests.
'allow_insecure_synthetic' => filter_var(env('followup_audio.ALLOW_INSECURE_SYNTHETIC', false), FILTER_VALIDATE_BOOLEAN),
// Stable logical slots preserve task schema. No model identifier or supplier is hardcoded.
'providers' => array_combine(['qwen', 'openai'], array_map(static function (string $profile): array {
$prefix = 'followup_audio.' . strtoupper($profile) . '_';
return [
'driver' => (string) env($prefix . 'DRIVER', 'dify'),
// Only the dify driver may reuse empty base/key fields from prescription_ai.
'base_url' => (string) env($prefix . 'BASE_URL', ''),
'api_key' => (string) env($prefix . 'API_KEY', ''),
'model' => (string) env($prefix . 'MODEL', ''),
'label' => (string) env($prefix . 'LABEL', $profile),
'verified_fingerprint' => (string) env($prefix . 'VERIFIED_FINGERPRINT', ''),
];
}, ['qwen', 'openai'])),
];
+22 -7
View File
@@ -12,15 +12,29 @@ namespace app\common\service\followupaudio {
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
$app = new \think\App(dirname(__DIR__) . '/'); $app->initialize();
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
// Explicit disposable local database only; no application initialization or .env loading.
$port = (int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT');
if ($port <= 0 || getenv('FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE') !== '1') {
throw new \RuntimeException('Explicit local disposable MySQL port and FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE=1 required');
}
$password = (string) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PASSWORD');
$database = 'fa_access_' . bin2hex(random_bytes(6));
$control = new \PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', $password,
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
$control->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
new \think\App();
set_exception_handler(static function (\Throwable $e): void {
fwrite(STDERR, $e->getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL); exit(1);
});
$app->config->set(['default' => 'mysql', 'connections' => ['mysql' => [
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => 23316,
'database' => 'followup_audio_test', 'username' => 'root', 'password' => 'followup_audio_isolated_test',
$manager = new \think\DbManager();
$manager->setConfig(['default' => 'mysql', 'connections' => ['mysql' => [
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
'database' => $database, 'username' => 'root', 'password' => $password,
'charset' => 'utf8mb4', 'prefix' => 'faa_', 'debug' => false,
]]], 'database');
]]]);
\think\Container::getInstance()->instance('think\DbManager', $manager);
\think\Container::getInstance()->instance('config', new \think\Config());
$db = \think\facade\Db::class;
$tables = [
'admin' => 'id BIGINT PRIMARY KEY, name VARCHAR(30), root INT, disable INT, delete_time BIGINT NULL',
@@ -52,7 +66,7 @@ namespace {
$deny = function (callable $f, string $label) use ($ok): void {
try { $f(); } catch (\DomainException $e) { $ok(true, $label); return; } $ok(false, $label);
};
$other = new \PDO('mysql:host=127.0.0.1;port=23316;dbname=followup_audio_test;charset=utf8mb4', 'root', 'followup_audio_isolated_test', [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
$other = new \PDO("mysql:host=127.0.0.1;port={$port};dbname={$database};charset=utf8mb4", 'root', $password, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
try {
$ok((int) $a::diagnosis(91, 8, ['root' => 1])['id'] === 91, 'own patient');
$deny(fn () => $a::diagnosis(92, 8, ['root' => 1]), 'forged cached root must fail');
@@ -80,6 +94,7 @@ namespace {
echo "Followup audio real authorization: {$checks} checks passed (isolated MySQL; current-read revocation/reassignment)\n";
} finally {
try { $db::rollback(); } catch (\Throwable $e) {}
foreach (array_keys($tables) as $name) { $db::execute("DROP TABLE IF EXISTS faa_{$name}"); }
$manager->connect()->close();
$control->exec("DROP DATABASE IF EXISTS `{$database}`");
}
}
+76
View File
@@ -9,6 +9,7 @@ require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\service\followupaudio\FollowupAudioFields as Fields;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use app\common\service\followupaudio\FollowupAudioProviderConfig as ProviderConfig;
use app\common\service\followupaudio\FollowupAudioStore as Store;
use think\Container;
use think\facade\Db;
@@ -37,6 +38,17 @@ $private = $child ? (string) getenv('FOLLOWUP_AUDIO_TEST_PRIVATE') : '/private/t
$config->set(['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'encryption_key' => str_repeat('synthetic-test-key-', 4),
'lease_seconds' => 60, 'concurrency' => 1, 'retention_days' => 90, 'private_dir' => $private], 'followup_audio');
Container::getInstance()->instance('config', $config);
// Explicit synthetic provider identity: no live credentials, application .env or external requests.
$testProviders = [];
foreach (['qwen', 'openai'] as $profile) {
$testProviders[$profile] = ['driver' => 'dify', 'base_url' => 'https://synthetic.invalid/v1',
'api_key' => 'synthetic-test-key', 'model' => 'synthetic-audio', 'label' => 'Synthetic ' . $profile];
}
$config->set(['providers' => $testProviders], 'followup_audio');
foreach (array_keys($testProviders) as $profile) {
$testProviders[$profile]['verified_fingerprint'] = ProviderConfig::resolve($profile)['fingerprint'];
}
$config->set(['providers' => $testProviders], 'followup_audio');
$root = ['root' => 1, 'admin_id' => 1, 'id' => 1, 'name' => 'Synthetic'];
if ($mode === '--claim') { echo json_encode(['id' => Store::claim()['id'] ?? null]) . "\n"; exit(0); }
if ($mode === '--create') {
@@ -182,9 +194,16 @@ try {
$expect(!Store::verified() && !Store::verified('qwen'), 'No profile is implicitly verified');
$config->set(['verified_profiles' => ['qwen']], 'followup_audio');
$expect(Store::verified('qwen') && !Store::verified('openai'), 'Verification is profile-specific');
$unboundProviders = $testProviders; $unboundProviders['qwen']['verified_fingerprint'] = '';
$config->set(['providers' => $unboundProviders], 'followup_audio');
$expect(!Store::verified('qwen'), 'Flags alone cannot verify an unbound provider');
$config->set(['providers' => $testProviders], 'followup_audio');
$reject(fn () => Store::create($firstUpload, $recordedAt, 'openai', 1, $root), 'DISABLED_OR_UNVERIFIED');
$config->set(['verified_profiles' => ['qwen', 'openai']], 'followup_audio');
$a = Store::create($firstUpload, $recordedAt, 'qwen', 1, $root);
$binding = json_decode(Store::task($a['task_id'])['upstream_ids_json'], true, 16, JSON_THROW_ON_ERROR);
$expect($binding === ['provider_fingerprint' => ProviderConfig::resolve('qwen')['fingerprint']],
'New task persists only immutable provider fingerprint, not credentials or endpoint');
$expect(Store::create($firstUpload, $recordedAt, 'qwen', 1, $root)['task_id'] === $a['task_id'], 'Repeated create does not enqueue duplicate upstream work');
$reject(fn () => Store::create($firstUpload, $recordedAt, 'openai', 1, $root), 'UPLOAD_ALREADY_USED');
$differentUpload = $upload(1, 1, $firstUpload);
@@ -203,6 +222,18 @@ try {
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['stage' => 'uploading', 'upstream_started_at' => time(),
'upstream_ids_json' => ['request_id' => 'opaque-test-request']]), 'Upstream started checkpoint persisted');
$reject(fn () => Store::checkpoint((int) $running['id'], $running['lease_token'], ['api_key' => 'forbidden']), 'CHECKPOINT_INVALID');
$savedIds = json_decode(Store::task((int) $running['id'])['upstream_ids_json'], true, 16, JSON_THROW_ON_ERROR);
$expect($savedIds['provider_fingerprint'] === $binding['provider_fingerprint'] && $savedIds['request_id'] === 'opaque-test-request',
'Normal upstream checkpoints preserve immutable fingerprint');
foreach ([str_repeat('0', 64), '', null] as $replacement) {
$reject(fn () => Store::checkpoint((int) $running['id'], $running['lease_token'],
['upstream_ids_json' => ['provider_fingerprint' => $replacement]]), 'CHECKPOINT_INVALID');
}
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['upstream_ids_json' => '{}'])
&& json_decode(Store::task((int) $running['id'])['upstream_ids_json'], true)['provider_fingerprint'] === $binding['provider_fingerprint'],
'An empty checkpoint cannot delete task binding');
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['upstream_ids_json' => $binding]),
'Transport may echo exactly the original immutable fingerprint');
Db::name('followup_audio_task')->where('id', $running['id'])->update(['lease_until' => time() - 1]);
$other = Store::claim();
$expect(Store::task((int) $running['id'])['status'] === 'needs_reconciliation', 'Expired attempted request cannot be resubmitted');
@@ -223,6 +254,51 @@ try {
$parallelClaim = Store::claim();
$expect((int) $parallelClaim['id'] === $parallelCreated[0]['id'], 'Concurrent dedupe leaves exactly one queued upstream operation');
Store::fail((int) $parallelClaim['id'], $parallelClaim['lease_token'], 'LOCAL_PROBE_FAILED', '');
// Queue bindings survive model switches; changed configuration never silently reroutes old audio.
$oldTask = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
Db::name('followup_audio_task')->where('id', $oldTask['id'])->update(['upstream_ids_json' => '{}']);
$expect(Store::claim() === null, 'Legacy task without fingerprint is never claimed');
$legacy = Store::task($oldTask['id']);
$expect($legacy['status'] === 'failed' && $legacy['error_code'] === 'PROVIDER_CONFIGURATION_CHANGED'
&& (int) $legacy['attempts'] === 0 && !Store::summary($legacy)['can_retry'], 'Legacy binding failure is visible and cannot retry');
$changedQueued = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
$changedProviders = $testProviders; $changedProviders['qwen']['model'] = 'synthetic-changed-model';
$config->set(['providers' => $changedProviders], 'followup_audio');
$expect(!Store::verified('qwen') && Store::claim() === null, 'Changed unverified model cannot consume queued audio');
$changedRow = Store::task($changedQueued['id']);
$expect($changedRow['status'] === 'failed' && $changedRow['error_code'] === 'PROVIDER_CONFIGURATION_CHANGED'
&& (int) $changedRow['upstream_started_at'] === 0, 'Configuration drift fails before upstream intent');
$changedProviders['qwen']['verified_fingerprint'] = ProviderConfig::resolve('qwen')['fingerprint'];
$config->set(['providers' => $changedProviders], 'followup_audio');
$expect(Store::verified('qwen') && !Store::summary($changedRow)['can_retry'], 'Reverified new provider does not unlock old task retry');
$providerMismatch = false;
try { Store::retry($changedQueued['id']); }
catch (\app\common\service\followupaudio\FollowupAudioException $error) { $providerMismatch = $error->errorCode === 'PROVIDER_CONFIGURATION_CHANGED'; }
$expect($providerMismatch, 'Retry reports stable provider-binding error rather than rerouting');
$config->set(['providers' => $testProviders], 'followup_audio');
$expect(Store::retry($changedQueued['id'])['status'] === 'queued', 'Explicit restoration of exact original provider allows safe pre-request retry');
$restored = Store::claim();
Store::fail((int) $restored['id'], $restored['lease_token'], 'LOCAL_PROBE_FAILED', '');
$switchUpload = $upload(); $switchTask = Store::create($switchUpload, $recordedAt, 'qwen', 1, $root);
$switchClaim = Store::claim();
Store::checkpoint($switchTask['id'], $switchClaim['lease_token'], ['upstream_started_at' => time()]);
$config->set(['providers' => $changedProviders], 'followup_audio');
$expect(!Store::heartbeat($switchTask['id'], $switchClaim['lease_token'])
&& !Store::checkpoint($switchTask['id'], $switchClaim['lease_token'], ['stage' => 'analyzing'])
&& !Store::complete($switchTask['id'], $switchClaim['lease_token'], []),
'Heartbeat checkpoint and complete independently fence provider drift');
$config->set(['enabled' => false, 'audio_verified' => false], 'followup_audio');
$expect(Store::fail($switchTask['id'], $switchClaim['lease_token'], 'PROVIDER_CONFIGURATION_CHANGED', 'must not persist', false),
'Configuration and feature withdrawal cannot prevent fenced failure persistence');
$failedSwitch = Store::task($switchTask['id']);
$expect($failedSwitch['status'] === 'needs_reconciliation' && $failedSwitch['lease_token'] === '',
'Attempted old-provider work is held for reconciliation, not left running');
$config->set(['enabled' => true, 'audio_verified' => true], 'followup_audio');
$switchReuse = Store::create($upload(1, 1, $switchUpload), $recordedAt, 'qwen', 1, $root);
$expect($switchReuse['id'] === $switchTask['id'] && $switchReuse['reused']
&& $switchReuse['status'] === 'needs_reconciliation' && str_contains($switchReuse['reuse_message'], '配置'),
'Reverified provider switch plus reupload cannot bypass unknown-request dedupe');
$config->set(['providers' => $testProviders], 'followup_audio');
$makeReview = static function (array $extraction, int $diagnosisId = 1, int $actor = 1) use ($upload, $recordedAt, $root): array {
$created = Store::create($upload($diagnosisId, $actor), $recordedAt, 'qwen', $actor, $root);
$claim = Store::claim();
+6 -2
View File
@@ -51,7 +51,7 @@ $expectError = static function (callable $call, string $code, bool $uncertain =
$expect(!str_contains($e->getMessage(), 'private-body') && !str_contains($e->getMessage(), 'synthetic-test-key'), 'redacted error');
}
};
$settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'ffprobe' => 'ffprobe', 'request_timeout' => 5, 'max_seconds' => 3600];
$settings = ['allow_insecure_synthetic' => true, 'enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'ffprobe' => 'ffprobe', 'request_timeout' => 5, 'max_seconds' => 3600];
$adapter = static function (string $scenario, array $extra = []) use ($port, $settings): Dify {
return new Dify(null, $extra + $settings, ['base_url' => 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1',
'models' => ['qwen' => ['api_key' => 'synthetic-test-key'], 'openai' => ['api_key' => 'synthetic-test-key']]]);
@@ -115,8 +115,12 @@ try {
$expectError(static fn () => $unverified->analyze([], $heartbeat), 'AUDIO_NOT_VERIFIED');
$notVerifiedProfile = new Dify($testTransport, ['verified_profiles' => ['qwen']] + $settings, $provider);
$expectError(static fn () => $notVerifiedProfile->analyze(['model_key' => 'openai'], $heartbeat), 'AUDIO_NOT_VERIFIED');
$safeProvider = ['base_url' => 'https://synthetic.invalid/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]];
$verifiedSettings = $settings;
$verifiedSettings['providers']['qwen']['verified_fingerprint'] = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $settings, $safeProvider)['fingerprint'];
$verifiedGuarded = new Dify($testTransport, $verifiedSettings, $safeProvider);
$guarded = new Dify($testTransport, $settings, $provider);
$expectError(static fn () => $guarded->analyze(['upstream_started_at' => 1, 'model_key' => 'qwen'], $heartbeat), 'RECONCILIATION_REQUIRED', true);
$expectError(static fn () => $verifiedGuarded->analyze(['upstream_started_at' => 1, 'model_key' => 'qwen'], $heartbeat), 'RECONCILIATION_REQUIRED', true);
$expectError(static fn () => $guarded->probe($wave, $fixture + ['irrelevant' => 'x'], 'unsupported', $heartbeat), 'INVALID_PROFILE');
$badFixture = $fixture; $badFixture['sha256'] = str_repeat('0', 64);
$expectError(static fn () => $guarded->probe($wave, $badFixture, 'qwen', $heartbeat), 'AUDIO_INVALID');
@@ -27,7 +27,9 @@ expectEndpoint(str_contains($controller, 'count($items) > 500'), 'bounded review
expectEndpoint(str_contains($controller, "'code' => 'FOLLOWUP_AUDIO_STALE_REVIEW'"), 'typed stale review response');
expectEndpoint(substr_count($controller, 'Logic::requireEnabled(true)') >= 3, 'upload capability gate');
expectEndpoint(str_contains($logic, "Store::verified(\$p['model_key'])"), 'per-model audio capability gate');
expectEndpoint(str_contains($logic, "'models' => array_values(array_filter("), 'only verified models advertised');
expectEndpoint(str_contains($logic, "'models' => ProviderConfig::publicModels()"), 'only fingerprint-verified server model labels advertised');
expectEndpoint(!str_contains($logic, 'Dify 音频能力') && !str_contains($logic, 'api_key') && !str_contains($logic, 'base_url'),
'provider-neutral public capabilities never expose credentials or addresses');
expectEndpoint(str_contains($logic, "(int) \$upload['diagnosis_id'] !== \$p['diagnosis_id']"), 'upload/diagnosis binding');
expectEndpoint(str_contains($logic, "new \\DateTimeZone('Asia/Shanghai')"), 'explicit local date anchor timezone');
expectEndpoint(str_contains($stream, 'private, no-store, max-age=0'), 'private playback response');
+24 -5
View File
@@ -64,12 +64,31 @@ $makeTask = static function (string $path, string $extension) use ($directory):
'file_name' => 'synthetic.' . $extension, 'extension' => $extension, 'total_bytes' => filesize($target),
'received_bytes' => filesize($target), 'sha256' => $hash, 'duration_seconds' => $duration,
'status' => 'complete', 'created_at' => time(), 'expires_at' => time() + 86400]);
return ['id' => 1, 'upload_id' => $id, 'diagnosis_id' => 91, 'actor_id' => 7, 'model_key' => 'qwen',
return ['upstream_ids_json' => '{}', 'id' => 1, 'upload_id' => $id, 'diagnosis_id' => 91, 'actor_id' => 7, 'model_key' => 'qwen',
'recorded_at' => '2026-09-29 10:00:00', 'sha256' => $hash, 'duration_seconds' => $duration, 'path' => $target];
};
$adapter = static function (string $scenario, array $overrides = []) use ($port, $settings): Dify {
return new Dify(null, $overrides + $settings, ['base_url' => 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1',
'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]]);
$provider = ['base_url' => 'https://synthetic.invalid/' . $scenario . '/v1',
'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]];
$effective = $overrides + $settings;
// Keep a stable task identity while varying transport scenarios; the test seam owns scenario routing.
$provider['base_url'] = 'https://synthetic.invalid/v1';
$effective['providers']['qwen']['verified_fingerprint'] = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $effective, $provider)['fingerprint'];
$adapter = null;
$transport = static function (array $spec, callable $heartbeat) use (&$adapter, $port, $scenario): array {
$spec['url'] = str_replace('https://synthetic.invalid', 'http://127.0.0.1:' . $port . '/' . $scenario, $spec['url']);
// Reflection is a test seam only; production cURL still verifies HTTPS certificates.
$curl = new ReflectionMethod(Dify::class, 'curl');
return $curl->invoke($adapter, $spec, $heartbeat);
};
$adapter = new Dify($transport, $effective, $provider);
return $adapter;
};
$taskFingerprint = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $settings,
['base_url' => 'https://synthetic.invalid/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]])['fingerprint'];
$bindTask = static function (array $task) use ($taskFingerprint): array {
$task['upstream_ids_json'] = json_encode(['provider_fingerprint' => $taskFingerprint]);
return $task;
};
$requests = static fn (): array => is_file($directory . '/requests.jsonl') ? array_map(static fn (string $line): array =>
json_decode($line, true), file($directory . '/requests.jsonl', FILE_IGNORE_NEW_LINES)) : [];
@@ -87,7 +106,7 @@ try {
file_put_contents($directory . '/source.amr', "#!AMR\n" . str_repeat("\x3c" . str_repeat("\0", 31), 150));
$tasks = [];
foreach (['wav', 'm4a', 'mp3', 'amr'] as $extension) {
$task = $makeTask($directory . '/source.' . $extension, $extension); $tasks[$extension] = $task;
$task = $bindTask($makeTask($directory . '/source.' . $extension, $extension)); $tasks[$extension] = $task;
$events = []; $copies = [];
$heartbeat = static function (array $fields = []) use (&$events, &$copies, $task): bool {
$events[] = $fields;
@@ -140,7 +159,7 @@ try {
$expect(count($requests()) === $before + 2 && glob(dirname($wave['path']) . '/processing.*') === [],
'unknown HTTP result is not resent and processing copy is erased');
$expect(hash_file('sha256', $wave['path']) === $wave['sha256'], 'unknown result leaves original intact');
$makeWav($directory . '/long.wav', 3600); $long = $makeTask($directory . '/long.wav', 'wav');
$makeWav($directory . '/long.wav', 3600); $long = $bindTask($makeTask($directory . '/long.wav', 'wav'));
$copyMetadata = [];
$adapter('media-long')->analyze($long, static function (array $fields = []) use ($long, &$copyMetadata): bool {
if (isset($fields['upstream_started_at'])) {
+114
View File
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
/** Synthetic bytes, real ffmpeg/ffprobe, injected HTTP response; no network, .env or patient data. */
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioDify as Adapter;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioException as AudioError;
new think\App();
$directory = sys_get_temp_dir() . '/followup-audio-openai-' . bin2hex(random_bytes(6)); mkdir($directory, 0700, true);
$pdo = new PDO('sqlite:' . $directory . '/dictionary.sqlite');
$pdo->exec('CREATE TABLE zyt_dict_data (id INTEGER PRIMARY KEY, type_value TEXT, status INTEGER, sort INTEGER, name TEXT, value TEXT)');
$manager = new think\DbManager();
$manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => $directory . '/dictionary.sqlite', 'prefix' => 'zyt_']]]);
think\Container::getInstance()->instance('think\DbManager', $manager);
$checks = 0;
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
$expectError = static function (callable $call, string $code, bool $uncertain = false) use ($expect): void {
try { $call(); } catch (AudioError $error) {
$expect($error->errorCode === $code && $error->uncertain === $uncertain, 'expected ' . $code . ', got ' . $error->errorCode); return;
}
throw new RuntimeException('Expected ' . $code);
};
$slot = ['driver' => 'openai_audio', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key', 'model' => 'audio-model-from-config'];
$settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen'], 'providers' => ['qwen' => $slot],
'max_seconds' => 3600, 'ffmpeg' => 'ffmpeg', 'ffprobe' => 'ffprobe', 'request_timeout' => 5];
$fingerprint = Provider::resolve('qwen', $settings, [])['fingerprint'];
$settings['providers']['qwen']['verified_fingerprint'] = $fingerprint;
$wave = $directory . '/short.wav';
$samples = str_repeat(pack('v', 0), 48000);
$bytes = 'RIFF' . pack('V', 36 + strlen($samples)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16)
. 'data' . pack('V', strlen($samples)) . $samples;
file_put_contents($wave, $bytes);
$fixture = ['synthetic' => true, 'generator' => 'followup-audio-synthetic-v1', 'case' => 'short', 'sha256' => hash_file('sha256', $wave), 'duration_seconds' => 3];
$raw = ['schema_version' => 'followup-audio-v1', 'audio_processed' => true, 'summary' => '合成事实',
'transcript' => '昨天晚上九点,收缩压126,舒张压82。', 'uncertainties' => [], 'items' => [[
'kind' => 'blood', 'values' => ['systolic_pressure' => 126, 'diastolic_pressure' => 82],
'record_date' => null, 'record_time' => '21:00:00', 'time_period' => null, 'time_estimated' => false,
'date_text' => '昨天', 'time_text' => '晚上九点', 'evidence' => [['text' => '昨天晚上九点,收缩压126,舒张压82。']], 'needs_review' => false,
]]];
$events = []; $requests = []; $mode = 'success';
$heartbeat = static function (array $fields = []) use (&$events): bool { $events[] = $fields; return true; };
$transport = static function (array $spec) use (&$requests, &$events, &$mode, $raw, $expect): array {
$expect(!empty(end($events)['upstream_started_at']), 'intent is durable before a single chat request');
$requests[] = $spec;
$answer = $raw;
if ($mode === 'not_read') { $answer['audio_processed'] = false; }
$content = $mode === 'text_only' ? '没有收到可读取音频附件' : json_encode($answer, JSON_UNESCAPED_UNICODE);
$response = ['id' => 'synthetic-completion-id', 'choices' => [['finish_reason' => $mode === 'truncated' ? 'length' : 'stop', 'message' => ['role' => 'assistant', 'content' => $content]]]];
if ($mode === 'malformed_choices') { $response['choices'] = 'invalid'; }
return ['http_code' => $mode === 'unknown' ? 504 : ($mode === 'rejected' ? 400 : 200),
'errno' => 0, 'request_id' => 'synthetic-request-id', 'body' => json_encode($response)];
};
$adapter = new Adapter($transport, $settings, []);
try {
$result = $adapter->probe($wave, $fixture, 'qwen', $heartbeat);
$spec = $requests[0]; $content = $spec['json']['messages'][0]['content'];
$expect(count($requests) === 1 && $spec['url'] === 'https://synthetic.invalid/v1/chat/completions', 'one explicit OpenAI endpoint, no upload or Dify fallback');
$expect($spec['json']['model'] === $slot['model'], 'selected model actually sent');
$expect($content[1]['type'] === 'input_audio' && $content[1]['input_audio']['format'] === 'wav'
&& base64_decode($content[1]['input_audio']['data'], true) === $bytes, 'exact original WAV bytes attached');
$expect($result['items'][0]['record_date'] === '2026-09-28', 'same policy and temporal normalization');
$ids = json_decode(end($events)['upstream_ids_json'], true);
$expect($ids['provider_fingerprint'] === $fingerprint && $ids['message_id'] === 'synthetic-completion-id'
&& $ids['upstream_request_id'] === 'synthetic-request-id', 'response ID and bound identity checkpointed');
foreach (['not_read' => 'AUDIO_NOT_PROCESSED', 'text_only' => 'UPSTREAM_SCHEMA_INVALID', 'truncated' => 'UPSTREAM_SCHEMA_INVALID',
'malformed_choices' => 'UPSTREAM_SCHEMA_INVALID', 'rejected' => 'UPSTREAM_AUDIO_REJECTED', 'unknown' => 'UPSTREAM_UNCERTAIN'] as $scenario => $code) {
$mode = $scenario; $before = count($requests);
$expectError(static fn () => $adapter->probe($wave, $fixture, 'qwen', $heartbeat), $code, $scenario === 'unknown');
$expect(count($requests) === $before + 1, 'HTTP success/rejection/unknown never causes fallback or resend');
$ids = json_decode(end($events)['upstream_ids_json'], true);
$expect($ids['provider_fingerprint'] === $fingerprint && $ids['message_id'] === 'synthetic-completion-id', 'failed response retains identity and billable ID');
}
$mode = 'success';
$http = $settings; $http['providers']['qwen']['base_url'] = 'http://synthetic.invalid/v1';
$before = count($requests);
$expectError(static fn () => (new Adapter($transport, $http, []))->probe($wave, $fixture, 'qwen', $heartbeat), 'HTTPS_REQUIRED');
$http['allow_insecure_synthetic'] = true;
(new Adapter($transport, $http, []))->probe($wave, $fixture, 'qwen', $heartbeat);
$expect(count($requests) === $before + 1, 'HTTP requires explicit synthetic-only switch');
$expectError(static fn () => (new Adapter($transport, $http, []))->analyze(['model_key' => 'qwen'], $heartbeat), 'AUDIO_NOT_VERIFIED');
$before = count($requests);
$expectError(static fn () => $adapter->analyze(['model_key' => 'qwen', 'upstream_started_at' => 1], $heartbeat), 'RECONCILIATION_REQUIRED', true);
$expect(count($requests) === $before, 'started tasks never resend');
$analyze = new ReflectionMethod(Adapter::class, 'analyzeFile');
$task = ['id' => 'synthetic-task', 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen',
'sha256' => $fixture['sha256'], 'duration_seconds' => 3, 'upstream_ids_json' => '{}'];
$expectError(static fn () => $analyze->invoke($adapter, $wave, $task, $heartbeat), 'PROVIDER_CONFIGURATION_CHANGED');
$task['upstream_ids_json'] = json_encode(['provider_fingerprint' => str_repeat('0', 64)]);
$expectError(static fn () => $analyze->invoke($adapter, $wave, $task, $heartbeat), 'PROVIDER_CONFIGURATION_CHANGED');
$expect(count($requests) === $before, 'missing or changed provider snapshot fails before sending');
$task['upstream_ids_json'] = json_encode(['provider_fingerprint' => $fingerprint]);
// M4A and AMR must fully normalize to MP3, not be mislabeled input_audio formats.
$p = proc_open(['ffmpeg', '-nostdin', '-v', 'error', '-i', $wave, '-c:a', 'aac', $directory . '/short.m4a'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
fclose($pipes[0]); stream_get_contents($pipes[1]); stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]);
$expect(proc_close($p) === 0, 'synthetic M4A generated');
file_put_contents($directory . '/short.amr', "#!AMR\n" . str_repeat("\x3c" . str_repeat("\0", 31), 150));
foreach (['m4a', 'amr'] as $extension) {
$path = $directory . '/short.' . $extension; $hash = hash_file('sha256', $path); $task['sha256'] = $hash;
$analyze->invoke($adapter, $path, $task, $heartbeat);
$audio = end($requests)['json']['messages'][0]['content'][1]['input_audio'];
$expect($audio['format'] === 'mp3' && base64_decode($audio['data'], true) !== false, $extension . ' complete MP3 processing payload');
$expect(hash_file('sha256', $path) === $hash && glob($directory . '/processing.*') === [], $extension . ' original preserved and private copy cleaned');
}
$source = file_get_contents(dirname(__DIR__) . '/app/common/service/followupaudio/FollowupAudioDify.php');
$expect(str_contains($source, 'CURLOPT_SSL_VERIFYPEER => true') && str_contains($source, 'CURLOPT_SSL_VERIFYHOST => 2'), 'TLS validation is never disabled');
echo 'FOLLOWUP_AUDIO_OPENAI assertions=' . $checks . ' PASS real_input_audio=1 model_configurable=1 no_text_fallback=1 no_resend=1 https_patient_gate=1 full_normalization=1' . PHP_EOL;
} finally {
$manager->connect()->close(); $pdo = null;
foreach (glob($directory . '/*') ?: [] as $path) { if (is_file($path)) { unlink($path); } }
rmdir($directory);
}
+25 -7
View File
@@ -6,7 +6,7 @@ namespace app\common\service\followupaudio {
/** Command-state test double. Actual HTTP/audio behavior is covered by FollowupAudioDifyTest. */
final class FollowupAudioDify {
public static string $mode = 'uncertain';
public static string $fingerprint = 'synthetic-application-one';
public static string $fingerprint = '1111111111111111111111111111111111111111111111111111111111111111';
public static int $calls = 0;
public function configurationStatus(string $profile): array { return ['configured' => true, 'profile' => $profile, 'code' => 'OK', 'application_fingerprint' => self::$fingerprint]; }
public function probe(string $path, array $fixture, string $profile, callable $heartbeat): array {
@@ -24,6 +24,8 @@ namespace {
use app\command\FollowupAudioProbe as Probe;
use think\console\Input;
use think\console\Output;
new think\App(); // No initialization, real config or DB.
$safeConfig = new think\Config(); think\Container::getInstance()->instance('config', $safeConfig);
$directory = sys_get_temp_dir() . '/followup-audio-probe-state-' . bin2hex(random_bytes(6));
mkdir($directory, 0700, true);
$directory = realpath($directory);
@@ -52,26 +54,42 @@ namespace {
$expect($code === 1 && Dify::$calls === 0 && str_contains($stdout, 'SYNTHETIC_ACK_REQUIRED'), 'explicit synthetic acknowledgement required');
[$code, $stdout] = $run('qwen');
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'needs_reconciliation'), 'unknown result stops next cases');
$report = json_decode(file_get_contents($directory . '/probe-qwen.json'), true);
$report = json_decode(file_get_contents($directory . '/probe-qwen-' . Dify::$fingerprint . '.json'), true);
$expect($report['cases']['short']['upstream_started_at'] > 0, 'intent persisted before request');
[$code, $stdout] = $run('qwen');
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'resume never recharges unknown result');
$currentQwenPath = $directory . '/probe-qwen-' . Dify::$fingerprint . '.json';
$legacyQwenPath = $directory . '/probe-qwen.json';
rename($currentQwenPath, $legacyQwenPath);
$legacyBytes = file_get_contents($legacyQwenPath);
[$code, $stdout] = $run('qwen');
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'matching legacy identity remains authoritative for unknown requests');
$expect(file_get_contents($legacyQwenPath) === $legacyBytes, 'legacy unknown evidence retained unchanged');
$legacyReport = json_decode($legacyBytes, true);
$legacyReport['configuration']['application_fingerprint'] = hash('sha256', "https://synthetic.invalid/v1\0synthetic-key");
file_put_contents($legacyQwenPath, json_encode($legacyReport));
$safeConfig->set(['providers' => ['qwen' => ['driver' => 'dify', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key']]], 'followup_audio');
[$code, $stdout] = $run('qwen');
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'legacy Dify hash upgrade cannot resubmit same-app unknown');
$safeConfig->set([], 'followup_audio');
Dify::$mode = 'success';
[$code, $stdout] = $run('openai');
$expect($code === 0 && Dify::$calls === 4, 'three lengths passed sequentially');
$bytes = file_get_contents($directory . '/probe-openai.json');
$bytes = file_get_contents($directory . '/probe-openai-' . Dify::$fingerprint . '.json');
$report = json_decode($bytes, true);
$expect($report['audio_verified'] && count($report['cases']) === 3, 'all three required before verification report');
$expect(!str_contains($bytes . $stdout, 'PRIVATE_TRANSCRIPT_NEVER_PRINTED'), 'no transcript in output or report');
[$code, $stdout] = $run('openai');
$expect($code === 0 && Dify::$calls === 4 && substr_count($stdout, 'retained_passed') === 3, 'passed gate results reused without resending');
Dify::$fingerprint = 'synthetic-application-two';
$oldPath = $directory . '/probe-openai-' . Dify::$fingerprint . '.json';
Dify::$fingerprint = '2222222222222222222222222222222222222222222222222222222222222222';
[$code, $stdout] = $run('openai');
$expect($code === 1 && Dify::$calls === 4 && str_contains($stdout, 'PROBE_APPLICATION_CHANGED'), 'new application cannot inherit old gate');
$expect(file_get_contents($directory . '/probe-openai.json') === $bytes, 'application mismatch leaves original evidence unchanged');
$expect($code === 0 && Dify::$calls === 7 && !str_contains($stdout, 'retained_passed'), 'new application executes its own gate');
$expect(file_get_contents($oldPath) === $bytes, 'application mismatch leaves original evidence unchanged');
file_put_contents($directory . '/short.mp3', 'tampered');
[$code, $stdout] = $run('qwen');
$expect($code === 1 && Dify::$calls === 4 && str_contains($stdout, 'SYNTHETIC_FIXTURE_REQUIRED'), 'tampered fixture cannot run');
$expect($code === 1 && Dify::$calls === 7 && str_contains($stdout, 'SYNTHETIC_FIXTURE_REQUIRED'), 'tampered fixture cannot run');
echo 'FOLLOWUP_AUDIO_PROBE_COMMAND assertions=' . $checks . ' PASS durable_no_resend=1 retained_results=1 app_identity=1' . PHP_EOL;
} finally {
foreach (glob($directory . '/*') ?: [] as $file) { if (is_file($file)) { unlink($file); } }
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/** No app initialization, .env, network or real credentials. */
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioException as AudioError;
new think\App();
$config = new think\Config();
think\Container::getInstance()->instance('config', $config);
$checks = 0;
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
$expectError = static function (callable $call, string $code) use ($expect): void {
try { $call(); } catch (AudioError $error) { $expect($error->errorCode === $code, $code); return; }
throw new RuntimeException('Expected ' . $code);
};
$legacy = ['base_url' => 'https://legacy.invalid/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-legacy-key', 'name' => 'must-not-inherit']]];
$dify = Provider::resolve('qwen', [], $legacy);
$expect($dify['driver'] === 'dify' && $dify['base_url'] === $legacy['base_url'] && $dify['model'] === '', 'legacy Dify app identity allowed without fake internal model');
$slot = ['driver' => 'openai_audio', 'base_url' => 'https://audio.invalid/v1/chat/completions',
'api_key' => 'synthetic-provider-key', 'model' => 'configurable-audio-model', 'label' => 'Audio label'];
$settings = ['enabled' => false, 'audio_verified' => true, 'verified_profiles' => ['qwen'], 'providers' => ['qwen' => $slot]];
$resolved = Provider::resolve('qwen', $settings, $legacy);
$expect($resolved['base_url'] === 'https://audio.invalid/v1' && $resolved['model'] === $slot['model'], 'explicit selected driver model and normalized endpoint');
$settings['providers']['qwen']['verified_fingerprint'] = $resolved['fingerprint'];
$config->set($settings, 'followup_audio'); $config->set($legacy, 'prescription_ai');
$expect(Provider::isVerified('qwen'), 'verified state independent of disabled operational switch');
$expect(Provider::publicModels() === [['value' => 'qwen', 'label' => 'Audio label']], 'only verified slots exposed');
$status = Provider::status('qwen', $settings, $legacy);
$expect(array_keys($status) === ['configured', 'profile', 'code', 'application_fingerprint'], 'safe exact status keys');
$public = json_encode([$status, Provider::publicModels()]);
$expect(!str_contains($public, 'synthetic-provider-key') && !str_contains($public, 'audio.invalid'), 'public response contains no endpoint or key');
foreach (['driver' => 'dify', 'base_url' => 'https://changed.invalid/v1', 'model' => 'another-model', 'api_key' => 'other-synthetic-key'] as $field => $value) {
$changed = $settings; $changed['providers']['qwen'][$field] = $value;
$expect(!Provider::verified('qwen', $changed, $legacy), 'identity change invalidates ' . $field);
}
$changed = $settings; $changed['providers']['qwen']['label'] = 'Renamed display';
$expect(Provider::verified('qwen', $changed, $legacy), 'display-only label does not change request identity');
$changed = $settings; $changed['providers']['qwen']['label'] = '';
$expect(Provider::resolve('qwen', $changed, $legacy)['label'] === $slot['model'], 'empty label falls back to configured model');
$changed = $settings; $changed['providers']['qwen']['base_url'] = 'http://audio.invalid/v1';
$changed['providers']['qwen']['verified_fingerprint'] = Provider::resolve('qwen', $changed, $legacy)['fingerprint'];
$changed['allow_insecure_synthetic'] = true;
$expect(!Provider::verified('qwen', $changed, $legacy), 'HTTP never becomes production-verified');
foreach (['model', 'base_url', 'api_key'] as $field) {
$changed = $settings; $changed['providers']['qwen'][$field] = '';
$expectError(static fn () => Provider::resolve('qwen', $changed, $legacy), 'CONFIG_MISSING');
}
foreach (['ftp://audio.invalid/v1', 'https://user:password@audio.invalid/v1', 'https://audio.invalid/v1?token=x',
"https://audio.invalid/v1\n", 'https://audio.invalid/v1#fragment'] as $url) {
$changed = $settings; $changed['providers']['qwen']['base_url'] = $url;
$expectError(static fn () => Provider::resolve('qwen', $changed, []), 'CONFIG_INVALID');
}
$changed = $settings; $changed['providers']['qwen']['driver'] = 'automatic';
$expectError(static fn () => Provider::resolve('qwen', $changed, []), 'CONFIG_INVALID');
$changed = $settings; $changed['providers']['qwen']['verified_fingerprint'] = '';
$expect(!Provider::verified('qwen', $changed, $legacy), 'old unbound flags do not enable audio');
$expect(Provider::status('invalid', $settings, $legacy)['code'] === 'INVALID_PROFILE', 'logical slot allowlist retained');
echo 'FOLLOWUP_AUDIO_PROVIDER_CONFIG assertions=' . $checks . ' PASS explicit_driver=1 model_configurable=1 exact_fingerprint=1 https_verified=1 secrets_hidden=1' . PHP_EOL;
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
/** No autoloader, app, .env, filesystem inputs, network or DB: public gate must fail closed before I/O. */
namespace think\facade {
final class Db
{
public static int $calls = 0;
public static function __callStatic(string $name, array $args): never
{
self::$calls++;
throw new \RuntimeException('DATABASE_MUST_NOT_BE_USED');
}
}
}
namespace {
$settings = [];
function config(string $key, $default = null)
{
global $settings;
$value = $settings;
foreach (explode('.', $key) as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) { return $default; }
$value = $value[$part];
}
return $value;
}
$root = dirname(__DIR__) . '/app/common/service/followupaudio/';
foreach (['Exception', 'ProviderConfig', 'Store'] as $class) { require $root . 'FollowupAudio' . $class . '.php'; }
require dirname(__DIR__) . '/app/adminapi/logic/tcm/FollowupAudioLogic.php';
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Providers;
use app\adminapi\logic\tcm\FollowupAudioLogic as Logic;
$checks = 0;
$expect = static function (bool $ok, string $why) use (&$checks): void {
if (!$ok) { throw new RuntimeException($why); }
$checks++;
};
$expect(!Store::enabled() && !Store::verified() && !Store::verified('qwen'), 'Missing app configuration stays disabled and unverified');
$expect(Store::claim() === null, 'Missing app configuration never enters queue transaction');
$expect(Providers::publicModels() === [], 'Missing provider configuration publishes no models');
try { Store::assertEnabled('qwen'); throw new RuntimeException('Expected disabled gate'); }
catch (DomainException $error) { $expect($error->getMessage() === 'FOLLOWUP_AUDIO_DISABLED_OR_UNVERIFIED', 'Stable disabled error'); }
try { Logic::requireEnabled(true); throw new RuntimeException('Expected public disabled gate'); }
catch (DomainException $error) { $expect($error->getMessage() === '回访录音功能尚未启用', 'Public gate avoids database'); }
$settings['followup_audio'] = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen']];
$expect(!Store::verified(), 'Unbound flags alone never grant capability');
try { Logic::requireEnabled(true); throw new RuntimeException('Expected public unverified gate'); }
catch (DomainException $error) {
$expect(str_contains($error->getMessage(), '当前服务与模型') && !str_contains($error->getMessage(), 'Dify'), 'Public unverified error is provider-neutral');
}
$expect(Providers::publicModels() === [], 'Unconfigured flags do not advertise models');
$provider = ['driver' => 'openai_audio', 'base_url' => 'https://synthetic.invalid/v1',
'api_key' => 'synthetic-test-only', 'model' => 'audio-fixture-v1', 'label' => 'Synthetic audio model'];
$settings['followup_audio']['providers']['qwen'] = $provider;
$fingerprint = Providers::resolve('qwen')['fingerprint'];
$settings['followup_audio']['providers']['qwen']['verified_fingerprint'] = $fingerprint;
$task = ['model_key' => 'qwen', 'upstream_ids_json' => json_encode(['provider_fingerprint' => $fingerprint]), 'upstream_started_at' => 0];
$expect(Store::verified('qwen') && Store::providerMatches($task), 'Exact approved provider matches bound task');
$expect(Providers::publicModels() === [['value' => 'qwen', 'label' => 'Synthetic audio model']], 'Public model list contains only configured value/label');
$public = json_encode(Providers::publicModels());
$expect(!str_contains($public, 'synthetic.invalid') && !str_contains($public, $provider['api_key']), 'Public labels never expose server URL/key');
foreach (['{}', 'not-json', '{"provider_fingerprint":null}', '{"provider_fingerprint":123}'] as $ids) {
$unbound = array_replace($task, ['upstream_ids_json' => $ids]);
$expect(!Store::providerMatches($unbound), 'Missing malformed or invalid fingerprint cannot be trusted');
}
foreach (['driver' => 'dify', 'base_url' => 'https://changed.invalid/v1', 'api_key' => 'rotated-synthetic-key', 'model' => 'audio-fixture-v2'] as $field => $changed) {
$settings['followup_audio']['providers']['qwen'] = $provider + ['verified_fingerprint' => $fingerprint];
$settings['followup_audio']['providers']['qwen'][$field] = $changed;
$expect(!Store::verified('qwen') && !Store::providerMatches($task), 'Provider identity drift invalidates capability and task binding: ' . $field);
}
$expect(\think\facade\Db::$calls === 0, 'All missing-config and provider-binding checks performed without database I/O');
echo 'FOLLOWUP_AUDIO_PROVIDER_INTEGRATION assertions=' . $checks . " PASS database_calls=0 app_initialized=0 network_calls=0\n";
}
@@ -0,0 +1,317 @@
<?php
declare(strict_types=1);
/**
* Opt-in, LOCAL ONLY acceptance of user-supplied audio. No ASR, provider calls, application bootstrap or .env.
* Usage: php tests/FollowupAudioSampleAcceptanceTest.php --manifest /absolute/private/manifest.json
* A manifest must be a private (0600) local JSON file OUTSIDE Git, with this schema:
* {"samples":[{"id":"sample-1","path":"/absolute/local/audio.mp3","sha256":"<64 lowercase hex>"}],
* "recorded_at_cases":[{"id":"ordinary","recorded_at":"2026-10-07 00:00:00"},
* {"id":"month","recorded_at":"2026-03-01 00:00:00"},
* {"id":"year","recorded_at":"2026-01-01 00:00:00"},
* {"id":"leap","recorded_at":"2024-03-01 00:00:00"}]}
* Optional --ffmpeg/--ffprobe select absolute local executables; otherwise PATH is used.
* Real paths, recordings, keys and transcripts MUST NOT be added to committed fixtures.
* Date candidates below are explicitly SYNTHETIC, never transcripts of the supplied recordings.
*/
namespace app\common\service\followupaudio {
/** Test-only access boundary: upload implementation and SQLite are real; production RBAC is NOT exercised. */
final class FollowupAudioAccess
{
public static function diagnosis(int $id, int $actor, array $info, bool $daily = false): array
{
if ($id !== 91 || $actor !== 7) { throw new \DomainException('SYNTHETIC_SCOPE_DENIED'); }
return ['id' => 91, 'patient_id' => 101];
}
}
}
namespace {
use app\common\service\followupaudio\FollowupAudioDify as Dify;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
use think\Container;
use think\facade\Db;
function fail(string $code): never { throw new RuntimeException($code); }
function expect(bool $condition, string $code): void
{
if (!$condition) { fail($code); }
$GLOBALS['sample_checks']++;
}
function reject(callable $call, string $code): void
{
try { $call(); } catch (DomainException | RuntimeException $e) {
expect($e->getMessage() === $code, 'WRONG_REJECTION_CODE');
return;
}
fail('EXPECTED_REJECTION');
}
function exactKeys(array $value, array $keys): bool
{
$actual = array_keys($value); sort($actual); sort($keys);
return $actual === $keys;
}
function localFile(string $path, string $error): string
{
if ($path === '' || $path[0] !== '/' || preg_match('/[\x00-\x1f\x7f]/', $path)
|| preg_match('~/(?:\.|\.\.)(?:/|$)~', $path) || is_link($path) || !is_file($path) || !is_readable($path)) {
fail($error);
}
$real = realpath($path);
if ($real === false) { fail($error); }
// Reject symlink components too; no implicit reads through a linked private input.
$part = '';
foreach (explode('/', ltrim($path, '/')) as $component) {
$part .= '/' . $component;
if (is_link($part)) { fail($error); }
}
return $real;
}
function outsideGit(string $path): bool
{
for ($dir = dirname($path); ; $dir = dirname($dir)) {
if (file_exists($dir . '/.git')) { return false; }
if ($dir === dirname($dir)) { return true; }
}
}
function parseManifest(string $json): array
{
try { $value = json_decode($json, true, 64, JSON_THROW_ON_ERROR); }
catch (JsonException $e) { fail('MANIFEST_JSON_INVALID'); }
if (!is_array($value) || !exactKeys($value, ['samples', 'recorded_at_cases'])
|| !is_array($value['samples']) || !array_is_list($value['samples']) || count($value['samples']) < 1
|| count($value['samples']) > 20 || !is_array($value['recorded_at_cases']) || !array_is_list($value['recorded_at_cases'])) {
fail('MANIFEST_SCHEMA_INVALID');
}
$ids = []; $paths = [];
foreach ($value['samples'] as &$sample) {
if (!is_array($sample) || !exactKeys($sample, ['id', 'path', 'sha256'])
|| !is_string($sample['id']) || !preg_match('/^sample-[1-9][0-9]?$/D', $sample['id'])
|| isset($ids[$sample['id']]) || !is_string($sample['path'])
|| !is_string($sample['sha256']) || !preg_match('/^[a-f0-9]{64}$/D', $sample['sha256'])) {
fail('MANIFEST_SAMPLE_INVALID');
}
$sample['path'] = localFile($sample['path'], 'SAMPLE_PATH_INVALID');
if (isset($paths[$sample['path']])) { fail('MANIFEST_SAMPLE_DUPLICATE'); }
$ids[$sample['id']] = true; $paths[$sample['path']] = true;
}
unset($sample);
$expected = ['ordinary' => '2026-10-07', 'month' => '2026-03-01', 'year' => '2026-01-01', 'leap' => '2024-03-01'];
$dates = [];
foreach ($value['recorded_at_cases'] as $case) {
if (!is_array($case) || !exactKeys($case, ['id', 'recorded_at']) || !is_string($case['id'])
|| !isset($expected[$case['id']]) || isset($dates[$case['id']]) || !is_string($case['recorded_at'])
|| substr($case['recorded_at'], 0, 10) !== $expected[$case['id']]) { fail('MANIFEST_DATES_INVALID'); }
try { Policy::strictRecordedAt($case['recorded_at']); }
catch (DomainException $e) { fail('MANIFEST_DATES_INVALID'); }
$dates[$case['id']] = $case['recorded_at'];
}
if (count($dates) !== count($expected)) { fail('MANIFEST_DATES_INVALID'); }
return $value;
}
function process(array $command): array
{
$child = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
if (!is_resource($child)) { fail('DECODE_PROCESS_UNAVAILABLE'); }
fclose($pipes[0]); stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false);
$stdout = ''; $stderr = ''; $exit = -1; $deadline = microtime(true) + 180;
try {
do {
$stdout .= stream_get_contents($pipes[1]); $stderr .= stream_get_contents($pipes[2]);
$state = proc_get_status($child);
if (!$state['running']) { $exit = $state['exitcode']; break; }
if (microtime(true) > $deadline || strlen($stdout) + strlen($stderr) > 1048576) {
proc_terminate($child, 9); fail('DECODE_PROCESS_LIMIT');
}
usleep(10000);
} while (true);
$stdout .= stream_get_contents($pipes[1]); $stderr .= stream_get_contents($pipes[2]);
} finally {
fclose($pipes[1]); fclose($pipes[2]); $closed = proc_close($child);
}
return ['command' => $command, 'stdout' => $stdout, 'stderr' => $stderr, 'exit_status' => $exit < 0 ? $closed : $exit];
}
function eraseOwnedTree(string $root): void
{
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $file) {
$file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($root);
}
$GLOBALS['sample_checks'] = 0; $directory = null; $originals = []; $exit = 0; $oldMask = umask(0077);
set_error_handler(static function (int $severity): bool {
if (!(error_reporting() & $severity)) { return false; }
// Never print a warning that might contain a source filename or private input.
throw new RuntimeException('LOCAL_IO_FAILED');
});
try {
if ($argc === 1) {
echo "SKIP Followup audio real samples: opt in with --manifest outside Git; no files read, no ASR.\n";
} else {
$options = [];
for ($i = 1; $i < $argc; $i += 2) {
if (!in_array($argv[$i], ['--manifest', '--ffmpeg', '--ffprobe'], true) || !isset($argv[$i + 1])
|| isset($options[$argv[$i]])) { fail('ARGUMENTS_INVALID'); }
$options[$argv[$i]] = $argv[$i + 1];
}
if (!isset($options['--manifest'])) { fail('MANIFEST_REQUIRED'); }
$manifestPath = localFile($options['--manifest'], 'MANIFEST_PATH_INVALID');
if (!outsideGit($manifestPath)) { fail('MANIFEST_MUST_BE_OUTSIDE_GIT'); }
if ((fileperms($manifestPath) & 0777) !== 0600 || filesize($manifestPath) > 65536) { fail('MANIFEST_NOT_PRIVATE_OR_TOO_LARGE'); }
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
$manifest = parseManifest(file_get_contents($manifestPath));
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) { fail('SQLITE_REQUIRED_NO_PRODUCTION_FALLBACK'); }
$tools = [];
foreach (['ffmpeg', 'ffprobe'] as $name) {
$tools[$name] = $options['--' . $name] ?? $name;
if (isset($options['--' . $name]) && ($tools[$name][0] !== '/' || !is_executable($tools[$name]))) { fail('MEDIA_TOOL_INVALID'); }
}
$directory = realpath(sys_get_temp_dir()) . '/followup-audio-samples-' . bin2hex(random_bytes(12));
if (!mkdir($directory . '/private', 0700, true)) { fail('PRIVATE_DIRECTORY_FAILED'); }
new think\App(); // Intentionally NOT initialize(): never loads real app config/services/.env.
$database = $directory . '/test.sqlite';
$pdo = new PDO('sqlite:' . $database); chmod($database, 0600);
$pdo->exec('CREATE TABLE sample_followup_audio_upload (id TEXT PRIMARY KEY, diagnosis_id INT, actor_id INT,
file_name TEXT, extension TEXT, total_bytes INT, received_bytes INT, sha256 TEXT, duration_seconds REAL,
status TEXT, created_at INT, expires_at INT)');
$manager = new think\DbManager();
$manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => [
'type' => 'sqlite', 'database' => $database, 'prefix' => 'sample_']]]);
Container::getInstance()->instance('think\DbManager', $manager);
$settings = ['enabled' => false, 'audio_verified' => false, 'verified_profiles' => [],
'private_dir' => $directory . '/private', 'ffmpeg' => $tools['ffmpeg'], 'ffprobe' => $tools['ffprobe'],
'max_bytes' => 524288000, 'max_seconds' => 3600, 'chunk_bytes' => 65536,
'upstream_max_bytes' => 20971520, 'normalize_timeout' => 120];
$config = new think\Config(); $config->set($settings, 'followup_audio');
Container::getInstance()->instance('config', $config);
$networkAttempts = 0;
$adapter = new Dify(static function () use (&$networkAttempts): never { $networkAttempts++; fail('NETWORK_FORBIDDEN'); }, $settings, []);
$inspect = new ReflectionMethod(Dify::class, 'inspectAudio');
$prepare = new ReflectionMethod(Dify::class, 'prepareAudio');
$inspect->setAccessible(true); $prepare->setAccessible(true);
$media = [];
foreach ($manifest['samples'] as $sample) {
$path = $sample['path']; $hash = (string) hash_file('sha256', $path); $originals[$path] = $hash;
expect(hash_equals($sample['sha256'], $hash), 'INPUT_HASH_MISMATCH');
[, $extension] = Upload::fileName('sample.' . strtolower(pathinfo($path, PATHINFO_EXTENSION)));
$baseline = Upload::inspect($path, $extension);
$session = Upload::createSession(91, $sample['id'] . '.' . $extension, filesize($path), 7, []);
$source = fopen($path, 'rb'); $chunks = 0;
try {
while (!feof($source)) {
$contents = fread($source, $session['chunk_bytes']);
if ($contents === '') { break; }
file_put_contents($directory . '/chunk', $contents); chmod($directory . '/chunk', 0600);
Upload::putChunk($session['upload_id'], $chunks++, $directory . '/chunk', 7, []);
}
} finally { fclose($source); }
$result = Upload::complete($session['upload_id'], 7, []);
expect(hash_equals($hash, $result['sha256']), 'REASSEMBLY_HASH_MISMATCH');
expect(Upload::complete($session['upload_id'], 7, []) === $result, 'COMPLETION_NOT_IDEMPOTENT');
$assembled = Upload::path(Upload::session($session['upload_id']));
expect(hash_file('sha256', $assembled) === $hash, 'ASSEMBLED_BYTES_CHANGED');
expect(abs($baseline['duration_seconds'] - $result['duration_seconds']) < 0.001, 'ASSEMBLED_DURATION_CHANGED');
$decode = process([$tools['ffmpeg'], '-nostdin', '-hide_banner', '-v', 'error', '-xerror',
'-protocol_whitelist', 'file,pipe', '-threads', '1', '-i', $assembled, '-map', '0:a:0',
'-vn', '-sn', '-dn', '-f', 'null', '-']);
expect($decode['exit_status'] === 0, 'FULL_DECODE_FAILED');
$local = $inspect->invoke($adapter, $assembled, $hash);
expect(abs($local['duration'] - $baseline['duration_seconds']) <= 0.001, 'DIFY_LOCAL_INSPECT_MISMATCH');
// Only private LOCAL preprocessing helpers: never analyze(), probe(), or a provider response.
$prepared = $prepare->invoke($adapter, $local, static fn (): bool => true);
$temporary = !empty($prepared['temporary']);
if ($temporary) {
expect(dirname($prepared['path']) === dirname($assembled), 'PROCESSING_COPY_SCOPE_INVALID');
expect((fileperms($prepared['path']) & 0777) === 0600, 'PROCESSING_COPY_NOT_PRIVATE');
unlink($prepared['path']);
} else { expect($prepared['path'] === $assembled, 'UNEXPECTED_PREPARATION_PATH'); }
expect(hash_file('sha256', $path) === $hash && hash_file('sha256', $assembled) === $hash, 'ORIGINAL_CHANGED');
$media[] = ['id' => $sample['id'], 'bytes' => filesize($path), 'sha256' => $hash,
'duration_seconds' => $result['duration_seconds'], 'chunks' => $chunks,
'upload' => 'real_service_with_synthetic_access_and_disposable_sqlite',
'decode' => $decode, 'local_prepare' => $temporary ? 'private_processing_copy' : 'original_compatible_unchanged',
'original_sha256_unchanged' => true];
}
$dateResults = [];
$yesterdays = ['ordinary' => '2026-10-06', 'month' => '2026-02-28', 'year' => '2025-12-31', 'leap' => '2024-02-29'];
foreach ($manifest['recorded_at_cases'] as $case) {
$today = substr($case['recorded_at'], 0, 10);
$cases = [
['today', '今天', $today, '11:21', null, false, false],
['yesterday', '昨天', $yesterdays[$case['id']], '11:21', null, false, false],
['ambiguous-last-week', '上周', null, '11:21', null, false, true],
];
foreach (['凌晨' => null, '早晨' => '08:00', '上午' => '08:00', '中午' => '12:00',
'下午' => '15:00', '晚上' => '20:00', '睡前' => '22:00', '' => null] as $period => $clock) {
$cases[] = ['estimated-' . ($period ?: 'unspecified'), '今天', $today, $clock, $period ?: null, true, true];
}
foreach ($cases as [$id, $dateText, $expectedDate, $clock, $period, $estimate, $review]) {
$quote = $dateText . ($period ?? '') . '空腹血糖6.7';
$raw = ['kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.7], 'date_text' => $dateText,
'record_date' => $id === 'ambiguous-last-week' ? $today : null, 'record_time' => $estimate ? null : $clock,
'time_period' => $period, 'evidence' => [['text' => $quote]]];
$normalized = Policy::normalizeExtraction(['transcript' => $quote, 'summary' => 'Synthetic date-only candidate, NOT ASR',
'items' => [$raw], 'uncertainties' => []], $case['recorded_at']);
expect(count($normalized['items']) === 1, 'SYNTHETIC_CANDIDATE_MISSING');
$item = $normalized['items'][0];
expect($item['record_date'] === $expectedDate, 'SYNTHETIC_DATE_MISMATCH');
expect($item['record_time'] === $clock, 'SYNTHETIC_CLOCK_MISMATCH');
expect($item['time_estimated'] === $estimate, 'SYNTHETIC_ESTIMATION_MISMATCH');
expect($item['needs_review'] === $review && $item['selected'] === false, 'SYNTHETIC_REVIEW_MISMATCH');
$dateResults[] = ['case' => $case['id'] . ':' . $id, 'synthetic_only' => true,
'record_date' => $item['record_date'], 'record_time' => $item['record_time'],
'time_estimated' => $item['time_estimated'], 'needs_review' => $item['needs_review'], 'selected' => false];
}
}
// Input rejection tests use only anonymous temporary paths; no application/environment files are read.
$beforeNegative = $GLOBALS['sample_checks'];
reject(static fn () => parseManifest('{'), 'MANIFEST_JSON_INVALID');
reject(static fn () => parseManifest('{}'), 'MANIFEST_SCHEMA_INVALID');
foreach (['https://example.invalid/audio.mp3', 'relative.mp3', $directory . '/../missing.mp3', $directory . '/missing.mp3', $directory] as $bad) {
$invalid = $manifest; $invalid['samples'][0]['path'] = $bad;
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'SAMPLE_PATH_INVALID');
}
symlink($directory . '/chunk', $directory . '/linked.mp3');
$invalid = $manifest; $invalid['samples'][0]['path'] = $directory . '/linked.mp3';
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'SAMPLE_PATH_INVALID');
unlink($directory . '/linked.mp3');
$invalid = $manifest; $invalid['samples'][] = $invalid['samples'][0];
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'MANIFEST_SAMPLE_INVALID');
$invalid = $manifest; $invalid['recorded_at_cases'][0]['recorded_at'] = '2026-02-30 00:00:00';
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'MANIFEST_DATES_INVALID');
$negativeChecks = $GLOBALS['sample_checks'] - $beforeNegative;
expect($networkAttempts === 0, 'NETWORK_WAS_ATTEMPTED');
expect((fileperms($directory) & 0777) === 0700, 'PRIVATE_ROOT_MODE_INVALID');
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $file) {
expect(!$file->isLink() && (($file->getPerms() & 0777) === ($file->isDir() ? 0700 : 0600)), 'PRIVATE_MODE_INVALID');
}
echo json_encode(['suite' => 'followup-audio-local-samples-v1', 'status' => 'PASS',
'checks' => $GLOBALS['sample_checks'], 'media_samples' => count($media), 'synthetic_date_cases' => count($dateResults),
'negative_input_checks' => $negativeChecks, 'asr' => 'NOT_RUN', 'network_attempts' => $networkAttempts,
'production_database' => 'NOT_USED', 'app_env' => 'NOT_LOADED',
'limitations' => ['Synthetic Access stub does not validate production RBAC.',
'SQLite upload acceptance does not validate production MySQL or HTTP endpoints.',
'Date results are synthetic policy inputs, not recognition of supplied recordings.',
'No Dify upload, model recognition, transcript accuracy, or business writeback is claimed.'],
'media' => $media, 'synthetic_dates' => $dateResults], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL;
}
} catch (Throwable $error) {
$code = preg_match('/^[A-Z0-9_]+$/D', $error->getMessage()) ? $error->getMessage() : 'LOCAL_ACCEPTANCE_FAILED';
fwrite(STDERR, 'FAIL ' . $code . PHP_EOL); $exit = 1;
} finally {
foreach ($originals as $path => $hash) {
if (!is_file($path) || hash_file('sha256', $path) !== $hash) { fwrite(STDERR, "FAIL ORIGINAL_HASH_CHANGED\n"); $exit = 1; }
}
if ($directory !== null && is_dir($directory)) {
try { eraseOwnedTree($directory); }
catch (Throwable $error) { fwrite(STDERR, "FAIL TEMPORARY_CLEANUP_FAILED\n"); $exit = 1; }
}
umask($oldMask); restore_error_handler();
}
exit($exit);
}
+20 -3
View File
@@ -14,9 +14,11 @@ namespace app\common\service\followupaudio {
public static bool $verified = true;
public static array $events = [];
public static bool $lease = true;
public static bool $providerMatches = true;
public static function enabled(): bool { return self::$enabled; }
public static function verified(?string $profile = null): bool { return self::$verified; }
public static function claim(): ?array { self::$events[] = 'claim'; return ['id' => 1, 'actor_id' => 1, 'diagnosis_id' => 1, 'lease_token' => 'test', 'model_key' => 'qwen']; }
public static function claim(): ?array { self::$events[] = 'claim'; return self::$verified ? ['id' => 1, 'actor_id' => 1, 'diagnosis_id' => 1, 'lease_token' => 'test', 'model_key' => 'qwen'] : null; }
public static function assertTaskProvider(array $task): void { if (!self::$providerMatches) { throw new FollowupAudioException('PROVIDER_CONFIGURATION_CHANGED'); } }
public static function heartbeat(int $id, string $token): bool { self::$events[] = 'heartbeat'; return self::$lease; }
public static function checkpoint(int $id, string $token, array $fields): bool { self::$events[] = ['checkpoint' => $fields]; return self::$lease; }
public static function complete(int $id, string $token, array $extraction): bool { self::$events[] = ['complete' => $extraction]; return self::$lease; }
@@ -29,9 +31,11 @@ namespace app\common\service\followupaudio {
final class FollowupAudioDify {
public static string $mode = 'success';
public function analyze(array $task, callable $heartbeat): array {
if (self::$mode === 'change-before-send') { FollowupAudioStore::$providerMatches = false; }
if ($heartbeat(['upstream_started_at' => 1, 'stage' => 'uploading']) === false) { throw new FollowupAudioException('LEASE_LOST'); }
if (self::$mode === 'uncertain') { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
if (self::$mode === 'internal') { throw new \RuntimeException('private-patient'); }
if (self::$mode === 'change-after-send') { FollowupAudioStore::$providerMatches = false; }
if (self::$mode === 'revoke') { \app\common\service\prescriptionai\PrescriptionAiAccess::$active = false; }
return ['summary' => 'synthetic', 'items' => []];
}
@@ -51,8 +55,8 @@ namespace {
Store::$enabled = false;
$expect(!$worker->runOnce() && Store::$events === [], 'disabled before claim');
Store::$enabled = true; Store::$verified = false;
$expect(!$worker->runOnce() && Store::$events === [], 'unverified before claim');
Store::$verified = true;
$expect(!$worker->runOnce() && Store::$events === ['claim'], 'unverified task is not consumed; queue may fence stale bindings');
Store::$events = []; Store::$verified = true;
$expect($worker->runOnce(), 'success consumed task');
$expect(isset(Store::$events[count(Store::$events)-1]['complete']), 'success completed after authorization');
foreach (['uncertain' => 'UPSTREAM_UNCERTAIN', 'internal' => 'INTERNAL_ERROR', 'revoke' => 'LEASE_LOST'] as $mode => $code) {
@@ -68,5 +72,18 @@ namespace {
Actors::$active = true; Access::$allowed = false; Store::$events = [];
$worker->runOnce(); $last = end(Store::$events);
$expect($last['fail'] === 'INTERNAL_ERROR' && !$last['uncertain'], 'scope denied before upstream');
Access::$allowed = true; Actors::$active = true;
foreach (['change-before-send' => false, 'change-after-send' => true] as $mode => $uncertain) {
Dify::$mode = $mode; Store::$events = []; Store::$providerMatches = true;
$worker->runOnce(); $last = end(Store::$events);
$expect($last['fail'] === 'PROVIDER_CONFIGURATION_CHANGED' && $last['uncertain'] === $uncertain,
$mode . ' is fenced with correct upstream uncertainty');
$expect(!array_filter(Store::$events, static fn ($event): bool => is_array($event) && isset($event['complete'])),
$mode . ' never completes result under a changed provider');
}
Store::$providerMatches = false; Store::$events = []; Dify::$mode = 'success';
$worker->runOnce(); $last = end(Store::$events);
$expect($last['fail'] === 'PROVIDER_CONFIGURATION_CHANGED' && !$last['uncertain'] && count(Store::$events) === 2,
'Initial missing or stale task fingerprint is rejected before any heartbeat or transport');
echo 'FOLLOWUP_AUDIO_WORKER assertions=' . $checks . ' PASS gate=1 actor_recheck=1 reconciliation=1' . PHP_EOL;
}