yxh
8 天以前 68ff33beff11310e830b0615b5a81f83fad0f683
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
package cn.lihu.jh.module.ecg.service.appointment;
 
import org.junit.Test;
 
import java.lang.reflect.Method;
import java.util.*;
 
import static org.junit.Assert.*;
 
/**
 * 回归测试:HIS 报文解析的**类型安全导航**。
 *
 * <h3>背景</h3>
 * 2026-09-28 生产日志出现 **287 次** {@code ClassCastException:
 * class java.lang.String cannot be cast to class java.util.Map},
 * 集中在 {@code extractCheckCodeFromComponent2} 等方法的链式强制转换上。
 *
 * <p>原因是 HIS 报文经 Jackson 反序列化后**同一字段类型并不稳定**:
 * {@code observationRequest.code} 在部分报文中是 {@code Map}
 * ({@code code={code=7,...}}),在另一些报文中是**空字符串**({@code code=})。
 * 链式 {@code .map(m -> (Map<String,Object>) m.get("code"))} 遇到字符串即抛异常,
 * **导致整条消息处理中断**(预约时间、房间等字段写不进库)。
 *
 * <p>本测试锁定修复后的行为:遇到任意类型混合都返回 null 而**不抛异常**。
 */
public class HisParseSafetyTest {
 
    /** 构造典型报文片段:{@code code=} 为空字符串 */
    private static Map<String, Object> payloadWithEmptyStringCode() {
        Map<String, Object> codeLeaf = new LinkedHashMap<>();
        codeLeaf.put("code", "");              // ← 关键:空字符串而非 Map
        codeLeaf.put("displayName", mapOf("value", "检查结束"));
 
        Map<String, Object> obs = new LinkedHashMap<>();
        obs.put("id", mapOf("item", mapOf("extension", "2126092800574")));
        obs.put("code", "");                   // ← 关键:observationRequest.code 是空字符串
        obs.put("component1", mapOf("processStep", mapOf("code", codeLeaf)));
        obs.put("component2", mapOf("observationRequest", mapOf("code", "")));
 
        return mapOf("controlActProcess",
                mapOf("subject", mapOf("placerGroup", mapOf("component2", obs))));
    }
 
    /** 正常形态:{@code code} 是 Map(按 S0201ECG 真实结构构造) */
    private static Map<String, Object> payloadWithMapCode() {
        // 检查状态:component2.observationRequest.component1.processStep.code.code
        Map<String, Object> statusCode = mapOf(
                "code", "7",
                "codeSystem", "2.16.156.10011.2.5.1.12",
                "displayName", mapOf("value", "检查结束"));
        Map<String, Object> processStep = mapOf("code", statusCode);
        Map<String, Object> component1 = mapOf("processStep", processStep);
 
        // 检查项目:component2.observationRequest.code.code
        Map<String, Object> checkCode = mapOf(
                "code", "Y1202065",
                "displayName", mapOf("value", "床边心电图(常规+频谱)"));
 
        Map<String, Object> innerObs = mapOf(
                "id", mapOf("item", mapOf("root", "2.16.156.10011.1.28", "extension", "113034157")),
                "code", checkCode,
                "component1", component1);
 
        Map<String, Object> component2 = mapOf("observationRequest", innerObs);
 
        Map<String, Object> outerObs = mapOf("component2", component2);
 
        return mapOf("controlActProcess",
                mapOf("subject",
                    mapOf("observationRequest", outerObs)));
    }
 
    private static Map<String, Object> mapOf(Object... kv) {
        Map<String, Object> m = new LinkedHashMap<>();
        for (int i = 0; i + 1 < kv.length; i += 2) {
            m.put((String) kv[i], kv[i + 1]);
        }
        return m;
    }
 
    /** 反射调用私有的 dig 方法 */
    private static Map<String, Object> invokeDig(Map<String, Object> root, String... path) throws Exception {
        Method m = AppointmentServiceImpl.class.getDeclaredMethod("dig", Map.class, String[].class);
        m.setAccessible(true);
        @SuppressWarnings("unchecked")
        Map<String, Object> r = (Map<String, Object>) m.invoke(null, root, path);
        return r;
    }
 
