20 KiB
APP 接诊台队列第二行 Python dict 泄漏审计
审计日期:2026-08-14
审计范围:server 列表 API → Python API client / repository / model normalize → reception.py 队列卡片
操作边界:只读诊断;未修改任何业务代码或测试代码,仅新增本报告。
0. Trellis 指令检查
仓库根目录 D:\web\zyt 下不存在 .trellis/,因此没有可读取的 .trellis/workflow.md、.trellis/spec/ 或任务上下文。本审计已按根目录 AGENTS.md 的现有约束执行。
1. 结论
根因已经确定,不是 Qt 的渲染问题,也不是 JSON 解码问题,而是一个“对象字段被误当成文本别名”的类型边界错误:
- 当前服务端
doctor.appointment/lists使用->with('diagnosis'),所以每条挂号记录的diagnosis字段实际是一个完整的关联诊单对象(JSON object / Pythondict),缺失关联时则可能为null;它不是诊断名称字符串。 RemoteDoctorRepository.list_appointments()通过PageResult.from_payload(..., Appointment.from_dict)做 normalize。Appointment.from_dict()没有诊断摘要字段,只把完整原始行保存在Appointment.raw。get_value()对 dataclass 上不存在的字段会回退到raw,因此first_value(record, ..., "diagnosis")会取出那个dict。QueueRow把该值放进str(part).strip(),Python 按字典repr生成"{'id': ..., ...}",随后直接传给QLabel。这正是用户看到的第二行文本。
触发点位于当前工作区尚未提交的接诊台视觉改造:旧版队列第二行只展示预约时间,不读取 diagnosis;当前改造在 QueueRow 中新增了 "diagnosis" 这个兜底别名,从而首次暴露服务端一直存在的关联对象。
直接修复不能只是换成 display_text()。 display_text() 对容器同样执行 str(value),仍会显示 Python 字典。也不应把整个嵌套 diagnosis 合并进 appointment,因为两层都有 id、patient_id、status 等不同语义字段,会污染挂号状态和三个 ID 的权威口径。
2. 完整数据链路
2.1 服务端列表实际返回嵌套对象
入口和响应封装:
| 文件 / 函数 | 当前行号 | 事实 |
|---|---|---|
server/app/adminapi/controller/doctor/AppointmentController.php::lists() |
62-65 | doctor.appointment/lists 交给 AppointmentLists。 |
server/app/common/service/JsonService.php::dataLists() |
120-147 | HTTP envelope 的 data 为 {lists, count, page_no, page_size, extend}。 |
server/app/adminapi/lists/doctor/AppointmentLists.php::lists() |
162-369 | 构造并序列化每一条挂号记录。 |
决定 diagnosis 类型的代码:
AppointmentLists.php:213-218:查询从Appointment::alias('a')->with('diagnosis')开始;同时 jointcm_diagnosis u,只把患者、医生、医助、diagnosis_id等少数字段平铺到顶层。AppointmentLists.php:261-267:模型select()->toArray();未限制字段的关联模型随主记录一起转成数组。server/app/common/model/doctor/Appointment.php:99-102:diagnosis()是belongsTo(Diagnosis::class, 'patient_id', 'id')。因此 appointment 表里的patient_id实际指向诊单 ID,而不是诊单对象中的真实患者 ID。server/app/adminapi/logic/doctor/AppointmentLogic.php:623-642也明确记录:appointment.patient_id == tcm_diagnosis.id。server/app/common/model/tcm/Diagnosis.php:26-39:关联对象对应tcm_diagnosis模型。server/sql/tcm_diagnosis.sql:2-25:基础 schema 中诊单至少包含id、真实patient_id、patient_name、diagnosis_type、syndrome_type、symptoms、remark等字段。不同部署的后续列可能更多,但容器类型不变。
按照当前代码生成的响应形态如下(字段删减,仅表达类型和 ID 语义):
{
"code": 1,
"data": {
"lists": [
{
"id": 101,
"patient_id": 501,
"diagnosis_id": 501,
"patient_name": "张三",
"appointment_time": "09:00",
"status": 1,
"diagnosis": {
"id": 501,
"patient_id": 301,
"patient_name": "张三",
"diagnosis_type": "follow_up",
"syndrome_type": "...",
"symptoms": "口干"
}
}
],
"count": 1,
"page_no": 1,
"page_size": 15,
"extend": {}
}
}
这里的关键合同是:
- 顶层
diagnosis_id:诊单 ID; - 顶层
patient_id:历史命名,当前也存诊单 ID; diagnosis.id:诊单 ID;diagnosis.patient_id:真实患者 ID;diagnosis:object 或 null,不应作为字符串渲染。
本次没有调用线上接口或读取生产数据库;“实际字段形态”依据当前 server 查询、关系定义、模型序列化和 schema 静态确认。容器类型由 with('diagnosis') 确定,不依赖具体数据内容。
2.2 API client 解 envelope,但不改变行字段
app/src/doctor_workstation/services/api_client.py::ApiClient._unwrap(),344-382:校验 envelope,在code == 1时直接返回envelope['data']。- 所以 repository 收到的是
{lists, count, ...},列表行里的嵌套diagnosis仍为 Pythondict。
2.3 Repository / model normalize 保留嵌套对象到 raw
app/src/doctor_workstation/services/repository.py::RemoteDoctorRepository.list_appointments(),879-909:请求doctor.appointment/lists,再调用PageResult.from_payload(payload, Appointment.from_dict, ...)。app/src/doctor_workstation/core/models.py::PageResult.from_payload(),804-865:在 840 行逐条调用 parser。app/src/doctor_workstation/core/models.py::Appointment.from_dict(),213-257:只 normalize 顶层基本字段;没有clinical_diagnosis、diagnosis_name、disease_name或disease_coursedataclass 字段;256 行执行raw=dict(source),完整保留嵌套 relation。app/src/doctor_workstation/ui/widgets.py::get_value(),53-71:对象属性不存在时,66-67 行回退到对象的raw。app/src/doctor_workstation/ui/widgets.py::first_value(),74-81:只排除None和空字符串,不排除 Mapping、Sequence 或其他不可展示容器。
因此 normalize 后的真实 Python 形态是:
Appointment(
id=101,
patient_id=501,
diagnosis_id=501,
# 没有 canonical diagnosis summary 字段
raw={
# ...
"diagnosis": {"id": 501, "patient_id": 301, "symptoms": "口干"}
},
)
2.4 QueueRow 把 Mapping 转成 Python 文本
app/src/doctor_workstation/ui/pages/reception.py::ReceptionPage._apply_queue(),1473-1519:page_items()取出Appointment,并为每条记录创建QueueRow(record)。app/src/doctor_workstation/ui/pages/reception.py::QueueRow.__init__(),567-574:按clinical_diagnosis → diagnosis_name → disease_name → diagnosis取第一个非空值。- 前三个字段在当前 server 顶层没有,
diagnosis则通过get_value()的raw回退命中关联dict。 - 同函数 586-590:
str(part).strip()对该 dict 生成 Python repr。 - 591 行:repr 被送入
QLabel,没有任何类型检查。 app/src/doctor_workstation/ui/widgets.py::display_text(),84-91:即使改用此函数,91 行仍是str(value),所以不是修复。
相邻的 ReceptionPage._render_identity() 在 reception.py:1783-1809 也有“候选值 → str(part)”模式。它当前处理的是详情响应中的 diagnosis mapping 内部字段,不会必然触发本问题,但建议复用同一个 scalar-only helper,避免未来某个详情别名变成 object/list 时再次泄漏容器 repr。
3. 可重复证据
使用项目现有虚拟环境、-B 禁止生成 bytecode,执行了一个无网络、无文件写入的最小复现:
row = Appointment.from_dict({
"id": 1,
"patient_name": "张三",
"appointment_time": "09:00",
"diagnosis": {"id": 8, "patient_name": "张三", "symptoms": "口干"},
})
widget = QueueRow(row)
当前代码输出:
normalized_type= Appointment raw_diagnosis_type= dict
fallback_value= {'id': 8, 'patient_name': '张三', 'symptoms': '口干'}
rendered_subline= {'id': 8, 'patient_name': '张三', 'symptoms': '口干'}
这同时证明:
- API row 到
Appointment的 normalize 已发生; - dict 并未来自 Qt;
- 卡片最终文本和 Python dict repr 完全相同。
4. 为什么现有测试没有发现
app/tests/test_reception_parity_ui.py::test_queue_status_badge_is_not_clipped_in_narrow_panel(),103-128,只断言状态徽标尺寸和位置;fixture 不含diagnosis,也没有读取ReceptionQueueSubline。- 同文件队列分页/筛选 fixtures(247-386)只给
id/patient_name/status等平铺字段,未模拟 server 的diagnosis: {...}relation。 app/tests/test_mock_repository.py::test_tolerant_page_parsing_accepts_aliases_and_bad_rows(),298-324,只覆盖简单别名和坏行;没有嵌套关系字段。app/tests/test_repository_parity.py::test_page_result_preserves_outer_and_nested_extend(),155-174,覆盖的是分页 envelope 嵌套,不是 row 内 relation 嵌套。app/tests/test_repository_parity.py::test_remote_reception_is_forcibly_scoped_to_today(),227-244,只断言请求 endpoint/参数,不断言返回 DTO 字段类型。- Demo appointments 在
app/src/doctor_workstation/services/mock_repository.py:3153-3283不包含diagnosisrelation,因此视觉截图只会走时间 fallback,无法暴露生产响应问题。
5. 兼容旧 / 新响应的稳健提取规则
5.1 必须先区分“容器”和“可展示标量”
建议定义一个只接受 JSON scalar 的 helper:
- 接受:非空
str;必要时接受int/float并转换成字符串; - 拒绝:
Mapping、list/tuple/set、bool、None、空白字符串; - 绝不对未知容器调用
str(); - 如果产品以后明确支持多选诊断,应单独定义“纯字符串列表 join”合同,不能把任意 list/dict 通用字符串化。
5.2 诊断摘要优先级
兼容三类已知/合理响应:
- 新/平铺 canonical:顶层
clinical_diagnosis; - 平铺历史别名:顶层
diagnosis_name、disease_name; - 当前 server relation:若顶层
diagnosis是 Mapping,只从其内部的clinical_diagnosis、diagnosis_name、disease_name、标量diagnosis中选; - 更老的标量别名:只有当顶层
diagnosis本身是 scalar 时,才把它作为最后兜底; - 都没有可展示文本时,返回空字符串,让 UI 回退到预约时间。
推荐顺序可写成:
top.clinical_diagnosis
→ top.diagnosis_name
→ top.disease_name
→ diagnosis_object.clinical_diagnosis
→ diagnosis_object.diagnosis_name
→ diagnosis_object.disease_name
→ diagnosis_object.diagnosis(仅 scalar)
→ top.diagnosis(仅 scalar)
→ ""
不要把 diagnosis_type 直接当临床诊断:它在 server 中是初诊/复诊等类型 code;也不要直接显示未经翻译的 syndrome_type code。若产品明确希望第二行显示证型,应由 server 提供 syndrome_type_text 或由客户端字典翻译后作为另一个明确字段,不能把整个 relation 当成兜底。
5.3 病程摘要优先级
同样对顶层和 relation 内部执行 scalar-only 查找:
top.disease_course_text
→ top.disease_course
→ top.course_text
→ top.course
→ diagnosis_object.disease_course_text
→ diagnosis_object.disease_course
→ diagnosis_object.course_text
→ diagnosis_object.course
→ ""
5.4 最终渲染规则
diagnosis、course都有文本:诊断 · 病程;- 只有一个:只显示该项;
- 两者都没有:显示预约时间;
- 时间也没有:显示“时间待确认”;
- 无论输入如何,最终字符串都不得包含由容器 repr 产生的
{...}/[...]。
6. 建议补丁
6.1 首选:model 边界 canonicalize + UI 最后一道类型保护
A. core/models.py
在基础 helper 附近(当前 21-81 行)增加 scalar-only 提取器:
def _first_scalar_text(*values: object) -> str:
for value in values:
if isinstance(value, str):
text = value.strip()
if text:
return text
elif isinstance(value, (int, float)) and not isinstance(value, bool):
return str(value)
return ""
给 Appointment(当前 179-211 行)增加 canonical 字段:
clinical_diagnosis: str = ""
disease_course: str = ""
在 Appointment.from_dict() 当前 217 行之后只选择性读取 relation,不要 merge 整个 nested mapping:
source = _mapping(data)
diagnosis_value = source.get("diagnosis")
diagnosis = _mapping(diagnosis_value)
legacy_diagnosis = None if isinstance(diagnosis_value, Mapping) else diagnosis_value
clinical_diagnosis = _first_scalar_text(
source.get("clinical_diagnosis"),
source.get("diagnosis_name"),
source.get("disease_name"),
diagnosis.get("clinical_diagnosis"),
diagnosis.get("diagnosis_name"),
diagnosis.get("disease_name"),
diagnosis.get("diagnosis"),
legacy_diagnosis,
)
disease_course = _first_scalar_text(
source.get("disease_course_text"),
source.get("disease_course"),
source.get("course_text"),
source.get("course"),
diagnosis.get("disease_course_text"),
diagnosis.get("disease_course"),
diagnosis.get("course_text"),
diagnosis.get("course"),
)
随后赋给 dataclass 字段。可顺带在顶层 diagnosis_id 缺失时安全回退 diagnosis.id,但必须保持 appointment 的 id/status/patient_id 仍以顶层为权威。
B. ui/pages/reception.py
即使 model 已 canonicalize,QueueRow 仍可能被测试仓库或其他 repository 直接传入 dict,因此 UI 应保留 scalar-only guard。最小安全改法不是简单删除 "diagnosis",而是:
def _display_scalar(value: object) -> str:
if isinstance(value, str):
return value.strip()
if isinstance(value, (int, float)) and not isinstance(value, bool):
return str(value)
return ""
然后在 QueueRow.__init__() 当前 567-591 行:
diagnosis = _display_scalar(
first_value(
record,
"clinical_diagnosis",
"diagnosis_name",
"disease_name",
default=None,
)
) or _display_scalar(get_value(record, "diagnosis", None))
course = _display_scalar(
first_value(
record,
"disease_course_text",
"disease_course",
"course_text",
"course",
default=None,
)
)
subline_parts = [part for part in (diagnosis, course) if part]
subline = QLabel(" · ".join(subline_parts) or display_text(time or "时间待确认"))
���果希望 QueueRow 本身也兼容未经 Appointment.from_dict() 的嵌套 raw dict,则把 5.2/5.3 的 relation 内部候选一起放进一个纯函数(例如 _queue_summary_fields(record)),并由 model/UI 共用或分别调用同一优先级。重点是 relation 容器永远不能进入 QLabel。
建议同样把 _render_identity() 当前 1802-1805 行的 str(part) 改为这个 scalar-only helper,作为邻接防御。
6.2 不建议的修复
- 只改
display_text(diagnosis):仍会str(dict)。 - 只删掉
"diagnosis"别名:能止住当前服务端,但会丢掉历史 scalardiagnosis兼容,也无法读取未来/其他部署的嵌套 canonical 文本。 json.dumps(diagnosis):只是把 Python repr 换成 JSON,仍然把内部对象和潜在隐私信息显示给用户。- 把 relation 整体 merge 到 appointment:会让 diagnosis 的
id/patient_id/status覆盖挂号字段,破坏视频、完成接诊和选中一致性。 - 立即删除 server 的
with('diagnosis'):可能影响已有管理端消费者;在没有完整 server contract 回归前不应作为 APP 热修。
6.3 可选的服务端长期收敛
长期可以让 AppointmentLists 明确返回队列所需的 scalar summary,例如 clinical_diagnosis / disease_course_text / 已翻译的 syndrome_type_text,并限制或移除列表里的完整 relation,以减少 payload 和 PII 面。但当前 schema 各部署并不完全一致,直接在 SQL field 中引用未必存在的列会造成查询失败,因此这应作为单独的 API contract 变更,不是本次桌面 APP 热修的前置条件。
7. 必需测试
7.1 Model / repository normalize 测试
建议放在 app/tests/test_repository_parity.py,直接通过 PageResult.from_payload(..., Appointment.from_dict) 覆盖真实路径:
- relation object 内有
clinical_diagnosis和disease_course,normalize 后得到 canonical 字符串,同时raw['diagnosis']仍保留原 dict。 - 顶层 flattened canonical 字段优先于嵌套字段。
- 顶层 legacy scalar
diagnosis可兼容。 diagnosis只有无关 mapping 字段时,canonical 诊断为空,不出现 dict repr。- 顶层
status=1/id=101/patient_id=501与 nestedstatus=0/id=501/patient_id=301同时存在时,appointment 权威字段不得被 nested 覆盖。 diagnosis=null、空 dict、字段为空白、错误的 list/dict 类型均不抛异常。
建议核心断言示例:
assert appointment.clinical_diagnosis == "消渴"
assert appointment.disease_course == "2 年"
assert isinstance(appointment.raw["diagnosis"], dict)
assert appointment.id == 101
assert appointment.status == 1
assert appointment.patient_id == 501
7.2 QueueRow 渲染测试
建议放在 app/tests/test_reception_parity_ui.py,扩展当前 103 行附近的 QueueRow 测试;通过 findChild(QLabel, 'ReceptionQueueSubline') 直接断言:
| 输入 | 期望第二行 |
|---|---|
flat clinical_diagnosis='消渴', disease_course_text='2 年' |
消渴 · 2 年 |
legacy scalar diagnosis='消渴', course='2 年' |
消渴 · 2 年 |
nested relation 内含 canonical 文本(经 Appointment.from_dict) |
消渴 · 2 年 |
diagnosis={'id': 8, 'symptoms': '口干'},无摘要 |
回退预约时间 |
clinical_diagnosis={...} / course=[...] |
回退预约时间,且不抛异常 |
| 所有字段缺失 | 时间待确认 |
每个 case 还应有通用安全断言:
assert "{" not in subline.text()
assert "}" not in subline.text()
assert "[" not in subline.text()
assert "]" not in subline.text()
如果正常业务文本本身允许这些符号,则更精确地断言“不等于 repr(payload['diagnosis'])”并断言预期 fallback;不要只做脆弱的字符黑名单。
7.3 Server contract 测试(若修改 server)
若后续调整 AppointmentLists,PHP 侧应加入 endpoint/列表类 contract:
diagnosis明确为 array|null,禁止在 contract 中宣称 string;- 新增的 queue summary 必须是 string|null;
- count / scope / 日期过滤不受影响;
- relation 字段收窄或移除前,先盘点 admin 其他页面消费者。
8. 修复验收标准
- 线上/current server 的 nested
diagnosisresponse 不再把{...}显示在队列第二行。 - 平铺 canonical、历史 scalar 和 nested canonical 三种形态均有确定输出。
- 没有可展示诊断/病程时稳定回退预约时间,而不是空白或容器 repr。
Appointment的挂号id/status/patient_id不被 nested diagnosis 覆盖。- 新增 model 与 QueueRow 测试通过;现有 same-day、分页、切换患者和状态徽标测试保持通过。
- 不需要以修改 server 或数据库 schema 作为 APP 修复前提。
9. 最终判定
这是一个确定性的 P1 展示与数据边界缺陷:不会直接修改数据,但会把完整关联对象(其中可能包含手机号、身份证、病史等字段,取决于部署 schema)暴露在医生端 UI,并破坏卡片可读性。推荐用“repository/model selective normalize + UI scalar-only guard”双层修复;不要序列化对象,也不要 merge relation。