Files
zyt/app/research/reception_queue_error_audit.md
T
2026-08-14 14:37:30 +08:00

409 lines
20 KiB
Markdown
Raw 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.
# 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,因为两层都有 `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')` 开始;同时 join `tcm_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 语义):
```json
{
"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` 仍为 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_diagnosis``diagnosis_name``disease_name``disease_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 形态是:
```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,执行了一个无网络、无文件写入的最小复现:
```python
row = Appointment.from_dict({
"id": 1,
"patient_name": "张三",
"appointment_time": "09:00",
"diagnosis": {"id": 8, "patient_name": "张三", "symptoms": "口干"},
})
widget = QueueRow(row)
```
当前代码输出:
```text
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_name``disease_name`
3. **当前 server relation**:若顶层 `diagnosis` 是 Mapping,只从其内部的 `clinical_diagnosis``diagnosis_name``disease_name`、标量 `diagnosis` 中选;
4. **更老的标量别名**:只有当顶层 `diagnosis` 本身是 scalar 时,才把它作为最后兜底;
5. 都没有可展示文本时,返回空字符串,让 UI 回退到预约时间。
推荐顺序可写成:
```text
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 查找:
```text
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 提取器:
```python
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 字段:
```python
clinical_diagnosis: str = ""
disease_course: str = ""
```
`Appointment.from_dict()` 当前 217 行之后只选择性读取 relation**不要 merge 整个 nested mapping**
```python
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"`,而是:
```python
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 行:
```python
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_diagnosis``disease_course`normalize 后得到 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 类型均不抛异常。
建议核心断言示例:
```python
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 还应有通用安全断言:
```python
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. 修复验收标准
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。