Files
zyt/app/research/final_parity_audit.md
2026-08-10 17:46:04 +08:00

261 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 医生工作站最终发布 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/scalarworker 不再读取 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 P00**
- **OPEN P10**
- **发布阻断:无**
- **最终结论:可作为医生桌面 release candidate 进入打包和部署环境验收。**