    /** 反射调用私有的 digText 方法 */
    private static String invokeDigText(Map<String, Object> root, String... path) throws Exception {
        Method m = AppointmentServiceImpl.class.getDeclaredMethod("digText", Map.class, String[].class);
        m.setAccessible(true);
        return (String) m.invoke(null, root, path);
    }
 
    // ==================== 核心:不得抛 ClassCastException ====================
 
    @Test
    public void testDigText_emptyStringInPath_shouldReturnNullNotThrow() throws Exception {
        Map<String, Object> payload = payloadWithEmptyStringCode();
        // code 是空字符串 → 应安全返回 null,而不是抛 ClassCastException
        String v = invokeDigText(payload, "controlActProcess", "subject", "placerGroup",
                "component2", "observationRequest", "code", "code");
        assertNull(v);
    }
 
    @Test
    public void testDigText_stringWhereMapExpected_shouldNotThrow() throws Exception {
        // 整条路径上放字符串,模拟最恶劣的形态
        Map<String, Object> payload = mapOf("controlActProcess", "I-AM-A-STRING");
        assertNull(invokeDigText(payload, "controlActProcess", "subject", "placerGroup"));
    }
 
    @Test
    public void testDig_emptyStringInPath_shouldReturnNullNotThrow() throws Exception {
        Map<String, Object> payload = payloadWithEmptyStringCode();
        assertNull(invokeDig(payload, "controlActProcess", "subject", "placerGroup",
                "component2", "observationRequest", "component1", "processStep", "code", "ZDY"));
    }
 
    @Test
    public void testDig_nullInPath_shouldReturnNull() throws Exception {
        assertNull(invokeDig(mapOf("a", null), "a", "b"));
        assertNull(invokeDig(new LinkedHashMap<>(), "notExist"));
    }
 
    @Test
    public void testDigText_listInPath_shouldTakeFirstElement() throws Exception {
        // item=[{code=X}] 形态:digText 应自动取首元素继续下钻
        Map<String, Object> payload = mapOf("methodCode",
                mapOf("item", Collections.singletonList(mapOf("code", "M001"))));
        assertEquals("M001", invokeDigText(payload, "methodCode", "item", "code"));
    }
 
    @Test
    public void testDigText_emptyListInPath_shouldReturnNull() throws Exception {
        Map<String, Object> payload = mapOf("methodCode", mapOf("item", Collections.emptyList()));
        assertNull(invokeDigText(payload, "methodCode", "item", "code"));
    }
 
    @Test
    public void testDigText_shouldStillReadNormalValues() throws Exception {
        Map<String, Object> payload = payloadWithMapCode();
        // 正常形态必须照常读出,不能因加了防御而失效
        // 与生产代码 extractCheckCodeFromComponent2 使用的是**同样的路径写法**
        assertEquals("Y1202065", invokeDigText(payload, "controlActProcess", "subject",
                "observationRequest", "component2", "observationRequest", "code", "code"));
    }
 
    @Test
    public void testDigText_shouldReadProcessStepStatus() throws Exception {
        // 检查状态码:controlActProcess.subject.observationRequest.component2
        //            .observationRequest.component1.processStep.code.code
        Map<String, Object> payload = payloadWithMapCode();
        assertEquals("7", invokeDigText(payload, "controlActProcess", "subject",
                "observationRequest", "component2", "observationRequest",
                "component1", "processStep", "code", "code"));
    }
 
    @Test
    public void testDigText_basicTraversal() throws Exception {
        // 最基础:三层 Map 取值
        Map<String, Object> payload = mapOf("a", mapOf("b", mapOf("c", "VALUE")));
        assertEquals("VALUE", invokeDigText(payload, "a", "b", "c"));
        // 两层
        assertEquals("X", invokeDigText(mapOf("a", mapOf("b", "X")), "a", "b"));
        // 一层
        assertEquals("Y", invokeDigText(mapOf("a", "Y"), "a"));
        // Map 末端取 value
        assertEquals("Z", invokeDigText(mapOf("a", mapOf("b", mapOf("value", "Z"))), "a", "b"));
    }
 
