# 医生工作站最终发布 parity 审计 审计日期:2026-08-10 审计对象:`D:\web\zyt\app` 当前源码 唯一 Web 基准:`D:\web\zyt\admin\src\views` 及其直接引用的 `src/api`、`src/components`、`src/utils` 判定范围:医生桌面五页面(接诊台、我的患者、我的问诊、我的处方库、已开处方)及其 `core/services/video` 支撑合同。 ## 1. 发布结论 **RELEASE CANDIDATE。** 原报告的 3 个 P0 与 10 个 P1 已全部关闭: - P0:`CLOSED 3 / OPEN 0` - P1:`CLOSED 10 / OPEN 0` - 当前未发现医生桌面范围内的发布阻断项。 - 处方预约上下文、附件上传和预约 ID 语义等高风险路径均有反例测试。 - 全量测试:**140 项通过**。 - 静态检查:`ruff check src tests` 通过;`ruff format --check src tests` 显示 **53 files already formatted**。 本结论是客户端源码、管理端基准和纯本地测试合同的发布复审结论;服务端生产权限、数据归属和真实第三方视频可用性仍应由部署环境验收。 ## 2. OPEN / CLOSED 总表 | 编号 | 原问题 | 最终状态 | parity 结论 | |---|---|---:|---:| | P0-1 | 接诊附件未 multipart 上传并可能泄漏本机路径 | **CLOSED** | **EXACT** | | P0-2 | appointment 处方查询回退 diagnosis 历史处方,缺 `case_record` | **CLOSED** | **EXACT / fail-closed 加固** | | P0-3 | 我的患者预约误用真实患者 ID,排班 DTO 不完整 | **CLOSED** | **EXACT** | | P1-1 | DiagnosisDialog 字段缩水、隐私与唯一性检查缺失 | **CLOSED** | **EXACT** | | P1-2 | 预约表单缺医生排班、服务端号源和关键字段 | **CLOSED** | **EXACT** | | P1-3 | 诊单上下文订单缺失,支付/退款字段缩水 | **CLOSED** | **EXACT** | | P1-4 | 建单 paid-order/定金未加载或串诊单竞态 | **CLOSED** | **EXACT / fail-closed 加固** | | P1-5 | 患者写操作 latest-wins 吞掉已执行 mutation 回调 | **CLOSED** | **EXACT** | | P1-6 | QRunnable worker 读取 QWidget | **CLOSED** | **EXACT** | | P1-7 | permission helper 接受点号/斜杠别名,handler 缺二次门槛 | **CLOSED** | **EXACT** | | P1-8 | 原生医生直呼复用小程序 `videoQr` 权限 | **CLOSED** | **EXACT(客户端)** | | P1-9 | 接诊队列固定前 50 条 | **CLOSED** | **EXACT** | | P1-10 | 已开处方无 `readonlyDetail` 权限仍请求诊单 | **CLOSED** | **EXACT** | ## 3. P0 逐项复核 ### P0-1 multipart、两阶段提交与安全路径 — CLOSED Admin 合同: - `D:\web\zyt\admin\src\api\file.ts:9-32`:`POST /upload/image|file`,multipart 字段为 `file`、`cid`,返回 `uri/url`。 - `D:\web\zyt\admin\src\components\material\picker.vue:260-283`:业务组件只接收上传后的服务端地址。 - `D:\web\zyt\admin\src\views\patient\reception\components\NoteTimeline.vue:195-225`:最终备注 DTO 为 `diagnosis_id/content/tongue_images/report_files`。 - `D:\web\zyt\admin\src\api\patient.ts:20-26`:最终提交 `POST /doctor.appointment/addDoctorNote`。 App 证据: - `D:\web\zyt\app\src\doctor_workstation\services\api_client.py:134-157,172-196,249-270`:真实 multipart;上传请求不继承 JSON `Content-Type`,boundary 由 `httpx` 生成。 - `D:\web\zyt\app\src\doctor_workstation\services\repository.py:796-818`:图片走 `upload/image`、文件走 `upload/file`,发送 `file` 与 `cid=0`。 - `D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py:1485-1518,1520-1573`:GUI 线程先快照本地选择;worker 按顺序上传全部材料,全部成功后才提交备注。 - `D:\web\zyt\app\src\doctor_workstation\services\repository.py:825-848,1781-1831`:上传响应和最终 JSON 双层拒绝盘符路径、UNC、`file:` 与反斜杠本机路径;空 `uri/url` 也拒绝。 - `D:\web\zyt\app\tests\test_api_client.py:77-113`、`D:\web\zyt\app\tests\test_reception_parity_ui.py:462-544`:验证 multipart boundary、服务端 URL-only JSON 和任一上传失败时绝不提交备注。 结论:本地文件只作为上传输入,不进入 `addDoctorNote` JSON;原 P0 已关闭。 ### P0-2 appointment 唯一权威与完整 `case_record` — CLOSED Admin 合同: - `D:\web\zyt\admin\src\views\tcm\diagnosis\index.vue:1685-1694`:问诊行明确传入 `diagnosis_id` 与 `appointment_id`。 - `D:\web\zyt\admin\src\components\tcm-prescription\index.vue:1850-1863`:首先请求 `GET tcm.prescription/getByAppointment {appointment_id}`。 - 同文件 `:1865-1895,2251-2267`:无当前挂号处方时从诊单详情生成病例快照,保存 `diagnosis_id/appointment_id/case_record`。 App 证据: - `D:\web\zyt\app\src\doctor_workstation\ui\pages\consultations.py:1168-1191`:唯一查询源是 `get_prescription_by_appointment`,不存在 diagnosis 历史回退。 - 同文件 `:1193-1209,1227-1241,1300-1307`:仅空结果进入新建;查询异常直接 fail-closed;病例快照返回的 diagnosis ID 必须匹配当前行。 - 同文件 `:1315-1375`:编辑种子和最终创建 DTO 都强制覆盖当前 `diagnosis_id/appointment_id` 并深拷贝完整 `case_record`。 - `D:\web\zyt\app\src\doctor_workstation\services\repository.py:1030-1039,1118-1132`:端点分别为 `POST tcm.prescription/add` 与 `GET tcm.prescription/getByAppointment`。 - `D:\web\zyt\app\src\doctor_workstation\core\models.py:581-584,653-657,728-734`:`Prescription` 正式反序列化/序列化 `appointment_id`、`case_record`。 - `D:\web\zyt\app\src\doctor_workstation\services\mock_repository.py:861-870`:Demo 同样只按 appointment 精确匹配。 - `D:\web\zyt\app\tests\test_consultations_parity_ui.py:239-348`:覆盖 miss、异常、禁止 diagnosis fallback、ID 与不可变病例快照。 说明:Admin 在预约查询异常处吞错后可能继续新建;App 选择更安全的异常 fail-closed,但没有改变 appointment 作为唯一处方权威的业务合同。 ### P0-3 预约 ID、排班和提交 DTO — CLOSED Admin 合同: - `D:\web\zyt\admin\src\views\first_visit\my_patients\index.vue:513-518`:预约组件的 `patient_id` 被明确覆盖为 `diagnosis_id || id`。 - `D:\web\zyt\admin\src\views\tcm\diagnosis\appointment.vue:392-402,494-628`:加载医生、未来 7 天有效排班、服务端可用号源。 - 同文件 `:669-720`:提交 `patient_id,doctor_id,appointment_date,period,appointment_time,appointment_type,remark,channel_source,channel_source_detail`。 - 端点定义:`D:\web\zyt\admin\src\api\tcm.ts:107-108`、`D:\web\zyt\admin\src\api\doctor.ts:19-20,50-51`、`D:\web\zyt\admin\src\api\first_visit.ts:120-121`。 App 证据: - `D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:219-251,319-337`:预约上下文和今日重复检查均使用 diagnosis ID;`source_patient_id` 只保留给视频。 - 同文件 `:410-486,495-585`:请求未来 7 天排班和 `availableSlots`,只接受可用、未过期、有配额的时段。 - 同文件 `:600-644`:所有必填门槛与 Admin DTO 对齐,`patient_id == diagnosis_id`;特定自媒体渠道要求补充字段。 - 同文件 `:2208-2223`:确认后通过 `book_patient_appointment` 提交完整快照 DTO。 - `D:\web\zyt\app\src\doctor_workstation\services\repository.py:722-784,1428-1433,1570-1573`:端点为 `getDoctors`、`doctor.roster/lists`、`availableSlots`、`firstvisit.myPatient/createAppointment`。 - `D:\web\zyt\app\tests\test_patients_ui.py:276-343`:明确断言 diagnosis ID 与真实 patient ID 不同且 body 使用 diagnosis ID,并核对排班/号源 query 和完整 body。 App 额外保留 `diagnosis_id` 作为显式上下文键;服务端仍收到 Admin 所需全部字段,未再混淆 ID。 ## 4. P1 逐项复核 ### P1-1 DiagnosisDialog 隐私、字段 DTO 与唯一性 — CLOSED - Admin:`D:\web\zyt\admin\src\views\tcm\diagnosis\edit.vue:823-856,916-970,1013-1132,1263-1275,1331-1367`,明文受 `tcm.diagnosis/phonePlain` 控制,基础字段受服务端锁定,并调用 phone/id-card 唯一性检查。 - App:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\diagnosis.py:42-131,213-241,426-450`,接收/继承 permissions,覆盖患者基本信息、生命体征、病史、四诊和诊断字段。 - App:同文件 `:595-689`,缺权限时 phone/id-card 从 seed 到详情均 fail-closed 脱敏,且不把掩码写回服务端;`patient_basic_locked`/`can_edit_patient_basic=false` 时锁定基础字段。 - App:同文件 `:829-903`,保存前校验格式并调用 `checkPhone {phone,id}`、`checkIdCard {id_card,id}`,然后 `update_diagnosis`。 - Repository:`D:\web\zyt\app\src\doctor_workstation\services\repository.py:1474-1509,1575-1583`。 - 测试:`D:\web\zyt\app\tests\test_prescription_security_ui.py:132-191`。 ### P1-2 预约医生、排班、号源与字段 — CLOSED P0-3 已给出完整证据。补充:Admin 的可提交条件在 `D:\web\zyt\admin\src\views\tcm\diagnosis\appointment.vue:377-386`;App 对应 fail-closed 条件在 `D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:600-644`。医生、排班日、服务端 slot、渠道缺一不可;状态 `1/4` 的今日挂号只阻止再约今天。 ### P1-3 诊单上下文订单、支付与退款 — CLOSED - Admin:`D:\web\zyt\admin\src\views\tcm\diagnosis\readonly.vue:74-88`,订单区只在 `tcm.diagnosis/patientOrders` 下出现。 - Admin:`D:\web\zyt\admin\src\views\tcm\diagnosis\components\PatientOrderList.vue:131-178`,exact query 为 `context_diagnosis_id`、可用时的 `patient_id`、`scene='diagnosis_edit'`、`page_size=10`。 - App:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\diagnosis.py:506-549,551-563,764-827`,同权限、同 query、同分页,并以 diagnosis generation/target 拒绝过期响应。 - Repository:`D:\web\zyt\app\src\doctor_workstation\services\repository.py:1134-1145`,透传到 `GET tcm.prescriptionOrder/lists`。 - 支付/退款:`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:647-747`,支付包含 `order_type/pay_amount/pay_remark/completion_request/pay_create_type`;退款包含原因和可选 `refund_amount`。 - Admin 对应:`D:\web\zyt\admin\src\views\first_visit\my_patients\components\OrderActionHost.vue:573-621`。 - 状态门槛:App `D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:1687-1757,2415-2420,2520-2578` 与 Admin `D:\web\zyt\admin\src\views\first_visit\my_patients\components\order-actions.ts:89-110` 对齐。 ### P1-4 paid-order 上下文竞态与定金门槛 — CLOSED - Admin:`D:\web\zyt\admin\src\views\consumer\prescription\index.vue:2307-2337,2435-2480`,诊单变化清空支付单并重新请求,创建 body 绑定 prescription/diagnosis/pay-order。 - App:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:1800-1847,1945-1950`,已有处方的 diagnosis ID 锁定,初始保存禁用。 - App:同文件 `:1965-2060`,变化立即清空旧支付单/定金;响应必须同时匹配 generation、请求 diagnosis、当前 diagnosis。 - App:同文件 `:2070-2139`,加载中、失败或未就绪均禁止提交;门槛开启时必须选支付单且金额不低于 `deposit_min_amount`。 - Repository:`D:\web\zyt\app\src\doctor_workstation\services\repository.py:1157-1183`,`paidPayOrders {diagnosis_id}` 与 `prescriptionOrder/create`。 - 测试:`D:\web\zyt\app\tests\test_prescription_security_ui.py:227-260`。 ### P1-5 患者 mutation 不丢回调 — CLOSED - `D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:2161-2202`:每次写操作有独立 pending token;完成顺序不会使已执行 mutation 的 success/error 回调失效。 - 同文件 `:2183-2206`:每个服务端成功都 reconcile 患者、订单、面诊进度三工作区。 - 同文件 `:1687-1757,2415-2420`:handler 提交前重新核对 canonical permission 和行状态。 - `D:\web\zyt\app\tests\test_patients_ui.py:373-398`:两个 mutation 乱序完成仍触发两次 reconcile。 ### P1-6 QRunnable 只接收 GUI 快照 — CLOSED - `D:\web\zyt\app\src\doctor_workstation\ui\widgets.py:260-323`:`run_async` 的函数运行在 QRunnable。 - 接诊:`D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py:522-607`。 - 患者列表/订单:`D:\web\zyt\app\src\doctor_workstation\ui\pages\patients.py:1293-1318,1587-1615`。 - 处方库:`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescription_library.py:221-247`。 - 模板导入:`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:759-780`。 - 已开处方:`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py:499-540`。 上述位置均在 GUI 线程先读取 `.text()`、`.currentData()`、日期/页码等,再向 worker 传纯 query/scalar;worker 不再读取 QWidget。`D:\web\zyt\app\tests\test_reception_parity_ui.py:269-310`、`D:\web\zyt\app\tests\test_patients_ui.py:231-268`、`D:\web\zyt\app\tests\test_prescription_security_ui.py:269-334` 覆盖快照和 pending refresh。 视频后端写同样不阻塞 Qt:`D:\web\zyt\app\src\doctor_workstation\video\lifecycle.py:84-153,156-304` 以每通话 FIFO daemon worker 有序执行 `start -> bind -> end`,提供最长 5 秒的有限等待;`D:\web\zyt\app\src\doctor_workstation\video\window.py:515-548` 的窗口关闭只排队结束,不同步阻塞 GUI。 ### P1-7 canonical permission exact 与 handler 二次门槛 — CLOSED - Admin:`D:\web\zyt\admin\src\utils\perm.ts:3-14`,权限字符串精确匹配。 - App:`D:\web\zyt\app\src\doctor_workstation\ui\widgets.py:135-183`,只接受 exact、全局 `*`、资源 `prefix/*`;明确不把 `resource.action` 当成 `resource/action`。 - 处方库按钮和 handlers:`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescription_library.py:100-164,291-392`。 - 已开处方查看、增改删、患者修正、审核、建单、订单列表 handlers:`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py:642-689,707-907`。 - Canonical action 基准:`D:\web\zyt\admin\src\views\consumer\prescription\list.vue:36,85,93,102` 与 `D:\web\zyt\admin\src\views\consumer\prescription\index.vue:107,112,216,232,242,250,260`。 - 测试:`D:\web\zyt\app\tests\test_prescription_security_ui.py:123-129`,仅有点号 alias 时 slash action 被拒绝。 ### P1-8 视频 eligibility 与权限语义 — CLOSED(客户端范围) - Admin:`D:\web\zyt\admin\src\views\tcm\diagnosis\index.vue:390-391` 的 `tcm.diagnosis/videoQr` 只保护小程序二维码;同文件 `:1778-1780` 的问诊视频门槛为 `has_appointment && appointment_status == 1`。 - App:`D:\web\zyt\app\src\doctor_workstation\ui\pages\consultations.py:166-183,1106-1111,1441-1453`,使用相同 eligibility,且 appointment/patient/diagnosis 三 ID 分离、均需有效。 - UI 原生直呼不再受 `videoQr` 门槛;测试见 `D:\web\zyt\app\tests\test_consultations_parity_ui.py:86-92,390-403`。 - Remote endpoint/payload:`D:\web\zyt\app\src\doctor_workstation\services\repository.py:1677-1717`,分别是签名、`startCall`、`bindCallRoom`、`endCall`。 服务端仍必须最终验证通话状态和数据归属;这属于服务端安全边界,不是客户端页面缺失。 ### P1-9 接诊分页 — CLOSED - Admin:`D:\web\zyt\admin\src\views\patient\reception\index.vue:212-214,296-379`,`PAGE_SIZE=15`、按 `total/count` 持续加载和 ID 去重。 - App:`D:\web\zyt\app\src\doctor_workstation\ui\pages\reception.py:196-208,288-292,513-607,609-696`,同为 15 条;切 tab/搜索重置,加载更多,按 ID 合并,以 generation/query-key 拒绝旧响应。 - Repository:`D:\web\zyt\app\src\doctor_workstation\services\repository.py:668-695`,透传 `page_no/page_size` 到 `GET doctor.appointment/lists`。 - 测试:`D:\web\zyt\app\tests\test_reception_parity_ui.py:179-260`,覆盖跨页追加、筛选重置与 total 边界。 第 51 位之后的今日患者已可达,旧“固定前 50 条”结论失效。 ### P1-10 处方进入诊单的权限与只读端点 — CLOSED - `D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py:642-659`:处方详情先要求 `cf.prescription/read`;诊单按钮另要求 `tcm.diagnosis/readonlyDetail`。 - 同文件 `:661-698`:handler 再次检查 canonical 权限和 diagnosis ID,明确调用 readonly endpoint;无权时不发请求。 - `D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:1668-1696`:详情按钮按同一权限 boolean 隐藏。 - `D:\web\zyt\app\src\doctor_workstation\services\repository.py:1474-1488`:`GET tcm.diagnosis/readonlyDetail {id}`。 - `D:\web\zyt\app\tests\test_prescription_security_ui.py:335-388`:无权限不请求,并拒绝过期 target 响应。 ## 5. Endpoint / DTO 最终合同 | 能力 | Endpoint | 关键 query/body | 状态 | |---|---|---|---:| | 接诊队列 | `GET doctor.appointment/lists` | `status,start_date,end_date,patient_name,page_no,page_size` | **EXACT** | | 材料上传 | `POST upload/image|file` | multipart `file,cid` | **EXACT** | | 医生备注 | `POST doctor.appointment/addDoctorNote` | `diagnosis_id,content,tongue_images,report_files`,仅服务器 URI | **EXACT** | | 当前挂号处方 | `GET tcm.prescription/getByAppointment` | `appointment_id` | **EXACT** | | 新建处方 | `POST tcm.prescription/add` | `diagnosis_id,appointment_id,case_record` + 完整处方字段 | **EXACT** | | 预约医生 | `GET tcm.diagnosis/getDoctors` | 无 | **EXACT** | | 排班 | `GET doctor.roster/lists` | `doctor_id,start_date,end_date,status,page_no,page_size` | **EXACT** | | 可用号源 | `GET doctor.appointment/availableSlots` | `doctor_id,appointment_date,period='all'` | **EXACT** | | 我的患者预约 | `POST firstvisit.myPatient/createAppointment` | Admin 九字段;`patient_id == diagnosis_id` | **EXACT** | | 诊单详情/编辑 | `GET detail|readonlyDetail`; `POST checkPhone|checkIdCard|edit` | `{id}`、唯一性 DTO、完整医生相关字段 | **EXACT** | | 诊单上下文订单 | `GET tcm.prescriptionOrder/lists` | `context_diagnosis_id,patient_id?,scene='diagnosis_edit',page_no,page_size` | **EXACT** | | 建单支付单 | `GET tcm.prescriptionOrder/paidPayOrders` | `diagnosis_id` | **EXACT** | | 创建业务订单 | `POST tcm.prescriptionOrder/create` | `prescription_id,diagnosis_id,pay_order_ids?` + 收货/服务/金额字段 | **EXACT** | | 视频生命周期 | `getCallSignature/startCall/bindCallRoom/endCall` | 三 ID 分离;`call_type=2`;`room_id` | **EXACT(客户端)** | 对 `D:\web\zyt\app\src\doctor_workstation\services\repository.py`、Protocol、Remote、Demo 和五页面调用名进行复核,当前新增调用均存在;未发现不存在或错误命名的 repository 方法。关键端点合同由 `D:\web\zyt\app\tests\test_repository_parity.py:127-153,157-267,287-353` 覆盖。 ## 6. 异步、刷新与目标绑定 - 列表请求均以 generation 拒绝旧响应;接诊还绑定 query-key 和 appointment target。 - 处方库/已开处方在 loading 中收到刷新会记录 pending refresh,而不是静默丢失:`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescription_library.py:221-266`、`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py:499-574`。 - 处方详情、诊单详情、订单详情均绑定 generation + target ID:`D:\web\zyt\app\src\doctor_workstation\ui\pages\prescriptions.py:608-705`、`D:\web\zyt\app\src\doctor_workstation\ui\dialogs\prescription.py:2198-2284`。 - 问诊切行同时 invalidate prescription generation 并释放 busy:`D:\web\zyt\app\src\doctor_workstation\ui\pages\consultations.py:1085-1111`。 - Demo 的问诊筛选、字典、appointment-authoritative 处方及 mutation 均有真实离线语义:`D:\web\zyt\app\tests\test_mock_repository.py:140-178,300-396`。 上述原 P2 风险也已关闭;不影响本次 P0/P1 发布判断。 ## 7. 动态菜单与医生桌面范围 动态菜单仍以会话 menu 为权威,并要求菜单节点与当前 canonical permission 同时满足:`D:\web\zyt\app\src\doctor_workstation\ui\shell.py:41-80,195-249`。五个本地页面之外的节点不会被错误映射;非 Demo 且服务端 menu 为空时不会自行放宽页面。 下列功能明确不属于本次医生桌面同型范围,因此不计为 MISSING: - 患者小程序/H5、消费者端自助下单和二维码页面。 - 医助 watchCall、医助专属批量分配与其他角色工作台。 - 企业微信后台运营、素材运营、客户标签、统计/转化后台。 - 药房、财务、物流、审核员等角色的完整运营路由;医生页只保留其被授权的订单动作。 - 管理端 `videoQr` 小程序二维码能力;桌面原生直呼使用后端 ticket 与服务端最终授权。 ## 8. 验证记录 执行命令与结果: ```text .\.venv\Scripts\python.exe -m pytest -o addopts='' --tb=short collected 140 items 140 passed in 1.49s .\.venv\Scripts\python.exe -m pytest --collect-only -q 140 tests collected across 15 test files .\.venv\Scripts\python.exe -m ruff check src tests All checks passed! .\.venv\Scripts\python.exe -m ruff format --check src tests 53 files already formatted ``` 测试数量明细:`12+11+12+2+1+16+2+12+4+7+10+11+12+10+18 = 140`。 ## 9. 最终门槛 - **OPEN P0:0** - **OPEN P1:0** - **发布阻断:无** - **最终结论:可作为医生桌面 release candidate 进入打包和部署环境验收。**