From 9185c6db045fb9ec8a120b9139d9493ace247e70 Mon Sep 17 00:00:00 2001 From: long <452591453@qq.com> Date: Fri, 9 Oct 2026 17:13:21 +0800 Subject: [PATCH] fix: preserve typed followup audio review request payloads --- .../tcm/FollowupAudioController.php | 10 +++-- server/tests/FollowupAudioPreviewTest.php | 39 ++++++++++++++++++- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/server/app/adminapi/controller/tcm/FollowupAudioController.php b/server/app/adminapi/controller/tcm/FollowupAudioController.php index 10fd62a3e..544780a66 100644 --- a/server/app/adminapi/controller/tcm/FollowupAudioController.php +++ b/server/app/adminapi/controller/tcm/FollowupAudioController.php @@ -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('请求包含不支持的字段'); } diff --git a/server/tests/FollowupAudioPreviewTest.php b/server/tests/FollowupAudioPreviewTest.php index 4d8f4c4b3..6fe53997f 100644 --- a/server/tests/FollowupAudioPreviewTest.php +++ b/server/tests/FollowupAudioPreviewTest.php @@ -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']]);