fix: preserve typed followup audio review request payloads

This commit is contained in:
2026-10-09 17:13:21 +08:00
parent 4d0af7f3f4
commit 9185c6db04
2 changed files with 43 additions and 6 deletions
@@ -81,14 +81,14 @@ final class FollowupAudioController extends BaseAdminController
return $this->handle(true, ['id', 'version', 'items', 'channel_roles'], fn (array $p): array =>
Logic::saveDraft($this->positive($p, 'id'), $this->positive($p, 'version'),
$this->items($p), $this->adminId, $this->adminInfo,
array_key_exists('channel_roles', $p) ? $this->textValue($p, 'channel_roles', 20) : null));
array_key_exists('channel_roles', $p) ? $this->textValue($p, 'channel_roles', 20) : null), true);
}
public function apply()
{
return $this->handle(true, ['id', 'version', 'items'], fn (array $p): array =>
Logic::apply($this->positive($p, 'id'), $this->positive($p, 'version'),
$this->items($p), $this->adminId, $this->adminInfo));
$this->items($p), $this->adminId, $this->adminInfo), true);
}
public function retry()
@@ -106,12 +106,14 @@ final class FollowupAudioController extends BaseAdminController
});
}
private function handle(bool $post, array $allowed, callable $handler)
private function handle(bool $post, array $allowed, callable $handler, bool $typedJson = false)
{
if ($post ? !$this->request->isPost() : !$this->request->isGet()) {
return $this->fail('请求方式错误');
}
$params = $post ? $this->request->post() : $this->request->get();
// Review JSON carries strict booleans/integers and exact source evidence. Global trim
// would stringify scalars and alter quotations; keep all explicit validators below.
$params = $post ? $this->request->post('', null, $typedJson ? null : '') : $this->request->get();
if (array_diff(array_keys($params), $allowed) !== []) {
return $this->fail('请求包含不支持的字段');
}
+37 -2
View File
@@ -13,6 +13,7 @@ use app\common\service\followupaudio\FollowupAudioPipelineCheckpoint as Checkpoi
use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
use app\adminapi\logic\tcm\FollowupAudioLogic as Logic;
use think\facade\Db;
use app\adminapi\controller\tcm\FollowupAudioController;
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23319) { throw new RuntimeException('Preview tests require dedicated disposable23319'); }
$provider = ['driver' => 'asr_then_llm', 'label' => 'Synthetic preview',
@@ -63,6 +64,16 @@ $transport = static function (array $spec) use (&$active, &$calls, &$mode, $set,
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['message_id' => 'preview-dify-message', 'answer' => json_encode($answer, JSON_UNESCAPED_UNICODE)])];
};
$run = static fn (): bool => (new Worker(new Dify($transport)))->runOnce();
// Exercise the real app Request filter and public controller, not just direct Logic calls.
$requestAction = static function (string $action, array $body) use ($f): array {
$request = (new app\Request())->withServer(['REQUEST_METHOD' => 'POST'])
->withPost(json_decode(json_encode($body, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR));
$controller = (new ReflectionClass(FollowupAudioController::class))->newInstanceWithoutConstructor();
foreach (['request' => $request, 'adminId' => 1, 'adminInfo' => $f['actor']] as $key => $value) {
$property = new ReflectionProperty($controller, $key); $property->setAccessible(true); $property->setValue($controller, $value);
}
return $controller->$action()->getData();
};
try {
foreach (['', 'name-only', '1,,2', '0', '-1', ['1', null], ['id' => 1]] as $ids) {
$set(['test_diagnosis_ids' => $ids]); $cap = Logic::capabilities(1, 1, $f['actor']);
@@ -92,10 +103,34 @@ try {
$detail = Logic::detail($id, 1, $f['actor']);
$expect($detail['status'] === 'review' && $detail['preview_only'] && $detail['channel_roles'] === 'unconfirmed' && $calls === ['asr' => 2, 'extraction' => 1], 'allowed preview reaches transcript/review only');
$items = $detail['items']; $items[0]['selected'] = true; $items[0]['needs_review'] = false;
$saved = Logic::saveDraft($id, $detail['version'], $items, 1, $f['actor'], 'left_service');
$response = $requestAction('saveDraft', ['id' => $id, 'version' => $detail['version'], 'items' => $items, 'channel_roles' => 'left_service']);
$expect($response['code'] === 1, 'HTTP draft save: ' . $response['msg']);
$saved = $response['data'];
$expect($saved['channel_roles'] === 'left_service' && !$saved['items'][0]['selected'], 'preview role and draft remain usable');
$items = $saved['items']; $items[0]['selected'] = true; $items[0]['needs_review'] = false;
$saved = Logic::saveDraft($id, $saved['version'], $items, 1, $f['actor']);
$response = $requestAction('saveDraft', ['id' => $id, 'version' => $saved['version'], 'items' => $items]);
$expect($response['code'] === 1, 'HTTP repeated draft save: ' . $response['msg']);
$saved = $response['data'];
$expect($saved['items'][0]['selected'] === true && $saved['items'][0]['needs_review'] === false,
'JSON booleans remain booleans through the request/controller/database round trip');
$expect($saved['items'][0]['evidence'] === $items[0]['evidence'], 'immutable evidence and numeric channel/timestamps retained');
$body = ['id' => $id, 'version' => $saved['version'], 'items' => $saved['items']];
$invalid = $body; $invalid['items'][0]['evidence'][0]['text'] .= 'forged';
$response = $requestAction('saveDraft', $invalid);
$expect($response['code'] === 0 && $response['msg'] === 'FOLLOWUP_AUDIO_IMMUTABLE_FIELD', 'evidence tampering remains rejected');
$invalid = $body; $invalid['items'][0]['selected'] = 'true';
$response = $requestAction('saveDraft', $invalid);
$expect($response['code'] === 0 && $response['msg'] === 'FOLLOWUP_AUDIO_BOOLEAN_INVALID', 'string boolean remains invalid');
$invalid = $body; $invalid['items'][0]['target_id'] = '1';
$response = $requestAction('saveDraft', $invalid);
$expect($response['code'] === 0 && $response['msg'] === 'FOLLOWUP_AUDIO_TARGET_INVALID', 'string target remains invalid');
$response = $requestAction('saveDraft', $body + ['unlisted' => true]);
$expect($response['code'] === 0 && $response['msg'] === '请求包含不支持的字段', 'request key allowlist retained');
$response = $requestAction('apply', $body);
$expect($response['code'] === 0 && $response['msg'] === 'FOLLOWUP_AUDIO_PREVIEW_ONLY', 'actual apply endpoint still hard-denies preview');
$expect(Store::detail($id)['version'] === $saved['version'], 'rejected requests never advance the draft version');
$legacy = (new app\Request())->withPost(['selected' => false, 'text' => ' legacy '])->post();
$expect($legacy['selected'] === '' && $legacy['text'] === 'legacy', 'unrelated endpoints retain original global trim');
$reject(fn () => Logic::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
$reject(fn () => Apply::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
$set(['audio_verified' => true, 'verified_profiles' => ['qwen']]);