handle(false, ['diagnosis_id'], fn (array $p): array => Logic::capabilities($this->positive($p, 'diagnosis_id'), $this->adminId, $this->adminInfo)); } public function uploadSession() { return $this->handle(true, ['diagnosis_id', 'file_name', 'total_bytes'], function (array $p): array { Logic::requireEnabled(true); return Upload::createSession($this->positive($p, 'diagnosis_id'), $this->textValue($p, 'file_name', 240), $this->positive($p, 'total_bytes'), $this->adminId, $this->adminInfo); }); } public function uploadChunk() { return $this->handle(true, ['upload_id', 'index'], function (array $p): array { Logic::requireEnabled(true); $index = $p['index'] ?? null; if (!(is_int($index) || is_string($index)) || !preg_match('/^\d{1,5}$/D', (string) $index)) { throw new DomainException('录音分片序号无效'); } $file = $this->request->file('file'); if (!$file instanceof \think\file\UploadedFile || !$file->isValid()) { throw new DomainException('录音分片上传失败,请检查文件大小限制'); } return Upload::putChunk($this->textValue($p, 'upload_id', 64), (int) $index, $file->getPathname(), $this->adminId, $this->adminInfo); }); } public function uploadComplete() { return $this->handle(true, ['upload_id'], function (array $p): array { Logic::requireEnabled(true); return Upload::complete($this->textValue($p, 'upload_id', 64), $this->adminId, $this->adminInfo); }); } public function create() { return $this->handle(true, ['diagnosis_id', 'upload_id', 'recorded_at', 'model_key', 'channel_roles'], fn (array $p): array => Logic::create([ 'diagnosis_id' => $this->positive($p, 'diagnosis_id'), 'upload_id' => $this->textValue($p, 'upload_id', 64), 'recorded_at' => $this->textValue($p, 'recorded_at', 19), 'model_key' => $this->textValue($p, 'model_key', 16), // Preserve raw JSON type and exact enum, including null/whitespace rejection. 'channel_roles' => array_key_exists('channel_roles', $p) ? $p['channel_roles'] : 'unconfirmed', ], $this->adminId, $this->adminInfo), true); } public function lists() { return $this->handle(false, ['diagnosis_id'], fn (array $p): array => Logic::lists($this->positive($p, 'diagnosis_id'), $this->adminId, $this->adminInfo)); } public function detail() { return $this->handle(false, ['id'], fn (array $p): array => Logic::detail($this->positive($p, 'id'), $this->adminId, $this->adminInfo)); } public function saveDraft() { 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), 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), true); } public function retry() { return $this->handle(true, ['id'], fn (array $p): array => Logic::retry($this->positive($p, 'id'), $this->adminId, $this->adminInfo)); } public function audio() { return $this->handle(false, ['id'], function (array $p): FollowupAudioStream { $task = Access::task($this->positive($p, 'id'), $this->adminId, $this->adminInfo); $upload = Upload::session((string) $task['upload_id']); return new FollowupAudioStream(Upload::path($upload), (string) $upload['extension']); }); } private function handle(bool $post, array $allowed, callable $handler, bool $typedJson = false) { if ($post ? !$this->request->isPost() : !$this->request->isGet()) { return $this->fail('请求方式错误'); } // 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('请求包含不支持的字段'); } try { $this->adminInfo = Access::actor($this->adminId); $result = $handler($params); return $result instanceof \think\Response ? $result : $this->data($result); } catch (DomainException $e) { if (str_contains($e->getMessage(), 'FOLLOWUP_AUDIO_STALE_REVIEW')) { return $this->fail('病历或日常记录已发生变化,请重新审阅差异后确认', [ 'code' => 'FOLLOWUP_AUDIO_STALE_REVIEW', ]); } return $this->fail($e->getMessage()); } catch (\Throwable $e) { // Never expose raw SQL, audio paths, transcripts, provider responses or keys. return $this->fail('回访录音服务暂不可用,请联系管理员检查部署'); } } private function positive(array $p, string $key): int { $raw = $p[$key] ?? null; if (!(is_int($raw) || is_string($raw)) || !preg_match('/^[1-9]\d{0,17}$/D', (string) $raw)) { throw new DomainException('记录标识或版本无效'); } return (int) $raw; } private function textValue(array $p, string $key, int $max): string { $raw = $p[$key] ?? ''; if (!is_string($raw) || strlen($raw) > $max) { throw new DomainException('文本参数无效'); } return trim($raw); } private function items(array $p): array { $items = $p['items'] ?? null; if (!is_array($items) || !array_is_list($items) || count($items) > 500 || strlen(json_encode($items, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)) > 2097152) { throw new DomainException('审阅项目无效或数量超限'); } return $items; } }