Browse Source

修拾忆页第一次打开就报错:解包口径写错 + 红条抑制漏了 memory_list

两个都是我上一轮引入的,表现叠在一起就是「第一次打开拾忆界面抛异常」。

## 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<String, dynamic>.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) <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
46c815eb87
  1. 6
      apps/client/ios/Podfile.lock
  2. 23
      apps/client/lib/data/services/memory_service.dart
  3. 18
      apps/client/lib/data/services/network/auth_interceptor.dart
  4. 53
      apps/client/test/memory_service_unwrap_test.dart

6
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

23
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<String, dynamic>? _data(dynamic resp) {
if (resp == null) return null;
if (resp.data == null) return null;
if (resp.data is Map) return Map<String, dynamic>.from(resp.data as Map);
if (resp is Map) return Map<String, dynamic>.from(resp);
return null;
}

18
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 字段就是英文内部状态词

53
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<String, dynamic>? unwrap(dynamic resp) {
if (resp == null) return null;
if (resp is Map) return Map<String, dynamic>.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(<String, dynamic>{});
// {} 表示「成功但没数据」,必须是非 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);
});
});
}
Loading…
Cancel
Save