面诊文字对话：后端与桌面端只读调查
调查日期：2026-10-20
范围：仅代码、SQL 定义与无数据库契约；未读取 .env/凭据，未连接数据库，未查询患者数据，未修改业务代码。

结论
1. Web 新增“面诊文字对话”页签可以直接复用现有 getCallRecords，不需要新增 ASR、转写写入或全文详情接口。
2. 一次面诊在当前数据契约里对应一条 tcm_call_record，以 id / call_record_id 标识。全文 transcript_text 已逐通话存储。
3. 当前 HTTP getCallRecords 有登录验证，但控制器和查询逻辑未做诊单行级可见性验证。应补 canViewReadonlyDiagnosis，不能仅靠前端页签权限。
4. 没有独立转写分段 read 接口。现有 HTTP 列表不返回 transcript_segments；仅需已保存的完整文字时不存在接口缺口，需要逐句时间戳/说话人结构时才需要额外 read 能力。

一、现有读取接口与字段
- API：GET /adminapi/tcm.diagnosis/getCallRecords?diagnosis_id=<positive diagnosis id>。
  前端请求封装使用 /tcm.diagnosis/getCallRecords，admin/src/api/tcm.ts:226。
- 控制器：server/app/adminapi/controller/tcm/DiagnosisController.php:430，读取 GET 参数，仅验证 diagnosis_id 非空，随后调用 DiagnosisLogic::getCallRecords。
- 逻辑：server/app/adminapi/logic/tcm/DiagnosisLogic.php:1816。
  CallRecord::where('diagnosis_id', $params['diagnosis_id'])->order('id', 'desc')->select()->toArray()。
  无 page_no/page_size/limit/paginate/count；返回该诊单全部通话记录，顺序为 id 降序，不应假设是按 appointment 分页。
- JSON 外层沿用 JsonService::data：{code: 1, show: 0, msg: '', data: [<record>, ...]}。
  Web request 封装和桌面 client 均使用解包后的数组。
- 返回完整 CallRecord 行，另添加：start_time_text、end_time_text、duration_text、recording_urls_list、recording_status_text、local_audio_urls_list、local_audio_status_text、transcription_status_text。
- 通话核心字段：id、diagnosis_id、caller_id、caller_type、callee_id、callee_type、call_type(1语音/2视频)、status(1进行中/2已结束/3未接听/4已取消)、start_time/end_time（Unix秒）、duration（秒）、room_id。
- 转写字段（2026_08_12_add_call_transcription_fields.sql）：
  transcription_session_id: string <=128
  transcription_language: string <=32
  transcription_status: '' | running | completed | partial | failed
  transcription_segment_count: unsigned int
  transcript_text: nullable longtext，全通话转写，包含“医生：/患者：/未知说话人：”前缀与换行
  transcription_started_at / transcription_finished_at: Unix 秒，未发生时为0
- 状态展示：running=录音转写中；completed=文字已生成；partial=文字部分保存；failed=文字生成失败；其他/空值=未生成文字。
- start_time_text/end_time_text 由服务端 date('Y-m-d H:i:s') 生成；不要在前端将此字符串当 UTC 再转换。
- 查询异常当前被 logic 捕获后返回 []，控制器仍返回成功包装。前端能显示空记录，但无法靠此接口区分“真空列表”与后端查询异常；本次最小接入不应暗示不存在的数据一定已生成。

二、分段格式与持久化
- 服务端表：zyt_tcm_call_transcript_segment（实际表前缀由部署决定）。
  字段：id bigint、call_record_id int、transcription_session_id string、segment_id string、speaker_user_id string、speaker_role string、timestamp_ms bigint、text string、create_time/update_time Unix秒。
- 唯一键：(call_record_id, transcription_session_id, segment_id)，防止同一完成分段重试时重复存储。
- 写入接口 upsertCallTranscriptSegments 的 segments 数组项：
  {segment_id: string<=160, speaker_user_id: string<=160, speaker_role: 'doctor'|'patient'|'unknown', timestamp: nonnegative integer, text: nonempty string<=4000 chars}
  每批1到50条；未知 role 被规范为 unknown；timestamp 写入为 timestamp_ms，不应混用随访录音的 start_ms/end_ms/channel 分段格式。