    @Test
    public void testDigText_blankString_shouldReturnNull() throws Exception {
        // 空/纯空白字符串应视为"无值",便于上层用 isEmpty 判断
        Map<String, Object> payload = mapOf("k", "   ");
        assertNull(invokeDigText(payload, "k"));
        assertNull(invokeDigText(mapOf("k", ""), "k"));
    }
 
    // ==================== 医嘱号提取(findExtensionByRoot) ====================
    //
    // 依据 HIS《交互服务通用接口文档》:
    //   6.1 申请单新增服务 → observationRequest.id.item[root=2.16.156.10011.1.28] 注释为「医嘱ID」
    //   6.4 检查预约状态服务 → 同一 root 注释为「医嘱号」
    // 实测 S0201ECG 220 条、S0202ECG 29 条均携带该值(形如 112663505)。
    //
    // 关键风险:id.item 在部分服务中是**数组**,同时含
    //   1.24 申请单号 与 1.28 医嘱号
    // 若用 digText(遇 List 只取首元素)会把**申请单号当成医嘱号**。
    // 下列用例锁定「按 root 匹配」的正确行为。
 
    private static final String ROOT_ORDER_NO = "2.16.156.10011.1.28";
    private static final String ROOT_APPLY_NO = "2.16.156.10011.1.24";
 
    /** 反射调用私有的 findExtensionByRoot 方法 */
    private static String invokeFindExtensionByRoot(Object idNode, String root) throws Exception {
        Method m = AppointmentServiceImpl.class.getDeclaredMethod("findExtensionByRoot", Object.class, String.class);
        m.setAccessible(true);
        return (String) m.invoke(null, idNode, root);
    }
 
    @Test
    public void testFindExtensionByRoot_singleMap_shouldReturnExtension() throws Exception {
        // 实测 S0201ECG 的真实形态:id={item={root=1.28, extension=112663505}}
        Object idNode = mapOf("item", mapOf("root", ROOT_ORDER_NO, "extension", "112663505"));
        assertEquals("112663505", invokeFindExtensionByRoot(idNode, ROOT_ORDER_NO));
    }
 
    @Test
    public void testFindExtensionByRoot_listWithBothRoots_shouldPickOrderNo() throws Exception {
        // 关键用例:数组同时含 1.24(申请单号) 与 1.28(医嘱号)
        // digText 会取首元素(申请单号)→ 错;findExtensionByRoot 必须按 root 命中 1.28
        List<Object> items = Arrays.asList(
                mapOf("root", ROOT_APPLY_NO, "extension", "2126091700011"),
                mapOf("root", ROOT_ORDER_NO, "extension", "112663505"));
        Object idNode = mapOf("item", items);
        assertEquals("112663505", invokeFindExtensionByRoot(idNode, ROOT_ORDER_NO));
    }
 
    @Test
    public void testFindExtensionByRoot_listWithBothRoots_reverseOrder_shouldStillPickOrderNo() throws Exception {
        // 顺序颠倒也必须命中(不能依赖"恰好第一个是医嘱号")
        List<Object> items = Arrays.asList(
                mapOf("root", ROOT_ORDER_NO, "extension", "112663700"),
                mapOf("root", ROOT_APPLY_NO, "extension", "2126091700012"));
        assertEquals("112663700", invokeFindExtensionByRoot(mapOf("item", items), ROOT_ORDER_NO));
    }
 
    @Test
    public void testFindExtensionByRoot_listWithoutOrderNo_shouldReturnNull() throws Exception {
        // 只有申请单号时,绝不能把申请单号当医嘱号返回
        List<Object> items = Collections.singletonList(
                mapOf("root", ROOT_APPLY_NO, "extension", "2126091700011"));
        assertNull(invokeFindExtensionByRoot(mapOf("item", items), ROOT_ORDER_NO));
    }
 
