feat: configure audio providers and add local sample acceptance

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