$value) { $this[$key] = $value; } } } final class RecordQuery { private array $filters = []; private bool $locked = false; public function where(string $key, $value): self { $this->filters[$key] = $value; return $this; } public function lock(bool $locked): self { $this->locked = $locked; return $this; } public function find(): ?Record { if (!$this->locked || !\think\facade\Db::$transaction) { throw new \RuntimeException('Missing transaction row lock'); } foreach (\app\common\model\tcm\CallRecord::$rows as $row) { foreach ($this->filters as $key => $value) { if (($row[$key] ?? null) !== $value) { continue 2; } } return $row; } return null; } } final class SegmentQuery { private array $filters = []; public function where(string $key, $value): self { $this->filters[$key] = $value; return $this; } public function order(string $order): self { return $this; } public function select(): self { return $this; } public function toArray(): array { return array_values(array_filter(\think\facade\Db::$segments, function (array $row): bool { foreach ($this->filters as $key => $value) { if ($row[$key] !== $value) { return false; } } return true; })); } public function insertAll(array $rows): void { if (!\think\facade\Db::$transaction) { throw new \RuntimeException('Segment write outside transaction'); } foreach ($rows as $row) { foreach (\think\facade\Db::$segments as $existing) { if ($row['call_record_id'] === $existing['call_record_id'] && $row['transcription_session_id'] === $existing['transcription_session_id'] && $row['segment_id'] === $existing['segment_id']) { throw new \RuntimeException('Duplicate segment'); } } \think\facade\Db::$segments[] = $row; } } } } namespace app\common\model\tcm { final class CallRecord { public static array $rows = []; public static function where(string $key, $value): \recovery_test\RecordQuery { return (new \recovery_test\RecordQuery())->where($key, $value); } } } namespace think\facade { final class Db { public static bool $transaction = false; public static bool $failSave = false; public static array $segments = []; public static function transaction(callable $callback) { $rows = array_map(static fn ($row) => clone $row, \app\common\model\tcm\CallRecord::$rows); $segments = self::$segments; self::$transaction = true; try { return $callback(); } catch (\Throwable $e) { \app\common\model\tcm\CallRecord::$rows = $rows; self::$segments = $segments; throw $e; } finally { self::$transaction = false; } } public static function name(string $name): \recovery_test\SegmentQuery { if ($name !== 'tcm_call_transcript_segment') { throw new \RuntimeException('Unexpected table'); } return new \recovery_test\SegmentQuery(); } } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\adminapi\logic\tcm\DiagnosisLogic as Logic; use app\common\model\tcm\CallRecord; use think\facade\Db; $assertions = 0; function check(bool $condition, string $message): void { global $assertions; if (!$condition) { throw new RuntimeException($message); } $assertions++; } function fixture(array $changes = []): array { CallRecord::$rows = [new recovery_test\Record(array_replace([ 'id' => 17, 'diagnosis_id' => 23, 'caller_id' => 91, 'caller_type' => 'doctor', 'status' => 2, 'local_audio_status' => 2, 'local_audio_urls' => '["https://audio.invalid/synthetic.webm"]', 'transcription_session_id' => 'original-session', 'transcription_status' => 'partial', 'transcription_segment_count' => 1, 'transcript_text' => '未知说话人:片段', ], $changes))]; Db::$segments = [['call_record_id' => 17, 'transcription_session_id' => 'original-session', 'segment_id' => 'old-1', 'text' => '片段', 'speaker_role' => 'unknown', 'timestamp_ms' => 10]]; Db::$failSave = false; return ['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91, 'recovery_id' => 'audio-' . str_repeat('a', 32), 'expected_session_id' => 'original-session', 'expected_status' => 'partial', 'audio_url' => 'https://audio.invalid/synthetic.webm', 'transcript_text' => "完整的合成录音。\n保留结尾及否定:没有服药。"]; } function rejects(array $params, string $label): void { $before = serialize([CallRecord::$rows, Db::$segments]); check(Logic::saveLocalAudioTranscript($params) === false, $label . ' rejects'); check(serialize([CallRecord::$rows, Db::$segments]) === $before, $label . ' does not mutate'); } $params = fixture(); $result = Logic::saveLocalAudioTranscript($params); check($result === ['call_record_id' => 17, 'transcription_status' => 'completed', 'saved' => true], 'recovery saves'); check(CallRecord::$rows[0]['transcription_session_id'] === $params['recovery_id'], 'new session is active'); check(CallRecord::$rows[0]['transcript_text'] === '未知说话人:' . $params['transcript_text'], 'complete text retained without claiming a speaker'); check(count(Db::$segments) === 2 && Db::$segments[0]['text'] === '片段', 'old session retained'); check(Db::$segments[1]['speaker_role'] === 'unknown' && Db::$segments[1]['timestamp_ms'] === 0, 'no invented attribution or timing'); $before = serialize([CallRecord::$rows, Db::$segments]); check(Logic::saveLocalAudioTranscript($params) === $result, 'lost response retry is idempotent despite stale CAS'); check(serialize([CallRecord::$rows, Db::$segments]) === $before, 'retry writes no duplicate segments'); $late = Logic::upsertCallTranscriptSegments(['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91, 'transcription_session_id' => 'original-session', 'segments' => [['segment_id' => 'late', 'text' => '迟到分段']]]); check($late === false, 'old realtime writer cannot overwrite replacement session'); $lateFinish = Logic::finishCallTranscription(['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91, 'transcription_session_id' => 'original-session', 'status' => 'completed', 'expected_segment_count' => 1]); check($lateFinish === false && serialize([CallRecord::$rows, Db::$segments]) === $before, 'late finish cannot overwrite replacement'); $params = fixture(['transcription_status' => 'completed']); $before = serialize([CallRecord::$rows, Db::$segments]); check(Logic::saveLocalAudioTranscript($params) === ['call_record_id' => 17, 'transcription_status' => 'completed', 'saved' => false, 'already_complete' => true], 'another complete session wins even with stale CAS'); check(serialize([CallRecord::$rows, Db::$segments]) === $before, 'completed transcript never overwritten'); foreach (['', 'pending', 'running', 'partial', 'failed', 'completed'] as $status) { $params = fixture(['transcription_status' => $status, 'transcript_text' => '']); $params['expected_status'] = $status; check(Logic::saveLocalAudioTranscript($params)['saved'] === true, 'recover empty/nonterminal session ' . $status); } $params = fixture(['transcription_session_id' => '', 'transcription_status' => '', 'transcript_text' => '']); $params['expected_session_id'] = $params['expected_status'] = ''; check(Logic::saveLocalAudioTranscript($params)['saved'] === true, 'record without prior session can recover'); foreach (['expected_session_id' => 'changed-session', 'expected_status' => 'running', 'audio_url' => 'https://audio.invalid/different.webm', 'admin_id' => 92, 'diagnosis_id' => 24, 'call_record_id' => 18, 'recovery_id' => 'invalid', 'transcript_text' => ' '] as $key => $value) { $params = fixture(); $params[$key] = $value; rejects($params, 'invalid ' . $key); } foreach ([['status' => 1], ['status' => 3], ['caller_type' => 'patient'], ['local_audio_status' => 0], ['local_audio_urls' => 'invalid'], ['local_audio_urls' => '[]']] as $changes) { rejects(fixture($changes), 'invalid record ' . json_encode($changes)); } foreach ([null, [], false, 23] as $value) { $params = fixture(); $params['transcript_text'] = $value; rejects($params, 'non-string text'); } $params = fixture(); $params['transcript_text'] = str_repeat('字', 100001); rejects($params, 'oversize text'); $params = fixture(); $params['transcript_text'] = str_repeat('字', 100000); check(Logic::saveLocalAudioTranscript($params)['saved'] === true, 'Unicode limit counts characters'); $newSegments = array_slice(Db::$segments, 1); check(count($newSegments) === 25 && implode('', array_column($newSegments, 'text')) === $params['transcript_text'], 'bounded segments preserve all 100000 characters in order'); check(max(array_map(static fn ($row) => mb_strlen($row['text']), $newSegments)) <= 4000, 'segment schema limit respected'); $params = fixture(); Db::$failSave = true; rejects($params, 'transaction rollback after segment insertion'); check(!str_contains(Logic::getError(), 'Synthetic'), 'internal failure detail is not exposed'); fixture(['transcription_status' => 'running', 'transcript_text' => '', 'transcription_segment_count' => 0]); Db::$segments = []; $finish = ['diagnosis_id' => 23, 'call_record_id' => 17, 'admin_id' => 91, 'transcription_session_id' => 'original-session', 'status' => 'completed', 'expected_segment_count' => 0]; check(Logic::finishCallTranscription($finish)['status'] === 'failed', 'zero-segment completion is failed'); fixture(['transcription_status' => 'running']); $finish['expected_segment_count'] = 2; check(Logic::finishCallTranscription($finish)['status'] === 'partial', 'missing requested segments stays partial'); fixture(['transcription_status' => 'running']); $finish['expected_segment_count'] = 1; check(Logic::finishCallTranscription($finish)['status'] === 'completed', 'nonempty complete realtime session still completes'); echo "Local audio transcript recovery: {$assertions} assertions passed (no DB or network)\n"; }