    @Test
    public void testFindExtensionByRoot_emptyOrBlankExtension_shouldReturnNull() throws Exception {
        // 规范中该节点 extension 可为空(6.1 示例即 extension="")→ 应返回 null 而非空串
        assertNull(invokeFindExtensionByRoot(
                mapOf("item", mapOf("root", ROOT_ORDER_NO, "extension", "")), ROOT_ORDER_NO));
        assertNull(invokeFindExtensionByRoot(
                mapOf("item", mapOf("root", ROOT_ORDER_NO, "extension", "   ")), ROOT_ORDER_NO));
        assertNull(invokeFindExtensionByRoot(
                mapOf("item", mapOf("root", ROOT_ORDER_NO)), ROOT_ORDER_NO));
    }
 
    @Test
    public void testFindExtensionByRoot_nullAndMalformed_shouldReturnNullNotThrow() throws Exception {
        assertNull(invokeFindExtensionByRoot(null, ROOT_ORDER_NO));
        assertNull(invokeFindExtensionByRoot(new LinkedHashMap<>(), ROOT_ORDER_NO));
        assertNull(invokeFindExtensionByRoot(mapOf("item", null), ROOT_ORDER_NO));
        assertNull(invokeFindExtensionByRoot(mapOf("item", Collections.emptyList()), ROOT_ORDER_NO));
        // item 是字符串(类型不稳定)→ 不得抛异常
        assertNull(invokeFindExtensionByRoot(mapOf("item", "I-AM-A-STRING"), ROOT_ORDER_NO));
        // root 缺失
        assertNull(invokeFindExtensionByRoot(mapOf("item", mapOf("extension", "X")), ROOT_ORDER_NO));
        // root 为 null
        assertNull(invokeFindExtensionByRoot(mapOf("item", mapOf("root", null, "extension", "X")), ROOT_ORDER_NO));
    }
 
    @Test
    public void testFindExtensionByRoot_listContainingNonMap_shouldSkipGracefully() throws Exception {
        List<Object> items = Arrays.asList(
                "NOT-A-MAP",
                mapOf("root", ROOT_ORDER_NO, "extension", "112663932"));
        assertEquals("112663932", invokeFindExtensionByRoot(mapOf("item", items), ROOT_ORDER_NO));
    }
 
    @Test
    public void testFindExtensionByRoot_rootWithWhitespace_shouldStillMatch() throws Exception {
        // 报文里 root 可能带空白,需 trim 后比较
        Object idNode = mapOf("item", mapOf("root", "  " + ROOT_ORDER_NO + "  ", "extension", "112664036"));
        assertEquals("112664036", invokeFindExtensionByRoot(idNode, ROOT_ORDER_NO));
    }
 
    // ======================================================================
    // 需求2:ISCB「是否床边」解析(zdy 自定义字段区)
    // ======================================================================
 
    /** 反射调用私有的 findBedsideFlag(dataMap) */
    private static String invokeFindBedsideFlag(Map<String, Object> dataMap) throws Exception {
        Method m = AppointmentServiceImpl.class.getDeclaredMethod("findBedsideFlag", Map.class);
        m.setAccessible(true);
        return (String) m.invoke(null, dataMap);
    }
 
    /** 按真实 S0201ECG 结构构造含 zdy 的报文 */
    private static Map<String, Object> payloadWithZdy(Map<String, Object> zdyContent) {
        Map<String, Object> obs = new LinkedHashMap<>();
        obs.put("zdy", zdyContent);
        return mapOf("controlActProcess", mapOf("subject", mapOf("observationRequest", obs)));
    }
 
    @Test
    public void testFindBedsideFlag_presentZero_shouldReturnZero() throws Exception {
        // 实测真实报文中 ISCB 恒为 "0"
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("ISCB", "0");
        zdy.put("ISDN", "0");
        assertEquals("0", invokeFindBedsideFlag(payloadWithZdy(zdy)));
    }
 
