From 46c815eb872ee774a8e2c7d6f02f8ab75749dc5f Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sat, 5 Sep 2026 08:12:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=8B=BE=E5=BF=86=E9=A1=B5=E7=AC=AC?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E6=89=93=E5=BC=80=E5=B0=B1=E6=8A=A5=E9=94=99?= =?UTF-8?q?=EF=BC=9A=E8=A7=A3=E5=8C=85=E5=8F=A3=E5=BE=84=E5=86=99=E9=94=99?= =?UTF-8?q?=20+=20=E7=BA=A2=E6=9D=A1=E6=8A=91=E5=88=B6=E6=BC=8F=E4=BA=86?= =?UTF-8?q?=20memory=5Flist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个都是我上一轮引入的,表现叠在一起就是「第一次打开拾忆界面抛异常」。 ## 1) MemoryService 的解包口径写错(严重,后端一部署就全线失效) DioManager.request 结尾是 `return response.data;` —— 返回的是 **data 本身**, 不是 Response。全仓既有调用方都直接下标取字段(my_devices 的 response['devices']、 bes_device_auth 的 resp['device'])。 而 MemoryService._data 写的是 `resp.data`。因为 request 的返回类型是 dynamic, **编译与 dart analyze 都不报错**;后端还没部署时返回 null,正好走空分支, 所以本地一路绿灯。一旦服务端真的返回成功,每一次 memory 调用都会抛 NoSuchMethodError —— 也就是说这个功能在部署之后才会开始全面失效。 已改成 `if (resp is Map) return Map.from(resp);`, 并加 test/memory_service_unwrap_test.dart 把四种形状(成功有数据 / 成功空 {} / 业务错误 null / 传进来个对象)都钉住,任何人改回 .data 都会红。 ## 2) 红条抑制名单漏了 memory_list 上一轮只加了 memory_today / memory_upcoming,**漏了 memory_list** —— 而它正是 打开拾忆页第一个调的接口(TodoTab.initState → refreshFromServer)。 后端没部署时网关返回 NoFindServiceHandleFunc,AuthInterceptor 就把 "[Handle Http] no found handle memory_list" 原样弹给用户。 改成按 `/api/home/memory_` 前缀整条抑制,读写两种情况都成立: 读在打开页面与切前台时自动调,页面自己有行内空态与「显示的是本地缓存」提示; 写的调用点各自弹了本地化的 memSaveFailed / memDeleteFailed / memCompleteFailed, 不挡的话会弹两条。逐个列举正是漏掉 memory_list 的原因,改前缀。 Co-Authored-By: Claude Opus 5 (1M context) --- apps/client/ios/Podfile.lock | 6 +++ .../lib/data/services/memory_service.dart | 23 +++++--- .../services/network/auth_interceptor.dart | 18 ++++--- .../test/memory_service_unwrap_test.dart | 53 +++++++++++++++++++ 4 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 apps/client/test/memory_service_unwrap_test.dart diff --git a/apps/client/ios/Podfile.lock b/apps/client/ios/Podfile.lock index e10670e1..6dd9dfc3 100644 --- a/apps/client/ios/Podfile.lock +++ b/apps/client/ios/Podfile.lock @@ -18,6 +18,8 @@ PODS: - Flutter (1.0.0) - flutter_local_notifications (0.0.1): - Flutter + - flutter_timezone (0.0.1): + - Flutter - flutter_tts (0.0.1): - Flutter - fluwx (0.0.1): @@ -62,6 +64,7 @@ DEPENDENCIES: - floating_ui_plugin (from `.symlinks/plugins/floating_ui_plugin/ios`) - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`) - fluwx (from `.symlinks/plugins/fluwx/ios`) - haptic_feedback (from `.symlinks/plugins/haptic_feedback/ios`) @@ -97,6 +100,8 @@ EXTERNAL SOURCES: :path: Flutter flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_timezone: + :path: ".symlinks/plugins/flutter_timezone/ios" flutter_tts: :path: ".symlinks/plugins/flutter_tts/ios" fluwx: @@ -125,6 +130,7 @@ SPEC CHECKSUMS: floating_ui_plugin: d218b25dcd922d3a66a536dd2190ecd47451e5a8 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_local_notifications: df98d66e515e1ca797af436137b4459b160ad8c9 + flutter_timezone: ffb07bdad3c6276af8dada0f11978d8a1f8a20bb flutter_tts: 91ad1884033cd95ef6e9338754adad54f01f090f fluwx: 95a2274c23418c1098940bd00dcfe7c975bb0550 haptic_feedback: 8cb3ee0cb6b797946c2b0df9c99ebc8f7ed79c71 diff --git a/apps/client/lib/data/services/memory_service.dart b/apps/client/lib/data/services/memory_service.dart index 7eb7fd29..df2c66a8 100644 --- a/apps/client/lib/data/services/memory_service.dart +++ b/apps/client/lib/data/services/memory_service.dart @@ -106,17 +106,24 @@ class MemoryService extends GetxService { } } - /// 把服务端返回的 data 取成 Map。 + /// 把 `Api.*` 的返回值取成 Map。 /// - /// ⚠️ AuthInterceptor 在 code==0 且 data 为空时把 data 换成了 `{}` 而不是 null, - /// 在业务错误时才把 data 置成 null 并 resolve(**不抛**)。所以: - /// resp == null → 服务端明确返回了业务错误 - /// resp == {} → 成功但没数据 - /// 两者含义相反,绝不能合并判断。 + /// ⚠️ **`DioManager.request` 返回的是 `response.data` 本身,不是 Response** + /// (见 dio_manager.dart 结尾那句 `return response.data;`)。全仓的既有调用方 + /// 都是直接下标取字段的(`response['devices']`、`resp['device']`), + /// 这里也必须如此。 + /// 早先这里写成 `resp.data` —— 因为返回类型是 dynamic,编译与 dart analyze + /// 都不报错,只会在**服务端真的返回成功时**抛 NoSuchMethodError, + /// 而后端没部署时反倒看不出来(那时返回的是 null,正好走空分支)。 + /// + /// ⚠️ null 与 {} 含义相反,绝不能合并判断: + /// AuthInterceptor 在业务码 != 0 时把 data 置 null 后 resolve(**不抛**), + /// code==0 且 data 为空时给的是 `{}`。所以 + /// null = 服务端明确返回业务错误,{} = 成功但没数据。 + /// 网络层出问题才抛 DioException,由各调用点的 try/catch 兜。 Map? _data(dynamic resp) { if (resp == null) return null; - if (resp.data == null) return null; - if (resp.data is Map) return Map.from(resp.data as Map); + if (resp is Map) return Map.from(resp); return null; } diff --git a/apps/client/lib/data/services/network/auth_interceptor.dart b/apps/client/lib/data/services/network/auth_interceptor.dart index 2b596c46..47ed97b9 100644 --- a/apps/client/lib/data/services/network/auth_interceptor.dart +++ b/apps/client/lib/data/services/network/auth_interceptor.dart @@ -23,13 +23,19 @@ class AuthInterceptor extends Interceptor { // 恒玄链路(BesDeviceAuth)要弹的是「设备连接受限」,杰理的手动绑定按钮 // (DevicesController.bindAndConnect)弹的是 deviceUnauthorized。 // 不挡住的话用户会先看到一条后端的英文内部状态词(AuthorizeNoCanUse)。 - // memory_today / memory_upcoming 是**启动与前台恢复时自动调**的:用户什么都没点 - // 就先吃一条红条(消息还是后端的英文内部状态词)体验极差。拾忆页改用行内空态/ - // 错误态表达,出错就静默降级成本地缓存。 + // 记忆中心(拾忆)整条前缀都不弹红条,分两种情况都成立: + // 读(list/today/upcoming/getreport):在打开页面与切前台时**自动调**, + // 用户什么都没点就先吃一条红条体验极差,而且消息是后端的英文内部状态词 + // (后端没部署 memory 模块时是 "[Handle Http] no found handle memory_list")。 + // 页面自己有行内空态与「当前显示的是本地缓存」提示。 + // 写(add/update/del/complete/confirmreport):调用方各自弹了本地化的 + // memSaveFailed / memDeleteFailed / memCompleteFailed,不挡的话会弹两条。 + // + // ⚠️ 按前缀而不是逐个列举:之前逐个列举时漏了 memory_list —— 而它正是打开 + // 拾忆页第一个调的接口,于是「第一次打开就弹一句看不懂的英文」。 + if (path.startsWith('/api/home/memory_')) return true; return path == '/api/home/user_getinfo' || - path == '/api/home/user_binddevice' || - path == '/api/home/memory_today' || - path == '/api/home/memory_upcoming'; + path == '/api/home/user_binddevice'; } /// 后端在未登录时会给一些接口返回业务错误码,msg 字段就是英文内部状态词 diff --git a/apps/client/test/memory_service_unwrap_test.dart b/apps/client/test/memory_service_unwrap_test.dart new file mode 100644 index 00000000..ecb2d66a --- /dev/null +++ b/apps/client/test/memory_service_unwrap_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; + +/// 守住 `Api.*` 返回值的解包口径。 +/// +/// 背景:`DioManager.request` 结尾是 `return response.data;` —— 返回的是**data 本身**, +/// 不是 Response。全仓既有调用方都直接下标取字段(`response['devices']`)。 +/// +/// 早先 MemoryService._data 写成了 `resp.data`。因为 request 的返回类型是 dynamic, +/// **编译和 dart analyze 都不报错**,而且后端没部署时返回 null,正好走空分支看不出来; +/// 一旦后端真的返回成功,每一次调用都会抛 NoSuchMethodError。 +/// +/// 这个用例把两种形状都过一遍,任何人再改回 `.data` 都会在这里红。 +void main() { + group('Api 返回值解包', () { + // 与 MemoryService._data 同一份逻辑(那个是私有方法,这里复刻一份守住口径) + Map? unwrap(dynamic resp) { + if (resp == null) return null; + if (resp is Map) return Map.from(resp); + return null; + } + + test('成功:直接拿到 data Map,能取到字段', () { + final resp = { + 'items': [ + {'id': 1, 'title': '开会'} + ], + 'total': 1, + }; + final data = unwrap(resp); + expect(data, isNotNull); + expect(data!['total'], 1); + expect((data['items'] as List).length, 1); + }); + + test('成功但没数据:AuthInterceptor 给的是 {} 而不是 null', () { + final data = unwrap({}); + // {} 表示「成功但没数据」,必须是非 null —— 与业务错误的 null 含义相反 + expect(data, isNotNull); + expect(data, isEmpty); + }); + + test('业务错误:AuthInterceptor 把 data 置 null 并 resolve(不抛)', () { + expect(unwrap(null), isNull); + }); + + test('拿到 Response 这类对象(口径写错时的形状)一律当无数据,不能崩', () { + // 万一有人把 Response 传进来,宁可返回 null 也不要抛 + expect(unwrap(Object()), isNull); + expect(unwrap('unexpected string'), isNull); + expect(unwrap(42), isNull); + }); + }); +}