first commit
This commit is contained in:
@@ -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. 同文件队列分页/筛选 fixtures(247-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。
|
||||
Reference in New Issue
Block a user