From fea33285e8d8c720ee45bae6aa80483c3acce06b Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Wed, 7 Oct 2026 18:15:32 +0800 Subject: [PATCH] feat: configure audio providers and add local sample acceptance --- docs/plans/followup-audio-deployment.md | 52 ++- server/.env.followup-audio.example | 21 +- .../adminapi/logic/tcm/FollowupAudioLogic.php | 8 +- server/app/command/FollowupAudioProbe.php | 43 ++- .../followupaudio/FollowupAudioDify.php | 154 +++++---- .../followupaudio/FollowupAudioException.php | 6 +- .../FollowupAudioProviderConfig.php | 86 +++++ .../followupaudio/FollowupAudioStore.php | 74 +++- .../followupaudio/FollowupAudioWorker.php | 6 +- server/config/followup_audio.php | 16 +- server/tests/FollowupAudioAccessTest.php | 29 +- server/tests/FollowupAudioCoreTest.php | 76 +++++ server/tests/FollowupAudioDifyTest.php | 8 +- .../FollowupAudioEndpointContractTest.php | 4 +- server/tests/FollowupAudioMediaTest.php | 29 +- server/tests/FollowupAudioOpenAiTest.php | 114 +++++++ .../tests/FollowupAudioProbeCommandTest.php | 32 +- .../tests/FollowupAudioProviderConfigTest.php | 61 ++++ .../FollowupAudioProviderIntegrationTest.php | 75 +++++ .../FollowupAudioSampleAcceptanceTest.php | 317 ++++++++++++++++++ server/tests/FollowupAudioWorkerTest.php | 23 +- 21 files changed, 1103 insertions(+), 131 deletions(-) create mode 100644 server/app/common/service/followupaudio/FollowupAudioProviderConfig.php create mode 100644 server/tests/FollowupAudioOpenAiTest.php create mode 100644 server/tests/FollowupAudioProviderConfigTest.php create mode 100644 server/tests/FollowupAudioProviderIntegrationTest.php create mode 100644 server/tests/FollowupAudioSampleAcceptanceTest.php diff --git a/docs/plans/followup-audio-deployment.md b/docs/plans/followup-audio-deployment.md index 2f897cdba..ee74c312e 100644 --- a/docs/plans/followup-audio-deployment.md +++ b/docs/plans/followup-audio-deployment.md @@ -2,11 +2,11 @@ ## 当前交付边界 -源码在隔离工作副本 `/Users/long/.codex/worktrees/followup-audio/zyt`。原目录 `/Users/long/Work/zyt` 的已有源码保持不变;未提交、未推送、未迁移生产、未部署。入口默认关闭。 +源码在隔离工作副本 `/Users/long/.codex/worktrees/followup-audio/zyt`。原目录 `/Users/long/Work/zyt` 的已有源码保持不变;功能位于 `codex/followup-audio` 分支,初始提交 `70be2fc70`。未推送、未迁移生产、未部署。入口默认关闭。 已完成网页患者编辑入口、诊单绑定私有分片上传、独立持久异步任务、Dify原始音频传递、可编辑审阅草稿、显式人工确认、多表原子写入、同日真实记录明细、历史跟踪备注、来源审核轨迹及90天清理。 -**真实模型门禁仍未通过**:本地 qwen/openai 探针都真实执行并返回 `CONFIG_MISSING`,没有上传录音、没有产生已知模型调用。只能说明隔离环境未配置,不能推断生产Dify是否支持。媒体测试中的完整一小时WAV→MP3、HTTP回环模型和浏览器合成数据不是实际Dify识别验收;混合口音需代表性脱敏样例。 +**真实模型门禁仍未通过**:9月29日本地 Dify 探针返回 `CONFIG_MISSING`;10月7日已按用户指定兼容接口发起合成音频检查,返回 `AUDIO_NOT_PROCESSED`,详见文末。不能把 HTTP 200、上传成功或文本能力当成音频识别成功。媒体测试中的完整一小时WAV→MP3、HTTP回环模型和浏览器合成数据不是实际Dify识别验收;混合口音需代表性脱敏样例。 ## 使用与数据规则 @@ -23,7 +23,7 @@ ## 配置与运行前检查 1. 核对实际数据库字段/表前缀,先在线备份;经发布授权后应用 `server/sql/2026_09_29_followup_audio.sql`。SQL使用现有 `zyt_` 前缀,可重复执行;新增四表及血糖时间来源字段,不改旧数据。不得把测试库模拟schema当作生产已核验。 -2. 使用 `server/.env.followup-audio.example`,继续复用现有 `[prescription_ai]` base URL/profile凭据。不要把生产数据库凭据复制到本地测试环境,不要把API密钥放前端或提交Git。 +2. 使用 `server/.env.followup-audio.example` 配置明确的驱动、模型和凭据。`dify` 可复用现有 `[prescription_ai]` base URL/profile凭据;`openai_audio` 必须显式配置自己的地址、密钥和模型。不会自动切换协议/供应商或回退为纯文本。不要把生产数据库凭据复制到本地测试环境,不要把API密钥放前端或提交Git。 3. PHP8.2已做本地验证;需要curl/fileinfo/openssl、可执行FFmpeg/FFprobe。Web/worker用户都必须可读写同一个私有录音目录,目录不能放在public或公开存储域名下。部署多节点需共享私有存储和稳定加密密钥(至少32字符,离线备份),不要依赖各节点自动生成不同runtime key。 4. 反向代理/PHP单请求上限至少容纳2MiB分片及表单开销;不要将500MiB整个请求交给PHP上传。短期处理副本也要计入磁盘容量。 5. Dify存储副本/缓存的保留与删除策略另行核验;本系统90天删除不代表Dify侧已删除。现实现没有通用Dify远端删除凭证或删除API,不能作此承诺。 @@ -44,7 +44,7 @@ php think followup-audio:probe --synthetic --manifest /private/tmp/zyt-followup- 必须核实真实音频被读取、首尾事实和数值/否定/时间关系、未截断长录音,再用获准的代表性脱敏口音样例人工回听评估。上传成功、HTTP200或纯文本回复都不是通过。探针有状态文件:已通过的case复用;结果未知先核对原Dify任务/账单,禁止自动重发。不要删除未知状态来强制重试。 -只有验证通过的profile可加入 `VERIFIED_PROFILES`;人工核验通过后才设置 `AUDIO_VERIFIED=true`,经授权发布后再设 `ENABLED=true`。默认 `timestamp_verified=false`,不把LLM编出的毫秒位置当成ASR对齐。 +只有验证通过的profile可加入 `VERIFIED_PROFILES`,且必须将本次报告的 `application_fingerprint` 写入对应 `QWEN_VERIFIED_FINGERPRINT` / `OPENAI_VERIFIED_FINGERPRINT`;人工核验通过后才设置 `AUDIO_VERIFIED=true`,经授权发布后再设 `ENABLED=true`。默认 `timestamp_verified=false`,不把LLM编出的毫秒位置当成ASR对齐。 ## 独立消费者、清理与故障处理 @@ -87,3 +87,47 @@ node admin/scripts/verify-daily-records.cjs - 饮食早餐/午餐/晚餐提炼别名映射到实际`*_foods`字段,审阅读回、审计、现有日常表单编辑和提交保持一致,未选餐食及图片不清空;CSV合法值`0`不再被`empty()`抹去;医院诊断选项使用现有页面的三个静态枚举。 - 补测通过30条规则、16条防守和5条跨层检查,以及真实隔离MySQL113项检查。9个模型语义案例仍标注NOT_VERIFIED;真实Dify最新探针仍CONFIG_MISSING。不得把这些本地测试描述为真实ASR、方言或一小时录音识别已通过。 - 本次完整补测账本见工作副本`artifacts/followup-audio-implementation/extraction-acceptance-20260929.md`和`.json`,保留修复前结果、修复后结果及独立回滚证据。主四角色路径保持不变,本次记录追加到原验证记录。 + + +## 2026-10-07:真实样本、本地验收与可配置模型 + +### 本轮实际结果与边界 + +- 用户提供的4段MP3均可读取,长度分别约10分52秒、12分25秒、6分54秒、11分39秒。真实 Upload 服务完成分片、重复分片幂等、私有重组、全量 FFmpeg 解码;原始 SHA256 全部不变。验收用本地一次性 SQLite 和合成授权,不代表生产 HTTP/RBAC 验收。 +- 新的 opt-in `server/tests/FollowupAudioSampleAcceptanceTest.php` 读取 Git 外0600私有manifest;无参数安全跳过。4段录音与44个独立合成日期候选合计287项检查通过。模拟通话日期为2026-10-07、2026-03-01、2026-01-01、2024-03-01,覆盖普通日、跨月、跨年、闰年和模糊日期;这些候选不是从患者录音识别出来的内容。 +- 用户指定的兼容接口 `/models` 可访问。合成短音频按 `input_audio` 发给 `gpt-6.1-sol` 得到 HTTP200,但明确回复未收到可读取音频;`/audio/transcriptions` 请求返回404。新实现的原生能力探针再次以结构化输出契约执行,退出2、`AUDIO_NOT_PROCESSED`。未继续发送15分钟/一小时样本,没有发送4段真实患者录音,没有写入任何正式诊单。 +- HTTPS 实测证书与域名不匹配。没有关闭证书校验。HTTP只用于明确获准的合成检查,正式患者处理强制HTTPS,不能靠开启布尔配置绕过。 +- 上述结果说明目前所测入口/模型不能完成本功能的音频识别,不等于该服务所有未测模型均不支持。纯文本提炼不能替代语音转写。 +- 音频请求格式参考[OpenAI官方音频输入说明](https://developers.openai.com/api/docs/guides/audio-chat-completions),实际服务能力以本轮请求证据为准。 + +### 模型配置(服务端环境变量,不在客服页面暴露凭据) + +当前沿用 `qwen` / `openai` 两个稳定逻辑槽位;槽位不再决定固定品牌/模型,前端显示服务端配置的标签。示例: + +```ini +[followup_audio] +ENABLED = false +AUDIO_VERIFIED = false +VERIFIED_PROFILES = +PROFILE = openai +OPENAI_DRIVER = openai_audio +OPENAI_BASE_URL = https://HOST/v1 +OPENAI_API_KEY = +OPENAI_MODEL = MODEL +OPENAI_LABEL = 回访分析模型 +OPENAI_VERIFIED_FINGERPRINT = +ALLOW_INSECURE_SYNTHETIC = false +``` + +- API_KEY只通过部署环境或Git忽略的本地`.env`注入;本轮本地`.env`权限0600,未打包/提交。正式配置不要使用文档占位值。 +- `DRIVER=dify`选择Dify应用,内部使用哪个模型需在Dify端配置;本地MODEL字段不能改变Dify应用模型。`DRIVER=openai_audio`会将MODEL实际发送到Chat Completions,必须是该服务真正支持音频输入的模型。 +- 地址、密钥、driver、model任一变化都会使验证指纹失效;修改配置后重启消费者并重新验证。标签变化不改变提供方身份。不要保留旧指纹作为新模型的验收凭证。 +- 新任务将选择时的提供方指纹保存到现有`upstream_ids_json`,不增加数据库schema。消费者、心跳、checkpoint和完成均校验;旧任务缺少指纹或配置漂移不会静默改走新服务。已有结果不自动重分析,相同内容防重规则保持。 +- 探针状态保存为 `probe--.json`,旧身份报告保留。未知/已开始的同身份请求不自动重发;失败先核对已有请求ID及响应。不能删除报告来强制重试。 +- 需要先修复HTTPS并提供支持音频的模型/转写能力,然后完成短/中/长门禁,再对代表性真实录音人工回听、核对主体/否定/数值及时间。此步骤尚未完成,入口继续关闭。 + +### 本轮验证记录 + +ProviderConfig24、OpenAI音频传输47、Dify106、Media41、Probe14、隔离MySQL Core132、Worker20、Endpoint21、Access11、ProviderIntegration20均通过。音频传输单测采用受控响应/回环服务,不是外部ASR准确率;4个真实样本的287项检查只证明本地媒体与独立日期规则。网页未修改,38项录音组件测试回归通过。 + +完整证据在 `/Users/long/Work/zyt/artifacts/followup-audio-20260929/samples-20261007/`,主四角色路径沿用原交付;本地配置、真实音频和私有manifest都不纳入源码包。 diff --git a/server/.env.followup-audio.example b/server/.env.followup-audio.example index 2aa8ca041..7033ec25f 100644 --- a/server/.env.followup-audio.example +++ b/server/.env.followup-audio.example @@ -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. diff --git a/server/app/adminapi/logic/tcm/FollowupAudioLogic.php b/server/app/adminapi/logic/tcm/FollowupAudioLogic.php index 97ec8b7de..5c009100f 100644 --- a/server/app/adminapi/logic/tcm/FollowupAudioLogic.php +++ b/server/app/adminapi/logic/tcm/FollowupAudioLogic.php @@ -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('当前服务与模型的音频能力尚未验证,暂不接受真实录音或写入'); } } diff --git a/server/app/command/FollowupAudioProbe.php b/server/app/command/FollowupAudioProbe.php index 1bf57c144..3df2e8ff9 100644 --- a/server/app/command/FollowupAudioProbe.php +++ b/server/app/command/FollowupAudioProbe.php @@ -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' diff --git a/server/app/common/service/followupaudio/FollowupAudioDify.php b/server/app/common/service/followupaudio/FollowupAudioDify.php index 088aea147..04a312928 100644 --- a/server/app/common/service/followupaudio/FollowupAudioDify.php +++ b/server/app/common/service/followupaudio/FollowupAudioDify.php @@ -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); diff --git a/server/app/common/service/followupaudio/FollowupAudioException.php b/server/app/common/service/followupaudio/FollowupAudioException.php index 1fa7a2408..9d2b11b1a 100644 --- a/server/app/common/service/followupaudio/FollowupAudioException.php +++ b/server/app/common/service/followupaudio/FollowupAudioException.php @@ -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' => '录音处理副本仍超出已配置的上游限制,未发送,请联系管理员', diff --git a/server/app/common/service/followupaudio/FollowupAudioProviderConfig.php b/server/app/common/service/followupaudio/FollowupAudioProviderConfig.php new file mode 100644 index 000000000..12b17c29c --- /dev/null +++ b/server/app/common/service/followupaudio/FollowupAudioProviderConfig.php @@ -0,0 +1,86 @@ + 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; + } +} diff --git a/server/app/common/service/followupaudio/FollowupAudioStore.php b/server/app/common/service/followupaudio/FollowupAudioStore.php index ba0d9f6d8..fb8c1243e 100644 --- a/server/app/common/service/followupaudio/FollowupAudioStore.php +++ b/server/app/common/service/followupaudio/FollowupAudioStore.php @@ -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']; } diff --git a/server/app/common/service/followupaudio/FollowupAudioWorker.php b/server/app/common/service/followupaudio/FollowupAudioWorker.php index 770671f80..be2e0af2d 100644 --- a/server/app/common/service/followupaudio/FollowupAudioWorker.php +++ b/server/app/common/service/followupaudio/FollowupAudioWorker.php @@ -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); diff --git a/server/config/followup_audio.php b/server/config/followup_audio.php index 7a9d1070a..de40b13d2 100644 --- a/server/config/followup_audio.php +++ b/server/config/followup_audio.php @@ -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'])), ]; diff --git a/server/tests/FollowupAudioAccessTest.php b/server/tests/FollowupAudioAccessTest.php index cda203273..c639e3a83 100644 --- a/server/tests/FollowupAudioAccessTest.php +++ b/server/tests/FollowupAudioAccessTest.php @@ -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}`"); } } diff --git a/server/tests/FollowupAudioCoreTest.php b/server/tests/FollowupAudioCoreTest.php index 7a4cd1d2c..92378da8f 100644 --- a/server/tests/FollowupAudioCoreTest.php +++ b/server/tests/FollowupAudioCoreTest.php @@ -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(); diff --git a/server/tests/FollowupAudioDifyTest.php b/server/tests/FollowupAudioDifyTest.php index 57e8b6d3c..9bdb86a96 100644 --- a/server/tests/FollowupAudioDifyTest.php +++ b/server/tests/FollowupAudioDifyTest.php @@ -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'); diff --git a/server/tests/FollowupAudioEndpointContractTest.php b/server/tests/FollowupAudioEndpointContractTest.php index 71b10aedb..ded003e3b 100644 --- a/server/tests/FollowupAudioEndpointContractTest.php +++ b/server/tests/FollowupAudioEndpointContractTest.php @@ -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'); diff --git a/server/tests/FollowupAudioMediaTest.php b/server/tests/FollowupAudioMediaTest.php index 276c89aa9..cb5a2a9a7 100644 --- a/server/tests/FollowupAudioMediaTest.php +++ b/server/tests/FollowupAudioMediaTest.php @@ -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'])) { diff --git a/server/tests/FollowupAudioOpenAiTest.php b/server/tests/FollowupAudioOpenAiTest.php new file mode 100644 index 000000000..e26eef742 --- /dev/null +++ b/server/tests/FollowupAudioOpenAiTest.php @@ -0,0 +1,114 @@ +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); +} diff --git a/server/tests/FollowupAudioProbeCommandTest.php b/server/tests/FollowupAudioProbeCommandTest.php index bb0878a0a..116154954 100644 --- a/server/tests/FollowupAudioProbeCommandTest.php +++ b/server/tests/FollowupAudioProbeCommandTest.php @@ -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); } } diff --git a/server/tests/FollowupAudioProviderConfigTest.php b/server/tests/FollowupAudioProviderConfigTest.php new file mode 100644 index 000000000..19ea6c301 --- /dev/null +++ b/server/tests/FollowupAudioProviderConfigTest.php @@ -0,0 +1,61 @@ +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; diff --git a/server/tests/FollowupAudioProviderIntegrationTest.php b/server/tests/FollowupAudioProviderIntegrationTest.php new file mode 100644 index 000000000..9007a80d3 --- /dev/null +++ b/server/tests/FollowupAudioProviderIntegrationTest.php @@ -0,0 +1,75 @@ +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"; +} diff --git a/server/tests/FollowupAudioSampleAcceptanceTest.php b/server/tests/FollowupAudioSampleAcceptanceTest.php new file mode 100644 index 000000000..3fabebf7c --- /dev/null +++ b/server/tests/FollowupAudioSampleAcceptanceTest.php @@ -0,0 +1,317 @@ +"}], + * "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); +} diff --git a/server/tests/FollowupAudioWorkerTest.php b/server/tests/FollowupAudioWorkerTest.php index 5706b4ef2..9167d5f4d 100644 --- a/server/tests/FollowupAudioWorkerTest.php +++ b/server/tests/FollowupAudioWorkerTest.php @@ -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; }