['min_range' => 1]]) === false) { throw new DomainException('录音补转写身份参数无效'); } } foreach (['recovery_id', 'expected_session_id', 'expected_status', 'audio_url', 'transcript_text'] as $key) { if (!isset($params[$key]) || !is_string($params[$key])) { throw new DomainException('录音补转写参数无效'); } } if (!preg_match('/\Aaudio-[a-f0-9]{32}\z/D', $params['recovery_id']) || mb_strlen($params['expected_session_id']) > 128 || !in_array($params['expected_status'], ['', 'pending', 'running', 'partial', 'failed', 'completed'], true) || trim($params['audio_url']) === '' || strlen($params['audio_url']) > 8192) { throw new DomainException('录音补转写会话参数无效'); } $text = trim($params['transcript_text']); if ($text === '' || mb_strlen($params['transcript_text']) > 100000) { throw new DomainException('录音补转写文字必须为1到100000个字符'); } return Db::transaction(static function () use ($params, $text): array { // The realtime upsert/finish endpoints lock the same row, so a late // old-session write cannot run after this session switch succeeds. $record = CallRecord::where('id', (int) $params['call_record_id']) ->where('diagnosis_id', (int) $params['diagnosis_id']) ->where('caller_id', (int) $params['admin_id']) ->where('caller_type', 'doctor')->lock(true)->find(); if (!$record) { throw new DomainException('通话记录不存在或无权操作'); } if ((int) $record['status'] !== 2) { throw new DomainException('通话结束后才能补转写'); } $urls = json_decode((string) ($record['local_audio_urls'] ?? ''), true); if ((int) ($record['local_audio_status'] ?? 0) !== 2 || !is_array($urls) || !in_array($params['audio_url'], $urls, true)) { throw new DomainException('录音尚未上传或不属于当前通话'); } $result = ['call_record_id' => (int) $record['id'], 'transcription_status' => 'completed']; $session = (string) ($record['transcription_session_id'] ?? ''); $status = (string) ($record['transcription_status'] ?? ''); if ($status === 'completed' && trim((string) ($record['transcript_text'] ?? '')) !== '') { // Lost response retries succeed even with the original CAS values; // a different completed session always wins without being changed. return $session === $params['recovery_id'] ? $result + ['saved' => true] : $result + ['saved' => false, 'already_complete' => true]; } if ($session !== $params['expected_session_id'] || $status !== $params['expected_status']) { throw new DomainException('通话转写状态已变化,请刷新后重试'); } $now = time(); $segments = []; $lines = []; // Recognition supplied no diarization or alignment. Do not invent // doctor/patient attribution or timestamps; preserve source order by id. for ($offset = 0, $index = 0, $length = mb_strlen($text); $offset < $length; $offset += 4000, $index++) { $part = mb_substr($text, $offset, 4000); $segments[] = [ 'call_record_id' => (int) $record['id'], 'transcription_session_id' => $params['recovery_id'], 'segment_id' => $params['recovery_id'] . '-' . $index, 'speaker_user_id' => '', 'speaker_role' => 'unknown', 'timestamp_ms' => 0, 'text' => $part, 'create_time' => $now, 'update_time' => $now, ]; $lines[] = '未知说话人:' . $part; } // Keep all old-session segments for audit; the current session is the // only one consumed by transcript readers and clinical context builders. Db::name('tcm_call_transcript_segment')->insertAll($segments); $record->save([ 'transcription_session_id' => $params['recovery_id'], 'transcription_status' => 'completed', 'transcription_segment_count' => count($segments), 'transcript_text' => implode("\n", $lines), 'transcription_started_at' => $now, 'transcription_finished_at' => $now, 'update_time' => $now, ]); return $result + ['saved' => true]; }); } public static function completionStatus(string $requested, int $actual, int $expected, string $text): string { if ($requested !== 'completed') { return $requested; } if ($actual === 0 || trim($text) === '') { return 'failed'; } return $actual < $expected ? 'partial' : 'completed'; } }