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); + }); + }); +}