Files
xuetang/app/research/reception_queue_error_audit.md
T
2026-09-08 11:40:15 +08:00

20 KiB
Raw Blame History

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 解码问题,而是一个“对象字段被误当成文本别名”的类型边界错误:

  1. 当前服务端 doctor.appointment/lists 使用 ->with('diagnosis'),所以每条挂号记录的 diagnosis 字段实际是一个完整的关联诊单对象(JSON object / Python dict),缺失关联时则可能为 null;它不是诊断名称字符串。
  2. RemoteDoctorRepository.list_appointments() 通过 PageResult.from_payload(..., Appointment.from_dict) 做 normalize。Appointment.from_dict() 没有诊断摘要字段,只把完整原始行保存在 Appointment.raw
  3. get_value() 对 dataclass 上不存在的字段会回退到 raw,因此 first_value(record, ..., "diagnosis") 会取出那个 dict
  4. QueueRow 把该值放进 str(part).strip()Python 按字典 repr 生成 "{'id': ..., ...}",随后直接传给 QLabel。这正是用户看到的第二行文本。

触发点位于当前工作区尚未提交的接诊台视觉改造:旧版队列第二行只展示预约时间,不读取 diagnosis;当前改造在 QueueRow 中新增了 "diagnosis" 这个兜底别名,从而首次暴露服务端一直存在的关联对象。

直接修复不能只是换成 display_text() display_text() 对容器同样执行 str(value),仍会显示 Python 字典。也不应把整个嵌套 diagnosis 合并进 appointment,因为两层都有 idpatient_idstatus 等不同语义字段,会污染挂号状态和三个 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') 开始;同时 join tcm_diagnosis u,只把患者、医生、医助、diagnosis_id 等少数字段平铺到顶层。
  • AppointmentLists.php:261-267:模型 select()->toArray();未限制字段的关联模型随主记录一起转成数组。
  • server/app/common/model/doctor/Appointment.php:99-102diagnosis()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_idpatient_namediagnosis_typesyndrome_typesymptomsremark 等字段。不同部署的后续列可能更多,但容器类型不变。

按照当前代码生成的响应形态如下(字段删减,仅表达类型和 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
  • diagnosisobject 或 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 仍为 Python dict

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_diagnosisdiagnosis_namedisease_namedisease_course dataclass 字段;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-1519page_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-590str(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. 为什么现有测试没有发现

  1. app/tests/test_reception_parity_ui.py::test_queue_status_badge_is_not_clipped_in_narrow_panel(),103-128,只断言状态徽标尺寸和位置;fixture 不含 diagnosis,也没有读取 ReceptionQueueSubline
  2. 同文件队列分页/筛选 fixtures247-386)只给 id/patient_name/status 等平铺字段,未模拟 server 的 diagnosis: {...} relation。
  3. app/tests/test_mock_repository.py::test_tolerant_page_parsing_accepts_aliases_and_bad_rows(),298-324,只覆盖简单别名和坏行;没有嵌套关系字段。
  4. app/tests/test_repository_parity.py::test_page_result_preserves_outer_and_nested_extend()155-174,覆盖的是分页 envelope 嵌套,不是 row 内 relation 嵌套。
  5. app/tests/test_repository_parity.py::test_remote_reception_is_forcibly_scoped_to_today()227-244,只断言请求 endpoint/参数,不断言返回 DTO 字段类型。
  6. Demo appointments 在 app/src/doctor_workstation/services/mock_repository.py:3153-3283 不包含 diagnosis relation,因此视觉截图只会走时间 fallback,无法暴露生产响应问题。

5. 兼容旧 / 新响应的稳健提取规则

5.1 必须先区分“容器”和“可展示标量”

建议定义一个只接受 JSON scalar 的 helper

  • 接受:非空 str;必要时接受 int/float 并转换成字符串;
  • 拒绝:Mapping、list/tuple/set、bool、None、空白字符串;
  • 绝不对未知容器调用 str()
  • 如果产品以后明确支持多选诊断,应单独定义“纯字符串列表 join”合同,不能把任意 list/dict 通用字符串化。

5.2 诊断摘要优先级

兼容三类已知/合理响应:

  1. 新/平铺 canonical:顶层 clinical_diagnosis
  2. 平铺历史别名:顶层 diagnosis_namedisease_name
  3. 当前 server relation:若顶层 diagnosis 是 Mapping,只从其内部的 clinical_diagnosisdiagnosis_namedisease_name、标量 diagnosis 中选;
  4. 更老的标量别名:只有当顶层 diagnosis 本身是 scalar 时,才把它作为最后兜底;
  5. 都没有可展示文本时,返回空字符串,让 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 最终渲染规则

  • diagnosiscourse 都有文本:诊断 · 病程
  • 只有一个:只显示该项;
  • 两者都没有:显示预约时间;
  • 时间也没有:显示“时间待确认”;
  • 无论输入如何,最终字符串都不得包含由容器 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 已 canonicalizeQueueRow 仍可能被测试仓库或其他 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" 别名:能止住当前服务端,但会丢掉历史 scalar diagnosis 兼容,也无法读取未来/其他部署的嵌套 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) 覆盖真实路径:

  1. relation object 内有 clinical_diagnosisdisease_coursenormalize 后得到 canonical 字符串,同时 raw['diagnosis'] 仍保留原 dict。
  2. 顶层 flattened canonical 字段优先于嵌套字段。
  3. 顶层 legacy scalar diagnosis 可兼容。
  4. diagnosis 只有无关 mapping 字段时,canonical 诊断为空,不出现 dict repr。
  5. 顶层 status=1/id=101/patient_id=501 与 nested status=0/id=501/patient_id=301 同时存在时,appointment 权威字段不得被 nested 覆盖。
  6. 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

若后续调整 AppointmentListsPHP 侧应加入 endpoint/列表类 contract

  • diagnosis 明确为 array|null,禁止在 contract 中宣称 string
  • 新增的 queue summary 必须是 string|null
  • count / scope / 日期过滤不受影响;
  • relation 字段收窄或移除前,先盘点 admin 其他页面消费者。

8. 修复验收标准

  1. 线上/current server 的 nested diagnosis response 不再把 {...} 显示在队列第二行。
  2. 平铺 canonical、历史 scalar 和 nested canonical 三种形态均有确定输出。
  3. 没有可展示诊断/病程时稳定回退预约时间,而不是空白或容器 repr。
  4. Appointment 的挂号 id/status/patient_id 不被 nested diagnosis 覆盖。
  5. 新增 model 与 QueueRow 测试通过;现有 same-day、分页、切换患者和状态徽标测试保持通过。
  6. 不需要以修改 server 或数据库 schema 作为 APP 修复前提。

9. 最终判定

这是一个确定性的 P1 展示与数据边界缺陷:不会直接修改数据,但会把完整关联对象(其中可能包含手机号、身份证、病史等字段,取决于部署 schema)暴露在医生端 UI,并破坏卡片可读性。推荐用“repository/model selective normalize + UI scalar-only guard”双层修复;不要序列化对象,也不要 merge relation。