- snapshot 读取按 call_record_id AND transcription_session_id 过滤，按 timestamp_ms ASC, id ASC 排序，将每项拼成角色标签 + '：' + 文本，用换行连接到 transcript_text，并同步 transcription_segment_count。
  代码：DiagnosisLogic.php:2130 buildCallTranscriptSnapshot。
- 启动/上载/结束是 POST startCallTranscription、upsertCallTranscriptSegments、finishCallTranscription。它们不是读取接口。
  start：要求该 call 仍为 status=1；同一 record 已绑定其他 session 拒绝。
  upsert：session 必须匹配且 transcription_status=running，保存后即可在列表中看到已固化的部分全文。
  finish：期望完成条数大于实际条数时将 completed 降为 partial；结束后保留已存文本。
- 不应仅按 status=completed 才允许查看文字：running/partial/failed 也可能已有有效的 transcript_text。

三、数据隔离与权限核对
- call_record_id / diagnosis_id：写入路径 resolveCallRecordForTranscription 用 id=call_record_id AND diagnosis_id AND caller_id=当前管理员 AND caller_type='doctor'，防止串记录与越权写；快照再以 session 筛选。
- HTTP read：getCallRecords 目前只有 diagnosis_id WHERE，未使用 admin_id/adminInfo，也未调用 canViewReadonlyDiagnosis。CallRecord 和 BaseModel 没有隐藏的 DataScope 全局过滤。
  因此诊单 A 的请求不会混入诊单 B 的行，但调用者能否读诊单 A 没有在该业务入口校验。前端对诊单详情已授权不能替代每个 read 入口的独立校验。
- 现成行域入口：DiagnosisLogic::canViewReadonlyDiagnosis (DiagnosisLogic.php:4339)。验证正数诊单/管理员、排除被删除诊单；医助 role2 限本人 assistant_id；启用 DataScope 时按 visibleAdminIds 收窄；不存在与越权共用错误文案。
- 全局 LoginMiddleware 要求 token（此控制器没有免登录声明）；AuthMiddleware 验证登录 IP 和菜单权限，但未登记的 action 会走 legacy bypass。没有任何通用行级诊单过滤。
- 转写 POST 的权限迁移 2026_08_12_call_transcription_permissions.sql，将三个写操作作为 tcm.diagnosis/video-call 子权限分配；不能将这一“发起通话”权限直接作为历史文字查看的唯一权限。
- 现有 Web CallRecordPanel 和桌面诊单视频Tab均以 tcm.diagnosis/huifang 控制可见。兼容的最小 read 约束是：在 AuthMiddleware 未注册路由放行逻辑之前，针对规范化后的 tcm.diagnosis/getcallrecords 精确判断管理员具有 tcm.diagnosis/huifang 或 tcm.diagnosis/getcallrecords；保留既有 root 豁免，然后由控制器执行 canViewReadonlyDiagnosis。
  不要给历史 read 添加 edit 强制条件，也不要限定 caller_id=当前用户，否则可见诊单内其他医生的历史通话会被误挡。新的文字Tab应复用 huifang，或与直接getCallRecords权限做同样OR；权限撤回时退回基本资料并清空文字。
- MCP catalog/review/tcm.php:86 对 getCallRecords 有显式 canViewReadonlyDiagnosis guard。但该 guard 只用于 MCP 包装入口，不能证明 HTTP 控制器已经受到保护。
- appointment_id：仅 getCallSignature/startCall 用 AppointmentCallPolicy 检查允许通话方式；startCall 创建记录没有写 appointment_id。当前 CallRecord SQL与模型也没有该字段。
  因此按 diagnosis_id 返回的是该诊单历次通话，不能声称按 appointment_id 精确隔离。当前需求按每条通话分组可实现；如果产品必须对应某一挂号/预约，则需要持久化 appointment_id 与历史迁移策略，超出此次最小只读接入范围。