    @Test
    public void testFindBedsideFlag_presentOne_shouldReturnOne() throws Exception {
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("ISCB", "1");
        assertEquals("1", invokeFindBedsideFlag(payloadWithZdy(zdy)));
    }
 
    @Test
    public void testFindBedsideFlag_absent_shouldReturnNull() throws Exception {
        // 官方文档定义的 19 个字段里没有 ISCB —— 这类报文应返回 null(不写库)
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("jjbz", "N");
        zdy.put("babyFlag", "0");
        assertNull(invokeFindBedsideFlag(payloadWithZdy(zdy)));
    }
 
    @Test
    public void testFindBedsideFlag_emptyString_shouldReturnNull() throws Exception {
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("ISCB", "");
        assertNull("空字符串应视作未提供", invokeFindBedsideFlag(payloadWithZdy(zdy)));
 
        Map<String, Object> zdy2 = new LinkedHashMap<>();
        zdy2.put("ISCB", "   ");
        assertNull("纯空白应视作未提供", invokeFindBedsideFlag(payloadWithZdy(zdy2)));
    }
 
    @Test
    public void testFindBedsideFlag_nonStringValue_shouldToString() throws Exception {
        // Jackson 反序列化后标量类型可能是 Integer/Boolean,需稳妥 toString
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("ISCB", 1);
        assertEquals("1", invokeFindBedsideFlag(payloadWithZdy(zdy)));
 
        Map<String, Object> zdy2 = new LinkedHashMap<>();
        zdy2.put("ISCB", 0);
        assertEquals("0", invokeFindBedsideFlag(payloadWithZdy(zdy2)));
    }
 
    @Test
    public void testFindBedsideFlag_whitespaceAroundValue_shouldTrim() throws Exception {
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("ISCB", "  1  ");
        assertEquals("1", invokeFindBedsideFlag(payloadWithZdy(zdy)));
    }
 
    @Test
    public void testFindBedsideFlag_nullOrMalformedPayload_shouldReturnNullWithoutThrow() throws Exception {
        // 类型不稳定是 HIS 报文的常态(历史上曾因此抛 ClassCastException 中断整条消息),
        // 这里必须**不抛异常**
        assertNull(invokeFindBedsideFlag(null));
        assertNull(invokeFindBedsideFlag(mapOf()));
        assertNull("controlActProcess 是字符串", invokeFindBedsideFlag(mapOf("controlActProcess", "x")));
        assertNull("subject 是空字符串", invokeFindBedsideFlag(
                mapOf("controlActProcess", mapOf("subject", ""))));
        assertNull("observationRequest 缺失", invokeFindBedsideFlag(
                mapOf("controlActProcess", mapOf("subject", mapOf()))));
        assertNull("zdy 是字符串而非 Map", invokeFindBedsideFlag(
                mapOf("controlActProcess", mapOf("subject",
                        mapOf("observationRequest", mapOf("zdy", "not-a-map"))))));
    }
 
    @Test
    public void testFindBedsideFlag_realisticFullZdy_shouldReturnValue() throws Exception {
        // 真实报文的 zdy 有 26 个字段(文档只定义 19 个),按日志原样构造关键部分
        Map<String, Object> zdy = new LinkedHashMap<>();
        zdy.put("ApplyTotalFee", "19");
        zdy.put("Urgency", "");
        zdy.put("PayStatus", "1");
        zdy.put("HospitalCode", "01");
        zdy.put("HospitalName", "金华市人民医院");
        zdy.put("jjbz", "N");
        zdy.put("babyFlag", "0");
        zdy.put("ISCB", "1");            // ← 需求2 关注的字段
        zdy.put("ISDN", "0");
        zdy.put("BLLSH", "3054365");
        zdy.put("SWCJBS", "0");
        zdy.put("KDYSSJH", "15158660102");
        zdy.put("KLX", "");
        zdy.put("VTEGWBZ", "0");
        assertEquals("1", invokeFindBedsideFlag(payloadWithZdy(zdy)));
    }
}