This commit is contained in:
Your Name
2026-08-14 14:37:30 +08:00
parent 21790e35f4
commit 18c15d1262
117 changed files with 28157 additions and 8080 deletions
+408
View File
@@ -0,0 +1,408 @@
# 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。