- 2026-10-24医生网页面诊停用策略只拦签名/发起动作，不拦 getCallRecords；历史文字仍可查看，符合本次读取功能。

四、native 桌面端现状
- app/src/doctor_workstation/services/repository.py:2448 list_call_records：只校验诊单ID>0，然后 GET tcm.diagnosis/getCallRecords，参数只有 diagnosis_id；没有逐条 transcript 详情请求。
- ui/dialogs/diagnosis.py:2788，在视频Tab懒加载 list_call_records / get_call_records；返回时以 diagnosis_id 和 generation 双重校验，防止旧请求覆盖新诊单。
- ui/dialogs/diagnosis.py:3886 _call_transcript_text：先读 transcript_text，兼容 call_transcript/conversation_text/transcript；只有没有全文时才尝试 transcript_segments/segments，将 speaker_role 映射为中文角色并拼接文本。
  这个 fallback 是兼容能力，不能当作服务器列表实际返回 segments 的证据。
- ui/dialogs/diagnosis.py:4074：某行有文字且 call_record_id>0 时显示“查看文字”，将该行全文与id直接传入只读 QPlainTextEdit 对话框。无网络详情调用，也不会合并其他通话。
- mock_repository.py 中示例 record 的 transcript_segments 项格式与存储表不同：写入上来的 segment 字典被保留，通常具有 segment_id/speaker_user_id/speaker_role/timestamp/text；不能据 mock 声称真实 HTTP 有此字段。
- 已有桌面测试 app/tests/test_diagnosis_order_video_visual.py:479 覆盖云端回放、本机音频、转写文字分别显示，并将“查看文字”按钮绑定该行id。

五、建议的最小 Web 接入
- 新增“面诊文字对话”Tab，按当前 diagnosis_id 懒加载现有 getCallRecords。
- 以 record.id 选中单次通话，显示开始时间、时长、通话/转写状态与 transcription_segment_count；全文取该行 transcript_text，以纯文本保留换行显示（不要 v-html）。
- 数据仍按服务端 id 降序，默认选最新有文字记录或最新记录；列表保留没有文字的通话，说明未生成/进行中/部分保存/失败，避免误解为缺少该次面诊。
- 诊单切换时先清空 records/selection；用请求序号或 diagnosis_id 检查忽略旧响应；同诊单手动刷新尽量保留选中的 record.id。
- 如果全部记录都没有全文，应显示“暂无已保存的面诊文字”，不能提示点击生成/补采集，因为本次不接 ASR 与写接口。
- 不要求新后端 detail 接口、不增数据表、不调用转写 POST。同步补齐既有 read 的行域和功能权限即能满足本次范围。
- 若将来需要细粒度分段，则新增只读详情必须接受诊单id+通话id，并服务端验证 call_record_id属于该诊单、诊单可见；查询以记录当前 transcription_session_id精确过滤，分页稳定按timestamp_ms,id，切勿仅凭传入session读任意分段。

六、无数据库验证
1. 已运行：php server/tests/CallRecordLifecycleIdentityContractTest.php
   结果：Call-record lifecycle identity contract: OK（反射/源码契约，无数据库）。
2. 已运行仅 pathlib 读取源码的 Python 合同检查，13项全部 PASS：
   diagnosis WHERE、id降序、无分页、read不加载分段、状态标签、snapshot call+session双过滤、稳定排序、换行拼接、longtext迁移、唯一键、桌面diagnosis-only GET、桌面先取全文、桌面旧响应保护。
3. 未跑网络/数据库接口；不能确认各部署实际迁移执行情况、菜单登记数据、既存患者是否有转写。上线前由已有后端流程保证转写字段迁移已部署。

本报告完成时未修改业务文件；仅新增本调查交付文件。以上行号为调查时定位，主线程随后补授权时可能改变。
